catherd-cli 0.2.0 → 1.0.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 (187) hide show
  1. package/README.md +114 -30
  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 +1 -1
  9. package/plugin/commands/catherd.md +1 -1
  10. package/plugin/skills/catherd/SKILL.md +97 -51
  11. package/plugin/skills/catherd-setup/SKILL.md +34 -24
  12. package/src/adapters/all.ts +8 -0
  13. package/src/adapters/backend.ts +144 -0
  14. package/src/adapters/claude-code/events.ts +86 -0
  15. package/src/adapters/claude-code/index.ts +223 -0
  16. package/src/adapters/claude-code/models-api.ts +65 -0
  17. package/src/adapters/claude-code/models.ts +26 -0
  18. package/src/adapters/cli.ts +74 -0
  19. package/src/adapters/codex/events.ts +65 -0
  20. package/src/adapters/codex/home.ts +47 -0
  21. package/src/adapters/codex/index.ts +227 -0
  22. package/src/adapters/discovery.ts +87 -0
  23. package/src/adapters/opencode/agents.ts +84 -0
  24. package/src/adapters/opencode/events.ts +109 -0
  25. package/src/adapters/opencode/index.ts +316 -0
  26. package/src/adapters/registry.ts +14 -0
  27. package/src/cli.ts +159 -19
  28. package/src/domain/agents.ts +60 -0
  29. package/src/domain/budget.ts +45 -0
  30. package/src/domain/catalog.ts +262 -0
  31. package/src/domain/changes.ts +52 -0
  32. package/src/domain/cost.ts +89 -0
  33. package/src/domain/errors.ts +58 -0
  34. package/src/domain/hints.ts +22 -0
  35. package/src/domain/ids.ts +97 -0
  36. package/src/domain/jev.ts +283 -0
  37. package/src/domain/lane.ts +69 -0
  38. package/src/domain/profile-rules.ts +205 -0
  39. package/src/domain/profile.ts +383 -0
  40. package/src/domain/record.ts +84 -0
  41. package/src/{profile → domain}/role-prompts.ts +42 -25
  42. package/src/domain/roles.ts +25 -0
  43. package/src/domain/route.ts +126 -0
  44. package/src/domain/runtime.ts +21 -0
  45. package/src/domain/sanitize.ts +29 -0
  46. package/src/domain/secrets.ts +57 -0
  47. package/src/domain/select.ts +136 -0
  48. package/src/domain/state.ts +33 -0
  49. package/src/domain/util.ts +18 -0
  50. package/src/entry/capture-fixtures-command.ts +57 -0
  51. package/src/entry/catalog-command.ts +106 -0
  52. package/src/entry/cli-kit.ts +36 -0
  53. package/src/entry/deps.ts +16 -0
  54. package/src/entry/doctor-command.ts +45 -0
  55. package/src/entry/glyphs.ts +33 -0
  56. package/src/entry/init-command.ts +142 -0
  57. package/src/entry/lock-command.ts +123 -0
  58. package/src/entry/mcp/dispatch-tools.ts +82 -0
  59. package/src/entry/mcp/handshake.ts +48 -0
  60. package/src/entry/mcp/lane-tools.ts +83 -0
  61. package/src/entry/mcp/result.ts +51 -0
  62. package/src/entry/mcp/run-tools.ts +144 -0
  63. package/src/entry/mcp/server.ts +72 -0
  64. package/src/entry/mcp/setup-tools.ts +81 -0
  65. package/src/entry/profile-command.ts +320 -0
  66. package/src/entry/prompt.ts +103 -0
  67. package/src/entry/runs-command.ts +217 -0
  68. package/src/entry/supervise-bin.ts +18 -0
  69. package/src/entry/supervise-command.ts +37 -0
  70. package/src/entry/tui/commands.ts +545 -0
  71. package/src/entry/tui/effects.ts +270 -0
  72. package/src/entry/tui/fixtures.ts +318 -0
  73. package/src/entry/tui/profile-edits.ts +138 -0
  74. package/src/entry/tui/profile-tree.ts +419 -0
  75. package/src/entry/tui/providers/app.tsx +162 -0
  76. package/src/entry/tui/providers/boundary.tsx +57 -0
  77. package/src/entry/tui/providers/data.tsx +133 -0
  78. package/src/entry/tui/providers/keymap.tsx +166 -0
  79. package/src/entry/tui/providers/root.tsx +45 -0
  80. package/src/entry/tui/providers/theme.tsx +17 -0
  81. package/src/entry/tui/providers/toast.tsx +77 -0
  82. package/src/entry/tui/run.tsx +98 -0
  83. package/src/entry/tui/state.ts +336 -0
  84. package/src/entry/tui/stories.ts +75 -0
  85. package/src/entry/tui/text.ts +118 -0
  86. package/src/entry/tui/theme.ts +139 -0
  87. package/src/entry/tui/views/app.tsx +208 -0
  88. package/src/entry/tui/views/dialogs.tsx +41 -0
  89. package/src/entry/tui/views/profile-actions.ts +321 -0
  90. package/src/entry/tui/views/profiles.tsx +440 -0
  91. package/src/entry/tui/views/runs.tsx +281 -0
  92. package/src/entry/tui/views/save-dialog.tsx +332 -0
  93. package/src/entry/tui/views/status.tsx +206 -0
  94. package/src/entry/tui/widgets/chrome.tsx +114 -0
  95. package/src/entry/tui/widgets/dialog-confirm.tsx +58 -0
  96. package/src/entry/tui/widgets/dialog-prompt.tsx +52 -0
  97. package/src/entry/tui/widgets/dialog-select.tsx +212 -0
  98. package/src/entry/tui/widgets/dialog.tsx +68 -0
  99. package/src/entry/tui/widgets/line.tsx +92 -0
  100. package/src/entry/tui/widgets/list.tsx +196 -0
  101. package/src/entry/tui/widgets/spinner.tsx +49 -0
  102. package/src/entry/tui/widgets/toast.tsx +62 -0
  103. package/src/infra/assets.ts +4 -0
  104. package/src/infra/dispatch-dir.ts +278 -0
  105. package/src/infra/env.ts +43 -0
  106. package/src/infra/filelock.ts +159 -0
  107. package/src/infra/git.ts +91 -0
  108. package/src/infra/heavy-lock.ts +36 -0
  109. package/src/infra/jev-client.ts +162 -0
  110. package/src/infra/launch.ts +25 -0
  111. package/src/infra/log.ts +84 -0
  112. package/src/infra/paths.ts +38 -0
  113. package/src/infra/proc.ts +110 -0
  114. package/src/infra/store.ts +229 -0
  115. package/src/infra/supervisor.ts +257 -0
  116. package/src/infra/version.ts +6 -0
  117. package/src/services/admission.ts +277 -0
  118. package/src/services/agent-links.ts +139 -0
  119. package/src/services/backends.ts +44 -0
  120. package/src/services/budget.ts +47 -0
  121. package/src/services/capture.ts +233 -0
  122. package/src/services/catalog-service.ts +382 -0
  123. package/src/services/dispatch-service.ts +479 -0
  124. package/src/services/dispatches.ts +145 -0
  125. package/src/services/doctor-backends.ts +185 -0
  126. package/src/services/doctor-checks.ts +103 -0
  127. package/src/services/doctor.ts +358 -0
  128. package/src/services/finalize.ts +359 -0
  129. package/src/services/jev-service.ts +183 -0
  130. package/src/services/lane-service.ts +248 -0
  131. package/src/services/ports.ts +132 -0
  132. package/src/services/preflight.ts +207 -0
  133. package/src/services/profile-service.ts +292 -0
  134. package/src/services/profile-store.ts +141 -0
  135. package/src/services/reconcile.ts +101 -0
  136. package/src/services/routing-service.ts +190 -0
  137. package/src/services/run-debug.ts +79 -0
  138. package/src/services/run-service.ts +161 -0
  139. package/src/services/run-store.ts +316 -0
  140. package/src/services/setup.ts +87 -0
  141. package/src/services/state.ts +104 -0
  142. package/src/services/summary.ts +227 -0
  143. package/catalog/catalog.json +0 -112
  144. package/catalog/models-dev.json +0 -56453
  145. package/src/core/codex-events.ts +0 -49
  146. package/src/core/codex.ts +0 -166
  147. package/src/core/harness.ts +0 -89
  148. package/src/core/lanes.ts +0 -67
  149. package/src/core/lock.ts +0 -111
  150. package/src/core/opencode.ts +0 -207
  151. package/src/core/reconcile.ts +0 -26
  152. package/src/core/reply.ts +0 -54
  153. package/src/core/runstore.ts +0 -163
  154. package/src/core/spawn.ts +0 -89
  155. package/src/core/status.ts +0 -193
  156. package/src/files.ts +0 -19
  157. package/src/mcp/dispatch.ts +0 -154
  158. package/src/mcp/lane-tools.ts +0 -200
  159. package/src/mcp/out.ts +0 -6
  160. package/src/mcp/preflight.ts +0 -69
  161. package/src/mcp/run-tools.ts +0 -121
  162. package/src/mcp/runs.ts +0 -118
  163. package/src/mcp/server.ts +0 -26
  164. package/src/mcp/setup-tools.ts +0 -175
  165. package/src/paths.ts +0 -20
  166. package/src/profile/agents.ts +0 -130
  167. package/src/profile/profile.ts +0 -228
  168. package/src/routing/catalog.ts +0 -227
  169. package/src/routing/commands.ts +0 -22
  170. package/src/routing/jev.ts +0 -185
  171. package/src/routing/route.ts +0 -155
  172. package/src/routing/select.ts +0 -90
  173. package/src/tui/backends.ts +0 -57
  174. package/src/tui/commands.ts +0 -53
  175. package/src/tui/dashboard.tsx +0 -245
  176. package/src/tui/editor.tsx +0 -214
  177. package/src/tui/init.tsx +0 -208
  178. package/src/tui/matrix-model.ts +0 -279
  179. package/src/tui/matrix.tsx +0 -512
  180. package/src/tui/profiles.ts +0 -38
  181. package/src/tui/theme.ts +0 -177
  182. package/src/tui/ui.tsx +0 -267
  183. package/src/tui/watch-model.ts +0 -83
  184. package/src/tui/watch.tsx +0 -236
  185. package/src/types.ts +0 -184
  186. package/src/version.ts +0 -7
  187. /package/src/{mcp/commands.ts → entry/mcp/command.ts} +0 -0
