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,190 @@
1
+ import { BUDGET_CHEAP_AT } from "../domain/budget.ts";
2
+ import { errorMessage } from "../domain/errors.ts";
3
+ import {
4
+ type JevAnswers,
5
+ judgeRoute,
6
+ judgeVerdict,
7
+ VERDICT_OPTIONS,
8
+ laneState,
9
+ type SetName,
10
+ scrubFree,
11
+ } from "../domain/jev.ts";
12
+ import { type Difficulty, type Kind, parseLaneHeader } from "../domain/lane.ts";
13
+ import type { Role } from "../domain/roles.ts";
14
+ import type { RouteJev } from "../domain/route.ts";
15
+ import {
16
+ candidates,
17
+ defaultDifficulty,
18
+ defaultLadder,
19
+ type Pick,
20
+ type RoutingProfile,
21
+ select,
22
+ } from "../domain/select.ts";
23
+ import { log } from "../infra/log.ts";
24
+ import { catalogQuery, freshenDiscovery, loadCatalog } from "./catalog-service.ts";
25
+ import { type Asked, askJev, type JevOpts, jevQuestions, logJev } from "./jev-service.ts";
26
+ import type { ProfileView, RouteAnswer, RouteRequest, RoutingPort, Verdict } from "./ports.ts";
27
+
28
+ function routingProfile(v: ProfileView, role: Role, spentFraction: number): RoutingProfile {
29
+ const rc = v.roles[role];
30
+ return {
31
+ // spec §4.6: from 80 % of the budget on, start at the cheapest rung that clears the bar
32
+ objective: spentFraction >= BUDGET_CHEAP_AT ? "cost" : v.objective,
33
+ billing: v.billing,
34
+ role: {
35
+ enabled: rc?.enabled ?? false,
36
+ rungs: rc?.rungs ?? [],
37
+ ...(rc?.defaultRung ? { defaultRung: rc.defaultRung } : {}),
38
+ },
39
+ };
40
+ }
41
+
42
+ const answer = (
43
+ pick: Pick,
44
+ source: RouteAnswer["source"],
45
+ kind: Kind | null,
46
+ difficulty: Difficulty | null,
47
+ asked: Asked | null,
48
+ jev: RouteJev | null,
49
+ ): RouteAnswer => ({
50
+ ...pick,
51
+ source,
52
+ kind,
53
+ difficulty,
54
+ questionSet: asked?.answers ? asked.meta.questionSet : null,
55
+ jev,
56
+ });
57
+
58
+ /** How long `route` waits on the daily discovery refresh before routing on the cached listing. */
59
+ const DISCOVERY_BUDGET_MS = 5_000;
60
+
61
+ export interface RoutingOpts extends JevOpts {
62
+ /** how long a route waits on the discovery refresh (DISCOVERY_BUDGET_MS) */
63
+ discoveryBudgetMs?: number;
64
+ }
65
+
66
+ /** A wedged listing never holds a route: past `ms` it routes on the cache while the refresh finishes. */
67
+ async function freshenWithin(rungs: string[], repo: string, ms: number): Promise<void> {
68
+ const refresh = freshenDiscovery(rungs, Date.now(), repo).catch((e: unknown) =>
69
+ log("debug", "discovery", { error: errorMessage(e) }),
70
+ );
71
+ let timer: ReturnType<typeof setTimeout> | undefined;
72
+ const budget = new Promise<void>((resolve) => {
73
+ timer = setTimeout(resolve, ms);
74
+ });
75
+ await Promise.race([refresh, budget]);
76
+ clearTimeout(timer);
77
+ }
78
+
79
+ /**
80
+ * Spec §5.4: kind and difficulty from Jev (§5.5's rule), else the lane file's `Kind:`/`Difficulty:`,
81
+ * else the role's default rung. A kind Jev is sure of survives a difficulty in the dead band: the
82
+ * difficulty then comes from the lane, else the role's default difficulty (`jev-kind`). A role with one
83
+ * usable rung never asks Jev.
84
+ */
85
+ async function route(req: RouteRequest, o: RoutingOpts): Promise<RouteAnswer> {
86
+ const p = routingProfile(req.profile, req.role, req.spentFraction);
87
+ await freshenWithin(p.role.rungs, req.repo, o.discoveryBudgetMs ?? DISCOVERY_BUDGET_MS);
88
+ const c = loadCatalog({ repo: req.repo });
89
+ const fallback = () => defaultLadder(c, p, req.role);
90
+ if (req.laneText === null || candidates(c, p, req.role).length <= 1)
91
+ return answer(fallback(), "default", null, null, null, null);
92
+ const lane = parseLaneHeader(req.laneText);
93
+ let asked: Asked | null = null;
94
+ let judged: ReturnType<typeof judgeRoute> | null = null;
95
+ if (req.profile.jev.use !== "off") {
96
+ asked = await askJev(req.runDir, "route-v2", laneState(req.laneText), o);
97
+ if (asked.answers) judged = judgeRoute(jevQuestions().sets["route-v2"].rule, asked.answers);
98
+ }
99
+ const jev: RouteJev | null = judged
100
+ ? { pKind: judged.pKind, pA: judged.pA, pB: judged.pB, nouls: judged.nouls }
101
+ : null;
102
+ let out: RouteAnswer;
103
+ if (judged?.track && judged.difficulty) {
104
+ const kind = judged.kind ?? lane.kind ?? "repo_code";
105
+ out = answer(select(c, p, req.role, kind, judged.difficulty), "jev", kind, judged.difficulty, asked, jev);
106
+ } else if (judged?.kind) {
107
+ const difficulty = lane.difficulty ?? defaultDifficulty(c, p, req.role, judged.kind);
108
+ out = answer(
109
+ select(c, p, req.role, judged.kind, difficulty),
110
+ "jev-kind",
111
+ judged.kind,
112
+ difficulty,
113
+ asked,
114
+ jev,
115
+ );
116
+ } else {
117
+ const kind = lane.kind;
118
+ out =
119
+ kind && lane.difficulty
120
+ ? answer(select(c, p, req.role, kind, lane.difficulty), "lane", kind, lane.difficulty, asked, jev)
121
+ : answer(fallback(), "default", null, null, asked, jev);
122
+ }
123
+ if (asked) {
124
+ logJev(req.runDir, {
125
+ ...asked.meta,
126
+ call: "route-v2",
127
+ lane: req.lane,
128
+ answers: asked.answers,
129
+ derived: judged
130
+ ? { ...jev, kind: judged.kind, track: judged.track, difficulty: judged.difficulty }
131
+ : null,
132
+ used: `${req.role} ${out.rung}`,
133
+ source: out.source,
134
+ why: asked.why ?? (judged ? judged.rule : "no answers"),
135
+ });
136
+ }
137
+ return out;
138
+ }
139
+
140
+ async function verdict<T extends string>(
141
+ runDir: string,
142
+ set: Extract<SetName, "finding" | "same-defect">,
143
+ state: Record<string, unknown>,
144
+ options: readonly T[],
145
+ use: "auto" | "off",
146
+ o: JevOpts,
147
+ ): Promise<Verdict<T>> {
148
+ const rule = jevQuestions().sets[set].rule;
149
+ // spec §5.5: Jev off means no question and no jev.jsonl row; the rule's default answers
150
+ if (use === "off") return judgeVerdict(rule, set, options, null);
151
+ const asked = await askJev(runDir, set, state, o);
152
+ const v = judgeVerdict(rule, set, options, asked.answers as JevAnswers | null);
153
+ logJev(runDir, {
154
+ ...asked.meta,
155
+ call: set,
156
+ lane: null,
157
+ answers: asked.answers,
158
+ derived: null,
159
+ used: v.value,
160
+ source: v.source,
161
+ why: asked.why ?? `p ${v.probability} ${v.source === "jev" ? "≥" : "<"} ${rule.min}`,
162
+ });
163
+ return v;
164
+ }
165
+
166
+ /** The routing port over the 1.0 catalog and Jev (spec §5); `o` lets tests inject Jev's transport. */
167
+ export function routingService(o: RoutingOpts = {}): RoutingPort {
168
+ return {
169
+ route: (req) => route(req, o),
170
+ finding: (runDir, laneText, finding, use) =>
171
+ verdict(
172
+ runDir,
173
+ "finding",
174
+ { lane: laneState(laneText), finding: scrubFree(finding) },
175
+ VERDICT_OPTIONS.finding,
176
+ use,
177
+ o,
178
+ ),
179
+ sameDefect: (runDir, before, after, use) =>
180
+ verdict(
181
+ runDir,
182
+ "same-defect",
183
+ { before: scrubFree(before), after: scrubFree(after) },
184
+ VERDICT_OPTIONS["same-defect"],
185
+ use,
186
+ o,
187
+ ),
188
+ catalog: (filter, billing) => catalogQuery(filter, billing),
189
+ };
190
+ }
@@ -0,0 +1,79 @@
1
+ import { closeSync, fstatSync, openSync, readSync } from "node:fs";
2
+ import type { ExitInfo, RunRecord } from "../domain/record.ts";
3
+ import { dispatchPaths, readExit } from "../infra/dispatch-dir.ts";
4
+ import { redact } from "../infra/log.ts";
5
+ import { listDispatches } from "./dispatches.ts";
6
+ import { registerSavedSecrets } from "./jev-service.ts";
7
+ import { readRecords, type Run } from "./run-store.ts";
8
+
9
+ /** How many trailing lines of stderr, events and the supervisor log `runs show --debug` prints. */
10
+ export const TAIL_LINES = 20;
11
+
12
+ export interface DispatchDebug {
13
+ name: string;
14
+ dispatchId: string;
15
+ rung: string;
16
+ admittedAt: string;
17
+ record: RunRecord | null;
18
+ exit: ExitInfo | null;
19
+ stderrTail: string[];
20
+ eventsTail: string[];
21
+ supervisorTail: string[];
22
+ }
23
+
24
+ /**
25
+ * The last `n` non-blank lines of `file`, read backwards `chunk` bytes at a time until they are in hand:
26
+ * a worker's stderr or event stream can be large, and only its end is shown. [] when there is no file.
27
+ */
28
+ export function tail(file: string, n = TAIL_LINES, chunk = 64 * 1024): string[] {
29
+ let fd: number;
30
+ try {
31
+ fd = openSync(file, "r");
32
+ } catch {
33
+ return [];
34
+ }
35
+ try {
36
+ let pos = fstatSync(fd).size;
37
+ let buf = Buffer.alloc(0);
38
+ for (;;) {
39
+ const lines = buf.toString("utf8").split("\n");
40
+ // the first line may start mid-line, or mid-character: it counts only once the file's start is read
41
+ if (pos > 0) lines.shift();
42
+ const kept = lines.filter((l) => l.trim());
43
+ if (kept.length >= n || pos === 0) return kept.slice(-n);
44
+ const len = Math.min(chunk, pos);
45
+ pos -= len;
46
+ const part = Buffer.alloc(len);
47
+ readSync(fd, part, 0, len, pos);
48
+ buf = Buffer.concat([part, buf]);
49
+ }
50
+ } finally {
51
+ closeSync(fd);
52
+ }
53
+ }
54
+
55
+ /**
56
+ * Spec §10.2 `runs show <id> --debug`: per dispatch, oldest first, its record, exit.json and the tails of
57
+ * stderr, events.jsonl and supervisor.log, with every known secret redacted. Reads only.
58
+ */
59
+ export function runDebug(run: Run, name?: string): DispatchDebug[] {
60
+ registerSavedSecrets();
61
+ const records = new Map(readRecords(run).records.map((r) => [r.dispatchId, r]));
62
+ return listDispatches(run)
63
+ .filter((d) => name === undefined || d.admit.name === name)
64
+ .sort((a, b) => a.admit.dispatchId.localeCompare(b.admit.dispatchId))
65
+ .map((d) => {
66
+ const p = dispatchPaths(d.dir);
67
+ return redact({
68
+ name: d.admit.name,
69
+ dispatchId: d.admit.dispatchId,
70
+ rung: d.admit.rung,
71
+ admittedAt: d.admit.admittedAt,
72
+ record: records.get(d.admit.dispatchId) ?? null,
73
+ exit: readExit(d.dir),
74
+ stderrTail: tail(p.stderr),
75
+ eventsTail: tail(p.events),
76
+ supervisorTail: tail(p.supervisorLog),
77
+ });
78
+ });
79
+ }
@@ -0,0 +1,194 @@
1
+ import { existsSync, readFileSync } from "node:fs";
2
+ import { relative } from "node:path";
3
+ import { CatherdError } from "../domain/errors.ts";
4
+ import { assertId, parseRung } from "../domain/ids.ts";
5
+ import type { RunRecord } from "../domain/record.ts";
6
+ import type { Role } from "../domain/roles.ts";
7
+ import { awaitsCollect, dispatchPaths, endCollect, tryCollect } from "../infra/dispatch-dir.ts";
8
+ import { gitToplevel } from "../infra/git.ts";
9
+ import { writeTextAtomic } from "../infra/store.ts";
10
+ import {
11
+ dispatchState,
12
+ type DispatchState,
13
+ latestDispatch,
14
+ listDispatches,
15
+ recordHints,
16
+ } from "./dispatches.ts";
17
+ import type { Deps } from "./ports.ts";
18
+ import {
19
+ type AgentRun,
20
+ appendAgentRun,
21
+ createRun,
22
+ findRun,
23
+ knowledgeFile,
24
+ readRecords,
25
+ runFile,
26
+ } from "./run-store.ts";
27
+ import { protocolView } from "./protocol.ts";
28
+ import { claimRun, currentSession } from "./sessions.ts";
29
+ import { refreshState } from "./state.ts";
30
+
31
+ export async function startRun(
32
+ deps: Deps,
33
+ i: { repo: string; title: string; aLines: string[] },
34
+ ): Promise<{
35
+ run: string;
36
+ dir: string;
37
+ protocol: { next: string; checklist: string[] };
38
+ hints?: string[];
39
+ }> {
40
+ const top = await gitToplevel(i.repo);
41
+ if (!top)
42
+ throw new CatherdError("E_IO_PATH", `${i.repo} is not inside a git repository`, {
43
+ fix: "pass the path of the repository to work in",
44
+ });
45
+ const run = createRun({
46
+ repo: top,
47
+ title: i.title,
48
+ aLines: i.aLines,
49
+ version: deps.version,
50
+ now: new Date(deps.now()),
51
+ startedBy: currentSession(deps),
52
+ });
53
+ await claimRun(deps, run);
54
+ // a failed state.md refresh never fails the start: the run exists and is usable, so a retry would orphan it
55
+ const { hints } = await refreshState(run);
56
+ // spec 1.1 §10: the milestone loop, so the orchestrator starts on the protocol
57
+ return { run: run.id, dir: run.dir, protocol: protocolView(run, []), ...(hints.length ? { hints } : {}) };
58
+ }
59
+
60
+ export function writeRunFile(i: { run: string; path: string; content: string }): {
61
+ path: string;
62
+ bytes: number;
63
+ } {
64
+ const file = runFile(findRun(i.run), i.path, "write");
65
+ writeTextAtomic(file, i.content);
66
+ return { path: file, bytes: Buffer.byteLength(i.content) };
67
+ }
68
+
69
+ export function readRunFile(i: { run: string; path: string }): string {
70
+ const file = runFile(findRun(i.run), i.path, "read");
71
+ if (!existsSync(file))
72
+ throw new CatherdError("E_IO_PATH", `no file ${i.path} in the run folder`, {
73
+ fix: "pass a path relative to the run folder",
74
+ });
75
+ return readFileSync(file, "utf8");
76
+ }
77
+
78
+ /**
79
+ * `set_next`: state.md's new text, and, as every tool gives them, `hints`: `state.md not refreshed: <message>`
80
+ * when git fails, with `state` null (the note is kept in state.json either way).
81
+ */
82
+ export async function setNext(i: {
83
+ run: string;
84
+ next: string;
85
+ }): Promise<{ state: string | null; hints?: string[] }> {
86
+ const { text, hints } = await refreshState(findRun(i.run), { next: i.next });
87
+ return { state: text ?? null, ...(hints.length ? { hints } : {}) };
88
+ }
89
+
90
+ const CAP_LINES = 250;
91
+ const CAP_CHARS = 20_000;
92
+
93
+ function capReply(reply: string, path: string): string {
94
+ const lines = reply.split("\n");
95
+ const head = lines.length > CAP_LINES ? lines.slice(0, CAP_LINES).join("\n") : reply;
96
+ if (head === reply && reply.length <= CAP_CHARS) return reply;
97
+ return `${head.slice(0, CAP_CHARS)}\n[capped: the full reply is ${path}]`;
98
+ }
99
+
100
+ /**
101
+ * A role's latest dispatch: its record once finished, its capped reply and its hints. Spec §3.7: reading a
102
+ * finished record marks it read, with the collect lease (each record is marked read once, whoever reads it);
103
+ * an earlier record of the same name still unread (a usage limit its stand-in replaced) is marked read with it,
104
+ * and its hints come first.
105
+ */
106
+ export async function result(
107
+ deps: Deps,
108
+ i: { run: string; name: string },
109
+ ): Promise<{
110
+ name: string;
111
+ state: DispatchState | null;
112
+ record: RunRecord | null;
113
+ reply: string;
114
+ replyPath: string | null;
115
+ hints: string[];
116
+ }> {
117
+ const run = findRun(i.run);
118
+ assertId("role name", i.name);
119
+ const d = latestDispatch(run, i.name);
120
+ if (!d)
121
+ throw new CatherdError("E_RUN_NOT_FOUND", `${i.name} was never dispatched in this run`, {
122
+ fix: "status(run) lists the roles",
123
+ });
124
+ const records = new Map(readRecords(run).records.map((r) => [r.dispatchId, r]));
125
+ const record = records.get(d.admit.dispatchId) ?? null;
126
+ const hints: string[] = [];
127
+ for (const x of listDispatches(run).filter((x) => x.admit.name === i.name)) {
128
+ const r = records.get(x.admit.dispatchId);
129
+ if (!r || !awaitsCollect(x.dir) || !(await tryCollect(x.dir))) continue;
130
+ // read at once: the lease ends as the record goes out
131
+ endCollect(x.dir);
132
+ if (x.admit.dispatchId !== d.admit.dispatchId) hints.push(...recordHints(run, x, r));
133
+ }
134
+ if (record) for (const h of recordHints(run, d, record)) if (!hints.includes(h)) hints.push(h);
135
+ const reply = dispatchPaths(d.dir).reply;
136
+ const text = existsSync(reply) ? readFileSync(reply, "utf8") : "";
137
+ return {
138
+ name: i.name,
139
+ state: record ? "finished" : dispatchState(d, deps.now()),
140
+ record,
141
+ reply: capReply(text, relative(run.dir, reply)),
142
+ replyPath: relative(run.dir, reply),
143
+ hints,
144
+ };
145
+ }
146
+
147
+ /** Spec §4.6: what a native Claude subagent used, as the Agent tool reported it; counted in the budget. */
148
+ export function recordAgentRun(
149
+ deps: Deps,
150
+ i: {
151
+ run: string;
152
+ name: string;
153
+ role: Role;
154
+ rung: string;
155
+ totalTokens: number;
156
+ costUsd?: number;
157
+ durationMs?: number;
158
+ status?: AgentRun["status"];
159
+ lane?: string;
160
+ },
161
+ ): AgentRun {
162
+ const run = findRun(i.run);
163
+ assertId("role name", i.name);
164
+ if (i.lane !== undefined) assertId("lane", i.lane);
165
+ if (parseRung(i.rung).backend !== "claude")
166
+ throw new CatherdError("E_ADMIT_RUNG", `${i.rung} is not a native Claude rung`, {
167
+ fix: "record_agent_run is for Agent subagents; dispatch records every other run itself",
168
+ });
169
+ const row: AgentRun = {
170
+ at: new Date(deps.now()).toISOString(),
171
+ name: i.name,
172
+ role: i.role,
173
+ rung: i.rung,
174
+ agent: deps.profiles.agentFor(run.meta.repo, i.role, i.rung),
175
+ totalTokens: i.totalTokens,
176
+ costUsd: i.costUsd ?? null,
177
+ secs: i.durationMs === undefined ? null : Math.round(i.durationMs / 1000),
178
+ status: i.status ?? "ok",
179
+ lane: i.lane ?? null,
180
+ };
181
+ appendAgentRun(run, row);
182
+ return row;
183
+ }
184
+
185
+ /** What past runs of the repo learned; `repo` may be any path inside it. */
186
+ export async function readKnowledge(repo: string): Promise<string> {
187
+ const top = await gitToplevel(repo);
188
+ if (!top)
189
+ throw new CatherdError("E_IO_PATH", `${repo} is not inside a git repository`, {
190
+ fix: "pass the path of the repository",
191
+ });
192
+ const file = knowledgeFile(top);
193
+ return existsSync(file) ? readFileSync(file, "utf8") : "catherd: no knowledge recorded yet for this repo";
194
+ }