catherd-cli 1.5.0 → 2.0.0-beta.20261022113309

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 (275) hide show
  1. package/README.md +164 -208
  2. package/THIRD_PARTY_NOTICES.md +217 -35
  3. package/catalog/jev.json +27 -1
  4. package/catalog/models.json +56 -247
  5. package/package.json +6 -8
  6. package/plugin/.claude-plugin/plugin.json +1 -1
  7. package/plugin/.codex-plugin/plugin.json +1 -1
  8. package/plugin/bin/catherd-mcp +1 -1
  9. package/plugin/commands/catherd.md +2 -2
  10. package/plugin/skills/catherd/SKILL.md +97 -421
  11. package/plugin/skills/catherd-setup/SKILL.md +20 -19
  12. package/src/adapters/all.ts +0 -4
  13. package/src/adapters/backend.ts +1 -2
  14. package/src/adapters/cursor/home.ts +1 -1
  15. package/src/adapters/cursor/index.ts +4 -4
  16. package/src/cli.ts +22 -27
  17. package/src/domain/bars.ts +33 -107
  18. package/src/domain/blocked.ts +72 -0
  19. package/src/domain/branch.ts +51 -0
  20. package/src/domain/brief.ts +21 -6
  21. package/src/domain/catalog.ts +69 -324
  22. package/src/domain/cost.ts +15 -30
  23. package/src/domain/errors.ts +2 -2
  24. package/src/domain/fail-lines.ts +26 -0
  25. package/src/domain/failover.ts +37 -87
  26. package/src/domain/findings.ts +47 -0
  27. package/src/domain/gate-brief.ts +1 -0
  28. package/src/domain/ids.ts +13 -2
  29. package/src/domain/jev.ts +47 -2
  30. package/src/domain/judge.ts +124 -0
  31. package/src/domain/lane.ts +22 -3
  32. package/src/domain/milestone-memory.ts +53 -0
  33. package/src/domain/notice.ts +25 -8
  34. package/src/domain/notify.ts +32 -0
  35. package/src/domain/plan-doc.ts +198 -0
  36. package/src/domain/plan-lint.ts +141 -0
  37. package/src/domain/profile-rules.ts +101 -160
  38. package/src/domain/profile.ts +116 -63
  39. package/src/domain/record.ts +53 -2
  40. package/src/domain/role-prompts.ts +131 -45
  41. package/src/domain/role-scope.ts +4 -34
  42. package/src/domain/role-tools.ts +2 -2
  43. package/src/domain/roles.ts +2 -0
  44. package/src/domain/route.ts +17 -0
  45. package/src/domain/rules.ts +33 -0
  46. package/src/domain/run-events.ts +298 -0
  47. package/src/domain/run-machine.ts +668 -0
  48. package/src/domain/run-state.ts +1310 -0
  49. package/src/domain/scorecard.ts +197 -0
  50. package/src/domain/select.ts +151 -146
  51. package/src/domain/slopalytics.ts +72 -0
  52. package/src/entry/answer-command.ts +39 -0
  53. package/src/entry/catalog-command.ts +25 -174
  54. package/src/entry/check-bin.ts +17 -0
  55. package/src/entry/cli-kit.ts +1 -1
  56. package/src/entry/daemon-bin.ts +22 -0
  57. package/src/entry/glyphs.ts +15 -2
  58. package/src/entry/init-command.ts +18 -68
  59. package/src/entry/knowledge-command.ts +45 -4
  60. package/src/entry/lock-command.ts +6 -7
  61. package/src/entry/mcp/role-server.ts +18 -0
  62. package/src/entry/mcp/server.ts +13 -40
  63. package/src/entry/mcp/session-tools.ts +149 -0
  64. package/src/entry/panel/openapi.ts +189 -0
  65. package/src/entry/panel/server.ts +410 -0
  66. package/src/entry/panel-command.ts +68 -0
  67. package/src/entry/profile-command.ts +10 -14
  68. package/src/entry/role-cli-command.ts +47 -3
  69. package/src/entry/runs-command.ts +3 -19
  70. package/src/entry/scorecard-command.ts +187 -0
  71. package/src/entry/supervise-bin.ts +1 -1
  72. package/src/infra/env.ts +0 -2
  73. package/src/infra/git.ts +64 -0
  74. package/src/infra/heavy-lock.ts +45 -7
  75. package/src/infra/launch.ts +63 -7
  76. package/src/infra/memory.ts +45 -0
  77. package/src/infra/ntfy.ts +40 -0
  78. package/src/infra/open-url.ts +42 -0
  79. package/src/infra/paths.ts +2 -2
  80. package/src/infra/slopalytics.ts +30 -0
  81. package/src/infra/store.ts +6 -1
  82. package/src/services/admission.ts +24 -9
  83. package/src/services/backends.ts +4 -6
  84. package/src/services/capture.ts +1 -22
  85. package/src/services/catalog-service.ts +155 -262
  86. package/src/services/credentials.ts +4 -20
  87. package/src/services/daemon.ts +181 -0
  88. package/src/services/dispatch-service.ts +5 -1
  89. package/src/services/doctor-backends.ts +1 -1
  90. package/src/services/doctor-checks.ts +47 -0
  91. package/src/services/doctor.ts +4 -9
  92. package/src/services/failover-rungs.ts +109 -0
  93. package/src/services/finalize.ts +12 -0
  94. package/src/services/gate-env.ts +1 -2
  95. package/src/services/gate-service.ts +21 -6
  96. package/src/services/jev-service.ts +2 -4
  97. package/src/services/lane-service.ts +34 -3
  98. package/src/services/milestone-check.ts +433 -0
  99. package/src/services/milestone-tree.ts +170 -0
  100. package/src/services/milestones.ts +35 -8
  101. package/src/services/notifier.ts +69 -2
  102. package/src/services/notify-service.ts +26 -0
  103. package/src/services/panel-setup.ts +116 -0
  104. package/src/services/plan-input.ts +151 -0
  105. package/src/services/ports.ts +30 -4
  106. package/src/services/preflight.ts +82 -8
  107. package/src/services/profile-service.ts +30 -3
  108. package/src/services/profile-store.ts +8 -3
  109. package/src/services/protocol.ts +4 -0
  110. package/src/services/provenance.ts +31 -90
  111. package/src/services/questions.ts +9 -0
  112. package/src/services/reconcile.ts +11 -0
  113. package/src/services/routing-service.ts +26 -15
  114. package/src/services/run-briefs.ts +477 -0
  115. package/src/services/run-client.ts +217 -0
  116. package/src/services/run-effects.ts +837 -0
  117. package/src/services/run-events-store.ts +174 -0
  118. package/src/services/run-evidence.ts +1 -1
  119. package/src/services/run-read.ts +221 -0
  120. package/src/services/run-report.ts +176 -0
  121. package/src/services/run-service.ts +55 -6
  122. package/src/services/run-store.ts +54 -7
  123. package/src/services/scorecard-metrics.ts +86 -0
  124. package/src/services/scorecard.ts +382 -0
  125. package/src/services/summary.ts +27 -3
  126. package/src/services/tree-path.ts +27 -0
  127. package/src/services/workspace-admission.ts +5 -13
  128. package/src/services/workspace-service.ts +5 -0
  129. package/web/dist/assets/Brand-PKlUX5TE.js +1 -0
  130. package/web/dist/assets/Brand-PKlUX5TE.js.br +0 -0
  131. package/web/dist/assets/Brand-PKlUX5TE.js.gz +0 -0
  132. package/web/dist/assets/Modal-Dxc5so53.js +1 -0
  133. package/web/dist/assets/Modal-Dxc5so53.js.br +0 -0
  134. package/web/dist/assets/Modal-Dxc5so53.js.gz +0 -0
  135. package/web/dist/assets/Pressable-C8-T6Ed_.js +1 -0
  136. package/web/dist/assets/StatusChip-B5xfI_Mn.js +153 -0
  137. package/web/dist/assets/StatusChip-B5xfI_Mn.js.br +0 -0
  138. package/web/dist/assets/StatusChip-B5xfI_Mn.js.gz +0 -0
  139. package/web/dist/assets/_app-Ea5cpyKe.js +1 -0
  140. package/web/dist/assets/_app-Ea5cpyKe.js.br +0 -0
  141. package/web/dist/assets/_app-Ea5cpyKe.js.gz +0 -0
  142. package/web/dist/assets/_runId-pTG0fXVo.js +1 -0
  143. package/web/dist/assets/_runId-pTG0fXVo.js.br +0 -0
  144. package/web/dist/assets/_runId-pTG0fXVo.js.gz +0 -0
  145. package/web/dist/assets/drawer-Du3cdlok.js +1 -0
  146. package/web/dist/assets/drawer-Du3cdlok.js.br +0 -0
  147. package/web/dist/assets/drawer-Du3cdlok.js.gz +0 -0
  148. package/web/dist/assets/geist-cyrillic-ext-wght-normal-DjL33-gN.woff2 +0 -0
  149. package/web/dist/assets/geist-cyrillic-wght-normal-BEAKL7Jp.woff2 +0 -0
  150. package/web/dist/assets/geist-latin-ext-wght-normal-DC-KSUi6.woff2 +0 -0
  151. package/web/dist/assets/geist-latin-wght-normal-BgDaEnEv.woff2 +0 -0
  152. package/web/dist/assets/geist-mono-cyrillic-ext-wght-normal-X_5orZeX.woff2 +0 -0
  153. package/web/dist/assets/geist-mono-cyrillic-wght-normal-DiZS0aHC.woff2 +0 -0
  154. package/web/dist/assets/geist-mono-latin-ext-wght-normal-Bwz-egvJ.woff2 +0 -0
  155. package/web/dist/assets/geist-mono-latin-wght-normal-XN7g48iV.woff2 +0 -0
  156. package/web/dist/assets/geist-mono-symbols2-wght-normal-CO5SzqOn.woff2 +0 -0
  157. package/web/dist/assets/geist-mono-vietnamese-wght-normal-DadHysG0.woff2 +0 -0
  158. package/web/dist/assets/geist-vietnamese-wght-normal-6IgcOCM7.woff2 +0 -0
  159. package/web/dist/assets/index-B6GiwDkh.js +11 -0
  160. package/web/dist/assets/index-B6GiwDkh.js.br +0 -0
  161. package/web/dist/assets/index-B6GiwDkh.js.gz +0 -0
  162. package/web/dist/assets/index-C5IbylvM.css +2 -0
  163. package/web/dist/assets/index-C5IbylvM.css.br +0 -0
  164. package/web/dist/assets/index-C5IbylvM.css.gz +0 -0
  165. package/web/dist/assets/label-DFSYBc62.js +1 -0
  166. package/web/dist/assets/label-DFSYBc62.js.br +0 -0
  167. package/web/dist/assets/label-DFSYBc62.js.gz +0 -0
  168. package/web/dist/assets/link-D8EcKZUt.js +1 -0
  169. package/web/dist/assets/link-D8EcKZUt.js.br +0 -0
  170. package/web/dist/assets/link-D8EcKZUt.js.gz +0 -0
  171. package/web/dist/assets/login-DaN5HPPW.js +1 -0
  172. package/web/dist/assets/login-DaN5HPPW.js.br +0 -0
  173. package/web/dist/assets/login-DaN5HPPW.js.gz +0 -0
  174. package/web/dist/assets/query-Cu--ccT3.js +1 -0
  175. package/web/dist/assets/questions-shoO9OVj.js +1 -0
  176. package/web/dist/assets/questions-shoO9OVj.js.br +0 -0
  177. package/web/dist/assets/questions-shoO9OVj.js.gz +0 -0
  178. package/web/dist/assets/react-query.gen-Bu2yV-qJ.js +12 -0
  179. package/web/dist/assets/react-query.gen-Bu2yV-qJ.js.br +0 -0
  180. package/web/dist/assets/react-query.gen-Bu2yV-qJ.js.gz +0 -0
  181. package/web/dist/assets/rolldown-runtime-XKyaHtl9.js +1 -0
  182. package/web/dist/assets/run-status-C0DRuV7U.js +1 -0
  183. package/web/dist/assets/runs-C3rg2zZc.js +1 -0
  184. package/web/dist/assets/runs-C3rg2zZc.js.br +0 -0
  185. package/web/dist/assets/runs-C3rg2zZc.js.gz +0 -0
  186. package/web/dist/assets/setup-5I4Vl9fR.js +1 -0
  187. package/web/dist/assets/setup-5I4Vl9fR.js.br +0 -0
  188. package/web/dist/assets/setup-5I4Vl9fR.js.gz +0 -0
  189. package/web/dist/assets/table-BOxXhqS4.js +1 -0
  190. package/web/dist/assets/table-BOxXhqS4.js.br +0 -0
  191. package/web/dist/assets/table-BOxXhqS4.js.gz +0 -0
  192. package/web/dist/assets/textfield-BzRFkxBF.js +1 -0
  193. package/web/dist/assets/textfield-BzRFkxBF.js.br +0 -0
  194. package/web/dist/assets/textfield-BzRFkxBF.js.gz +0 -0
  195. package/web/dist/assets/time--9kSQbbp.js +1 -0
  196. package/web/dist/assets/use-css-variable-CpnN9WtA.js +5 -0
  197. package/web/dist/assets/use-css-variable-CpnN9WtA.js.br +0 -0
  198. package/web/dist/assets/use-css-variable-CpnN9WtA.js.gz +0 -0
  199. package/web/dist/assets/useMutation-CVPKlR34.js +1 -0
  200. package/web/dist/assets/useMutation-CVPKlR34.js.br +0 -0
  201. package/web/dist/assets/useMutation-CVPKlR34.js.gz +0 -0
  202. package/web/dist/index.html +15 -0
  203. package/catalog/ATTRIBUTION.md +0 -44
  204. package/catalog/scores.json +0 -3202
  205. package/catalog/sources.json +0 -97
  206. package/src/adapters/antigravity/events.ts +0 -131
  207. package/src/adapters/antigravity/home.ts +0 -46
  208. package/src/adapters/antigravity/index.ts +0 -335
  209. package/src/adapters/antigravity/models.ts +0 -37
  210. package/src/adapters/grok/events.ts +0 -112
  211. package/src/adapters/grok/home.ts +0 -187
  212. package/src/adapters/grok/index.ts +0 -358
  213. package/src/adapters/grok/models.ts +0 -48
  214. package/src/domain/calibration.ts +0 -132
  215. package/src/domain/sources.ts +0 -144
  216. package/src/entry/mcp/dispatch-tools.ts +0 -52
  217. package/src/entry/mcp/lane-tools.ts +0 -131
  218. package/src/entry/mcp/protocol-tools.ts +0 -63
  219. package/src/entry/mcp/push-tools.ts +0 -16
  220. package/src/entry/mcp/run-tools.ts +0 -164
  221. package/src/entry/mcp/setup-tools.ts +0 -109
  222. package/src/entry/mcp/workspace-tools.ts +0 -142
  223. package/src/entry/tui/commands.ts +0 -563
  224. package/src/entry/tui/effects.ts +0 -385
  225. package/src/entry/tui/fixtures.ts +0 -637
  226. package/src/entry/tui/profile-edits.ts +0 -146
  227. package/src/entry/tui/profile-tree.ts +0 -429
  228. package/src/entry/tui/providers/app.tsx +0 -162
  229. package/src/entry/tui/providers/boundary.tsx +0 -57
  230. package/src/entry/tui/providers/data.tsx +0 -160
  231. package/src/entry/tui/providers/keymap.tsx +0 -166
  232. package/src/entry/tui/providers/root.tsx +0 -45
  233. package/src/entry/tui/providers/theme.tsx +0 -17
  234. package/src/entry/tui/providers/toast.tsx +0 -77
  235. package/src/entry/tui/run.tsx +0 -114
  236. package/src/entry/tui/state.ts +0 -400
  237. package/src/entry/tui/stories.ts +0 -114
  238. package/src/entry/tui/text.ts +0 -130
  239. package/src/entry/tui/theme.ts +0 -140
  240. package/src/entry/tui/views/app.tsx +0 -208
  241. package/src/entry/tui/views/dialogs.tsx +0 -41
  242. package/src/entry/tui/views/profile-actions.ts +0 -331
  243. package/src/entry/tui/views/profiles.tsx +0 -520
  244. package/src/entry/tui/views/runs.tsx +0 -619
  245. package/src/entry/tui/views/save-dialog.tsx +0 -344
  246. package/src/entry/tui/views/status.tsx +0 -213
  247. package/src/entry/tui/widgets/chrome.tsx +0 -114
  248. package/src/entry/tui/widgets/dialog-confirm.tsx +0 -58
  249. package/src/entry/tui/widgets/dialog-prompt.tsx +0 -52
  250. package/src/entry/tui/widgets/dialog-select.tsx +0 -212
  251. package/src/entry/tui/widgets/dialog.tsx +0 -68
  252. package/src/entry/tui/widgets/line.tsx +0 -92
  253. package/src/entry/tui/widgets/list.tsx +0 -197
  254. package/src/entry/tui/widgets/spinner.tsx +0 -49
  255. package/src/entry/tui/widgets/toast.tsx +0 -62
  256. package/src/infra/sources/arena.ts +0 -95
  257. package/src/infra/sources/artificial-analysis.ts +0 -119
  258. package/src/infra/sources/cache.ts +0 -126
  259. package/src/infra/sources/csv.ts +0 -38
  260. package/src/infra/sources/epoch.ts +0 -82
  261. package/src/infra/sources/http.ts +0 -75
  262. package/src/infra/sources/litellm.ts +0 -58
  263. package/src/infra/sources/models-dev.ts +0 -59
  264. package/src/infra/sources/openrouter-endpoints.ts +0 -52
  265. package/src/infra/sources/openrouter-models.ts +0 -60
  266. package/src/infra/sources/rows.ts +0 -38
  267. package/src/infra/sources/vectara.ts +0 -69
  268. package/src/infra/sources/zip.ts +0 -48
  269. package/src/services/catalog-refresh.ts +0 -201
  270. package/src/services/doctor-sources.ts +0 -76
  271. package/src/services/runs-page.ts +0 -290
  272. package/src/services/source-derive.ts +0 -397
  273. package/src/services/source-sync.ts +0 -312
  274. package/src/services/standins.ts +0 -238
  275. package/src/services/treat-likes.ts +0 -113