@@ -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,161 @@
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 { dispatchPaths } from "../infra/dispatch-dir.ts";
8
+ import { gitToplevel } from "../infra/git.ts";
9
+ import { writeTextAtomic } from "../infra/store.ts";
10
+ import { dispatchState, type DispatchState, latestDispatch } from "./dispatches.ts";
11
+ import type { Deps } from "./ports.ts";
12
+ import {
13
+ type AgentRun,
14
+ appendAgentRun,
15
+ createRun,
16
+ findRun,
17
+ knowledgeFile,
18
+ readRecords,
19
+ runFile,
20
+ } from "./run-store.ts";
21
+ import { refreshState } from "./state.ts";
22
+
23
+ export async function startRun(
24
+ deps: Deps,
25
+ i: { repo: string; title: string; aLines: string[] },
26
+ ): Promise<{ run: string; dir: string; hints?: string[] }> {
27
+ const top = await gitToplevel(i.repo);
28
+ if (!top)
29
+ throw new CatherdError("E_IO_PATH", `${i.repo} is not inside a git repository`, {
30
+ fix: "pass the path of the repository to work in",
31
+ });
32
+ const run = createRun({
33
+ repo: top,
34
+ title: i.title,
35
+ aLines: i.aLines,
36
+ version: deps.version,
37
+ now: new Date(deps.now()),
38
+ });
39
+ // a failed state.md refresh never fails the start: the run exists and is usable, so a retry would orphan it
40
+ const { hints } = await refreshState(run);
41
+ return { run: run.id, dir: run.dir, ...(hints.length ? { hints } : {}) };
42
+ }
43
+
44
+ export function writeRunFile(i: { run: string; path: string; content: string }): {
45
+ path: string;
46
+ bytes: number;
47
+ } {
48
+ const file = runFile(findRun(i.run), i.path, "write");
49
+ writeTextAtomic(file, i.content);
50
+ return { path: file, bytes: Buffer.byteLength(i.content) };
51
+ }
52
+
53
+ export function readRunFile(i: { run: string; path: string }): string {
54
+ const file = runFile(findRun(i.run), i.path, "read");
55
+ if (!existsSync(file))
56
+ throw new CatherdError("E_IO_PATH", `no file ${i.path} in the run folder`, {
57
+ fix: "pass a path relative to the run folder",
58
+ });
59
+ return readFileSync(file, "utf8");
60
+ }
61
+
62
+ /**
63
+ * `set_next`: state.md's new text, and, as every tool gives them, `hints`: `state.md not refreshed: <message>`
64
+ * when git fails, with `state` null (the note is kept in state.json either way).
65
+ */
66
+ export async function setNext(i: {
67
+ run: string;
68
+ next: string;
69
+ }): Promise<{ state: string | null; hints?: string[] }> {
70
+ const { text, hints } = await refreshState(findRun(i.run), { next: i.next });
71
+ return { state: text ?? null, ...(hints.length ? { hints } : {}) };
72
+ }
73
+
74
+ const CAP_LINES = 250;
75
+ const CAP_CHARS = 20_000;
76
+
77
+ function capReply(reply: string, path: string): string {
78
+ const lines = reply.split("\n");
79
+ const head = lines.length > CAP_LINES ? lines.slice(0, CAP_LINES).join("\n") : reply;
80
+ if (head === reply && reply.length <= CAP_CHARS) return reply;
81
+ return `${head.slice(0, CAP_CHARS)}\n[capped: the full reply is ${path}]`;
82
+ }
83
+
84
+ /** A role's latest dispatch: its record once finished, and its capped reply. Reads only. */
85
+ export function result(
86
+ deps: Deps,
87
+ i: { run: string; name: string },
88
+ ): {
89
+ name: string;
90
+ state: DispatchState | null;
91
+ record: RunRecord | null;
92
+ reply: string;
93
+ replyPath: string | null;
94
+ } {
95
+ const run = findRun(i.run);
96
+ assertId("role name", i.name);
97
+ const d = latestDispatch(run, i.name);
98
+ if (!d)
99
+ throw new CatherdError("E_RUN_NOT_FOUND", `${i.name} was never dispatched in this run`, {
100
+ fix: "status(run) lists the roles",
101
+ });
102
+ const record = readRecords(run).records.find((r) => r.dispatchId === d.admit.dispatchId) ?? null;
103
+ const reply = dispatchPaths(d.dir).reply;
104
+ const text = existsSync(reply) ? readFileSync(reply, "utf8") : "";
105
+ return {
106
+ name: i.name,
107
+ state: record ? "finished" : dispatchState(d, deps.now()),
108
+ record,
109
+ reply: capReply(text, relative(run.dir, reply)),
110
+ replyPath: relative(run.dir, reply),
111
+ };
112
+ }
113
+
114
+ /** Spec §4.6: what a native Claude subagent used, as the Agent tool reported it; counted in the budget. */
115
+ export function recordAgentRun(
116
+ deps: Deps,
117
+ i: {
118
+ run: string;
119
+ name: string;
120
+ role: Role;
121
+ rung: string;
122
+ totalTokens: number;
123
+ costUsd?: number;
124
+ durationMs?: number;
125
+ status?: AgentRun["status"];
126
+ lane?: string;
127
+ },
128
+ ): AgentRun {
129
+ const run = findRun(i.run);
130
+ assertId("role name", i.name);
131
+ if (i.lane !== undefined) assertId("lane", i.lane);
132
+ if (parseRung(i.rung).backend !== "claude")
133
+ throw new CatherdError("E_ADMIT_RUNG", `${i.rung} is not a native Claude rung`, {
134
+ fix: "record_agent_run is for Agent subagents; dispatch records every other run itself",
135
+ });
136
+ const row: AgentRun = {
137
+ at: new Date(deps.now()).toISOString(),
138
+ name: i.name,
139
+ role: i.role,
140
+ rung: i.rung,
141
+ agent: deps.profiles.agentFor(run.meta.repo, i.role, i.rung),
142
+ totalTokens: i.totalTokens,
143
+ costUsd: i.costUsd ?? null,
144
+ secs: i.durationMs === undefined ? null : Math.round(i.durationMs / 1000),
145
+ status: i.status ?? "ok",
146
+ lane: i.lane ?? null,
147
+ };
148
+ appendAgentRun(run, row);
149
+ return row;
150
+ }
151
+
152
+ /** What past runs of the repo learned; `repo` may be any path inside it. */
153
+ export async function readKnowledge(repo: string): Promise<string> {
154
+ const top = await gitToplevel(repo);
155
+ if (!top)
156
+ throw new CatherdError("E_IO_PATH", `${repo} is not inside a git repository`, {
157
+ fix: "pass the path of the repository",
158
+ });
159
+ const file = knowledgeFile(top);
160
+ return existsSync(file) ? readFileSync(file, "utf8") : "catherd: no knowledge recorded yet for this repo";
161
+ }