@@ -4,7 +4,8 @@ import { CatherdError } from "../domain/errors.ts";
4
4
  import { assertId, parseRung } from "../domain/ids.ts";
5
5
  import { assertLaneValues } from "../domain/lane.ts";
6
6
  import type { RunRecord } from "../domain/record.ts";
7
- import type { Role } from "../domain/roles.ts";
7
+ import { ROLES, type Role } from "../domain/roles.ts";
8
+ import { rulesFor, tagNames } from "../domain/rules.ts";
8
9
  import { awaitsCollect, dispatchPaths, endCollect, tryCollect } from "../infra/dispatch-dir.ts";
9
10
  import { withFileLock } from "../infra/filelock.ts";
10
11
  import { git, gitToplevel } from "../infra/git.ts";
@@ -19,6 +20,7 @@ import {
19
20
  } from "./dispatches.ts";
20
21
  import type { Deps } from "./ports.ts";
21
22
  import { assertNativeHost } from "./backends.ts";
23
+ import { writeRunIndex } from "./catalog-service.ts";
22
24
  import {
23
25
  type AgentRun,
24
26
  appendAgentRun,
@@ -27,6 +29,7 @@ import {
27
29
  findRun,
28
30
  knowledgeFor,
29
31
  readRecords,
32
+ removeKnowledgeLine,
30
33
  runFile,
31
34
  type Run,
32
35
  runPaths,
@@ -109,7 +112,7 @@ async function supersededHints(run: Run, by: string): Promise<{ hints?: string[]
109
112
 
110
113
  export async function startRun(
111
114
  deps: Deps,
112
- i: { repo: string; title: string; aLines: string[]; from?: string },
115
+ i: { repo: string; title: string; aLines: string[]; from?: string; base?: string },
113
116
  ): Promise<{
114
117
  run: string;
115
118
  dir: string;
@@ -121,6 +124,18 @@ export async function startRun(
121
124
  throw new CatherdError("E_IO_PATH", `${i.repo} is not inside a git repository`, {
122
125
  fix: "pass the path of the repository to work in",
123
126
  });
127
+ // a run starts only when every enabled role has an indexed rung; the index it checked is the one it pins
128
+ const start = await deps.routing.checkStart(deps.profiles.forRepo(top), top);
129
+ if (start.unusable.length > 0) {
130
+ const roles = start.unusable.join(", ");
131
+ throw new CatherdError(
132
+ "E_CONFIG_INVALID",
133
+ `the ${roles} role${start.unusable.length === 1 ? " has" : "s have"} no usable rung on the Slopalytics index`,
134
+ {
135
+ fix: `catherd profile validate, then pick indexed pairs: catherd catalog list --role ${start.unusable[0]}`,
136
+ },
137
+ );
138
+ }
124
139
  // where the first milestone's commit range starts; a repo with no commit yet has none
125
140
  const head = await git(top, ["rev-parse", "--verify", "--quiet", "HEAD^{commit}"]);
126
141
  const open = async (): Promise<Run> => {
@@ -130,11 +145,13 @@ export async function startRun(
130
145
  aLines: i.aLines,
131
146
  version: deps.version,
132
147
  startHead: head.kind === "ok" ? head.out.trim() || null : null,
148
+ ...(i.base ? { base: i.base } : {}),
133
149
  now: new Date(deps.now()),
134
150
  startedBy: currentSession(deps),
135
151
  });
136
152
  // spec 1.5 "Pinned per run": what the run starts on stays what it dispatches on
137
153
  writePin(deps, run);
154
+ writeRunIndex(run.dir, start.index);
138
155
  await claimRun(deps, run);
139
156
  return run;
140
157
  };
@@ -164,8 +181,19 @@ export function writeRunFile(i: { run: string; path: string; content: string }):
164
181
  } {
165
182
  const run = findRun(i.run);
166
183
  const file = runFile(run, i.path, "write");
184
+ const rel = relative(run.dir, file).split(sep).join("/");
185
+ // macOS disks ignore case, so Plan-Input/ is the same folder
186
+ const low = rel.toLowerCase();
187
+ if (low === "plan-approved.md" || low === "plan-input" || low.startsWith("plan-input/"))
188
+ throw new CatherdError(
189
+ "E_INPUT_INVALID",
190
+ `${rel} holds the owner's approved plan; roles never write it`,
191
+ {
192
+ fix: "write plan.md or a lane file",
193
+ },
194
+ );
167
195
  // spec 1.5 "Lane editing": a lane's header values are checked when it is written, not first at preflight
168
- const lane = /^lanes\/([^/]+)\.md$/.exec(relative(run.dir, file).split(sep).join("/"));
196
+ const lane = /^lanes\/([^/]+)\.md$/.exec(rel);
169
197
  if (lane) assertLaneValues(i.content, `lanes/${lane[1]}.md`);
170
198
  writeTextAtomic(file, i.content);
171
199
  return { path: file, bytes: Buffer.byteLength(i.content) };
@@ -350,12 +378,33 @@ export async function knowledgeLines(repo: string): Promise<{ repo: string; path
350
378
  /** The source a line added by hand carries, where a landing puts its run's title and milestone. */
351
379
  const BY_HAND = "by hand";
352
380
 
353
- /** Appends one line of the user's own to the repo's knowledge.md, as `land`'s `learned` does; returns it. */
354
- export async function addKnowledge(repo: string, text: string, now: Date = new Date()): Promise<string> {
381
+ /** The repo's standing rules for `role`: its knowledge.md as `rulesFor` gives it, "" when missing or blank. */
382
+ export async function standingRules(repo: string, role: Role): Promise<string> {
383
+ const { lines } = await knowledgeLines(repo);
384
+ return rulesFor(lines.join("\n"), role);
385
+ }
386
+
387
+ /** Appends one line to the repo's knowledge.md (a hand-written one by default), as `land`'s `learned` does; returns it. */
388
+ export async function addKnowledge(
389
+ repo: string,
390
+ text: string,
391
+ now: Date = new Date(),
392
+ source = BY_HAND,
393
+ ): Promise<string> {
355
394
  const top = await repoTop(repo);
356
395
  if (!text.trim())
357
396
  throw new CatherdError("E_INPUT_INVALID", "the knowledge line is empty", {
358
397
  fix: 'pass one line of text, e.g. catherd knowledge add "the targeted test is bun test <file>"',
359
398
  });
360
- return appendKnowledge(top, now, BY_HAND, text);
399
+ const bad = (tagNames(text) ?? []).find((n) => !(ROLES as readonly string[]).includes(n));
400
+ if (bad !== undefined)
401
+ throw new CatherdError("E_INPUT_INVALID", `"${bad}" is not a role: tag a rule with ${ROLES.join(", ")}`, {
402
+ fix: 'catherd knowledge add "[worker, reviewer] <rule>"',
403
+ });
404
+ return appendKnowledge(top, now, source, text);
405
+ }
406
+
407
+ /** Removes the one line of the repo's knowledge.md that contains `match`; returns it. */
408
+ export async function removeKnowledge(repo: string, match: string): Promise<string> {
409
+ return removeKnowledgeLine(await repoTop(repo), match);
361
410
  }
@@ -2,10 +2,17 @@ import { existsSync, lstatSync, mkdirSync, readdirSync, realpathSync, renameSync
2
2
  import { dirname, join, relative, resolve, sep } from "node:path";
3
3
  import { z } from "zod";
4
4
  import { CatherdError } from "../domain/errors.ts";
5
+ import { runSlug } from "../domain/branch.ts";
5
6
  import { assertId } from "../domain/ids.ts";
6
7
  import { type RunRecord, RunRecordSchema } from "../domain/record.ts";
7
- import type { OutcomeRouteRow, OutcomeRow, RoleRouteRow, RouteRow } from "../domain/route.ts";
8
- import { cell, slug } from "../domain/util.ts";
8
+ import type {
9
+ FailoverRouteRow,
10
+ OutcomeRouteRow,
11
+ OutcomeRow,
12
+ RoleRouteRow,
13
+ RouteRow,
14
+ } from "../domain/route.ts";
15
+ import { cell } from "../domain/util.ts";
9
16
  import { withFileLock } from "../infra/filelock.ts";
10
17
  import { gitOrigin } from "../infra/git.ts";
11
18
  import { dataDir, originDir, repoDir, runsDir } from "../infra/paths.ts";
@@ -14,6 +21,7 @@ import {
14
21
  ensureJsonlHeader,
15
22
  ensurePrivateDir,
16
23
  PRIVATE_DIR,
24
+ PRIVATE_FILE,
17
25
  appendPrivate,
18
26
  nonBlankLines,
19
27
  readJsonl,
@@ -43,6 +51,8 @@ const RunMetaSchema = z.looseObject({
43
51
  workspace: z.strictObject({ id: z.string(), step: z.string() }).optional(),
44
52
  /** the repo's HEAD when run_start ran: where the first milestone's commit range starts (absent before 1.5) */
45
53
  startHead: z.string().optional(),
54
+ /** plan 35: the branch on origin each milestone's own branch starts from; absent for a run in the repo itself */
55
+ base: z.string().optional(),
46
56
  });
47
57
  type RunMeta = z.infer<typeof RunMetaSchema>;
48
58
 
@@ -80,7 +90,6 @@ export function runPaths(dir: string) {
80
90
 
81
91
  export const LEDGER_HEADER = "milestone | what | commit | minutes | evidence";
82
92
 
83
- const runSlug = (title: string) => slug(title).slice(0, 40) || "run";
84
93
  const stamp = (d: Date) => d.toISOString().replace(/[-:]/g, "").replace("T", "-").slice(0, 15);
85
94
 
86
95
  /** Creates a run folder under the repo's runs dir. `repo` is a git toplevel; meta.json is written last. */
@@ -93,6 +102,8 @@ export function createRun(o: {
93
102
  workspace?: { id: string; step: string };
94
103
  /** the repo's full HEAD at the start, when it has a commit */
95
104
  startHead?: string | null;
105
+ /** plan 35: the branch on origin each milestone's branch starts from */
106
+ base?: string;
96
107
  startedBy?: {
97
108
  host?: StartedBy["host"];
98
109
  sessionId: string;
@@ -145,6 +156,7 @@ export function createRun(o: {
145
156
  ...(o.startedBy ? { startedBy: StartedBySchema.parse(o.startedBy) } : {}),
146
157
  ...(workspace ? { workspace } : {}),
147
158
  ...(o.startHead ? { startHead: o.startHead } : {}),
159
+ ...(o.base ? { base: o.base } : {}),
148
160
  };
149
161
  writeJsonAtomic(p.meta, meta);
150
162
  return { id, dir, meta };
@@ -260,11 +272,18 @@ export function readOutcomeRoutes(run: Run): OutcomeRouteRow[] {
260
272
  /** Spec 1.5 plan 24: the run's role decisions outside a lane, oldest first. */
261
273
  export function readRoleRoutes(run: Run): RoleRouteRow[] {
262
274
  return readJsonl<RoleRouteRow>(runPaths(run.dir).routes).rows.filter(
263
- (r) => r?.lane === null && typeof r.role === "string" && typeof r.rung === "string",
275
+ (r) =>
276
+ r?.lane === null &&
277
+ (r.source === "route" || r.source === "dispatch") &&
278
+ typeof r.role === "string" &&
279
+ typeof r.rung === "string",
264
280
  );
265
281
  }
266
282
 
267
- export function appendRoute(run: Run, row: RouteRow | RoleRouteRow | OutcomeRouteRow): void {
283
+ export function appendRoute(
284
+ run: Run,
285
+ row: RouteRow | RoleRouteRow | OutcomeRouteRow | FailoverRouteRow,
286
+ ): void {
268
287
  const file = runPaths(run.dir).routes;
269
288
  ensureJsonlHeader(file, "routes");
270
289
  appendJsonl(file, row);
@@ -350,7 +369,7 @@ export async function knowledgeFor(toplevel: string): Promise<string> {
350
369
 
351
370
  /**
352
371
  * Appends one line to the repo's knowledge.md, `- <date> <source>: <text>` with `text` on one line, and returns
353
- * it. The single writer of knowledge.md: `land`'s `learned` and `catherd knowledge add` both come through here.
372
+ * it. `land`'s `learned` and `catherd knowledge add` both come through here; `removeKnowledgeLine` is the other writer.
354
373
  */
355
374
  export async function appendKnowledge(
356
375
  toplevel: string,
@@ -359,10 +378,38 @@ export async function appendKnowledge(
359
378
  text: string,
360
379
  ): Promise<string> {
361
380
  const line = `- ${at.toISOString().slice(0, 10)} ${source}: ${cell(text)}`;
362
- appendPrivate(await knowledgeFor(toplevel), `${line}\n`);
381
+ const file = await knowledgeFor(toplevel);
382
+ ensurePrivateDir(dirname(file));
383
+ await withFileLock(file, () => appendPrivate(file, `${line}\n`));
363
384
  return line;
364
385
  }
365
386
 
387
+ /** Removes the one line of the repo's knowledge.md that contains `match`, and returns it. */
388
+ export async function removeKnowledgeLine(toplevel: string, match: string): Promise<string> {
389
+ const file = await knowledgeFor(toplevel);
390
+ const needle = match.trim();
391
+ ensurePrivateDir(dirname(file));
392
+ return withFileLock(file, () => {
393
+ const lines = existsSync(file) ? nonBlankLines(file) : [];
394
+ const hits = needle ? lines.filter((l) => l.includes(needle)) : [];
395
+ if (hits.length === 0)
396
+ throw new CatherdError("E_INPUT_INVALID", `no line of knowledge.md contains "${match}"`, {
397
+ fix: "catherd knowledge show",
398
+ });
399
+ if (hits.length > 1)
400
+ throw new CatherdError(
401
+ "E_INPUT_INVALID",
402
+ `${hits.length} lines contain "${match}": quote more of the one to remove`,
403
+ {
404
+ fix: "catherd knowledge show",
405
+ },
406
+ );
407
+ const rest = lines.filter((l) => l !== hits[0]);
408
+ writeTextAtomic(file, rest.length ? `${rest.join("\n")}\n` : "", { mode: PRIVATE_FILE });
409
+ return hits[0] as string;
410
+ });
411
+ }
412
+
366
413
  /** Spec 1.5 "runs supersede": the run that took over a closed one, and when. */
367
414
  const SupersededSchema = z.looseObject({ schema: z.literal(1), by: z.string(), at: z.string() });
368
415
  export type Superseded = z.infer<typeof SupersededSchema>;
@@ -0,0 +1,86 @@
1
+ import { existsSync, readFileSync } from "node:fs";
2
+ import { join } from "node:path";
3
+ import { sessionKey } from "../domain/host.ts";
4
+ import { type Interventions, interventionTotal } from "../domain/scorecard.ts";
5
+ import type { RunEvent } from "../domain/run-events.ts";
6
+ import { coveredMs } from "../domain/util.ts";
7
+ import { landedMilestones } from "./milestones.ts";
8
+ import { pauseSpans } from "./pause.ts";
9
+ import { runMilestones } from "./protocol.ts";
10
+ import { parkedSpans, questionCounts } from "./questions.ts";
11
+ import { replay } from "../domain/run-state.ts";
12
+ import { isDaemonRun, readEvents } from "./run-events-store.ts";
13
+ import { listRuns, readAgentRuns, readRecords, type Run, supersededBy } from "./run-store.ts";
14
+ import { readSessionRows } from "./sessions.ts";
15
+
16
+ // Plan 28: what a finished run's own files say about how much it needed a person. Read-only.
17
+
18
+ /**
19
+ * A daemon run's own events (plan 36, ruling 12): the owner's answers and the questions still open are parks (a
20
+ * judge's answer is not), every answer is shown, and an owner's cancel or plan approval each count.
21
+ */
22
+ function eventCounts(run: Run): { parks: number; answers: number; cancels: number; approvals: number } {
23
+ const events = readEvents(run);
24
+ const n = (f: (e: RunEvent) => boolean) => events.filter(f).length;
25
+ // one snapshot: an answer written between two reads would otherwise count nowhere
26
+ const open = Object.keys(replay(events, run.id).questions).length;
27
+ return {
28
+ parks: n((e) => e.type === "question.answered" && e.by === "owner") + open,
29
+ answers: n((e) => e.type === "question.answered"),
30
+ cancels: n((e) => e.type === "cancel.requested"),
31
+ approvals: n((e) => e.type === "plan.approved" && e.by === "owner"),
32
+ };
33
+ }
34
+
35
+ /** A 1.5 run keeps its questions in questions.jsonl and has no cancel or plan approval to count. */
36
+ const oldCounts = (run: Run) => {
37
+ const { questions, answers } = questionCounts(run);
38
+ return { parks: questions, answers, cancels: 0, approvals: 0 };
39
+ };
40
+
41
+ /** The interventions a run needed by `end` (epoch ms), counted from its records (plan 28, ruling 4). */
42
+ export function interventionsOf(run: Run, end: number, chat: number): Interventions {
43
+ const daemon = isDaemonRun(run);
44
+ const { parks, answers, cancels, approvals } = daemon ? eventCounts(run) : oldCounts(run);
45
+ const keys = [...(run.meta.startedBy ? [run.meta.startedBy] : []), ...readSessionRows(run)].map(sessionKey);
46
+ const takeovers = keys.filter((k, i) => i > 0 && k !== keys[i - 1]).length;
47
+ // a daemon run admits every dispatch without a session
48
+ const manualDispatches = daemon ? readRecords(run).records.filter((r) => r.sessionId).length : 0;
49
+ const from = Date.parse(run.meta.createdAt);
50
+ const pauses = pauseSpans(run).filter((s) => s.from >= from && s.from <= end).length;
51
+ const superseded = listRuns([run.meta.repo]).runs.filter((r) => supersededBy(r)?.by === run.id).length;
52
+ const i = { parks, answers, takeovers, manualDispatches, pauses, superseded, chat, cancels, approvals };
53
+ return { ...i, total: interventionTotal(i) };
54
+ }
55
+
56
+ /** When the run ended, in epoch ms: its last landing, else its last record or native agent run, else its start. */
57
+ export function runEnd(run: Run): number {
58
+ const last = landedMilestones(run).at(-1);
59
+ if (last) {
60
+ const file = join(run.dir, "digests", `${last}.md`);
61
+ const at = existsSync(file) ? / · landed (\S+)\s*$/m.exec(readFileSync(file, "utf8"))?.[1] : undefined;
62
+ const ms = at ? Date.parse(at) : NaN;
63
+ if (!Number.isNaN(ms)) return ms;
64
+ }
65
+ const times = [...readRecords(run).records.map((r) => r.endedAt), ...readAgentRuns(run).map((a) => a.at)]
66
+ .map((t) => Date.parse(t))
67
+ .filter((t) => !Number.isNaN(t));
68
+ return times.length ? Math.max(...times) : Date.parse(run.meta.createdAt);
69
+ }
70
+
71
+ /** The ms of the run, up to `end`, that it spent parked on the owner or paused; overlaps once. */
72
+ export const pausedMs = (run: Run, end: number): number =>
73
+ coveredMs(Date.parse(run.meta.createdAt), end, [
74
+ ...runMilestones(run).flatMap((m) => parkedSpans(run, m)),
75
+ ...pauseSpans(run),
76
+ ]);
77
+
78
+ /** A failover stand-in (failoverFrom set) ended ok and was not blocked or refused. */
79
+ export const failoverRecovered = (run: Run): boolean =>
80
+ readRecords(run).records.some(
81
+ (r) =>
82
+ r.failoverFrom !== null &&
83
+ r.status === "ok" &&
84
+ r.replyStatus !== "blocked" &&
85
+ r.replyStatus !== "refused",
86
+ );