catherd-cli 1.4.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 (320) hide show
  1. package/README.md +188 -169
  2. package/THIRD_PARTY_NOTICES.md +217 -35
  3. package/catalog/jev.json +27 -1
  4. package/catalog/models.json +56 -239
  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 -356
  11. package/plugin/skills/catherd-setup/SKILL.md +21 -20
  12. package/src/adapters/all.ts +0 -4
  13. package/src/adapters/backend.ts +19 -4
  14. package/src/adapters/claude-code/index.ts +126 -14
  15. package/src/adapters/codex/events.ts +10 -0
  16. package/src/adapters/codex/index.ts +17 -5
  17. package/src/adapters/cursor/home.ts +1 -1
  18. package/src/adapters/cursor/index.ts +10 -4
  19. package/src/adapters/opencode/index.ts +38 -1
  20. package/src/cli.ts +27 -27
  21. package/src/domain/agents.ts +3 -1
  22. package/src/domain/bars.ts +33 -107
  23. package/src/domain/blocked.ts +72 -0
  24. package/src/domain/branch.ts +51 -0
  25. package/src/domain/brief.ts +106 -0
  26. package/src/domain/catalog.ts +69 -313
  27. package/src/domain/changes.ts +23 -1
  28. package/src/domain/cost.ts +19 -29
  29. package/src/domain/errors.ts +6 -2
  30. package/src/domain/fail-lines.ts +26 -0
  31. package/src/domain/failover.ts +37 -75
  32. package/src/domain/findings.ts +47 -0
  33. package/src/domain/gate-brief.ts +53 -0
  34. package/src/domain/hints.ts +13 -2
  35. package/src/domain/ids.ts +13 -2
  36. package/src/domain/jev.ts +47 -2
  37. package/src/domain/judge.ts +124 -0
  38. package/src/domain/lane.ts +118 -3
  39. package/src/domain/milestone-memory.ts +53 -0
  40. package/src/domain/notice.ts +32 -8
  41. package/src/domain/notify.ts +32 -0
  42. package/src/domain/plan-doc.ts +198 -0
  43. package/src/domain/plan-lint.ts +141 -0
  44. package/src/domain/profile-rules.ts +175 -138
  45. package/src/domain/profile.ts +140 -63
  46. package/src/domain/record.ts +68 -3
  47. package/src/domain/reply.ts +29 -0
  48. package/src/domain/role-prompts.ts +182 -56
  49. package/src/domain/role-scope.ts +76 -0
  50. package/src/domain/role-tools.ts +34 -0
  51. package/src/domain/roles.ts +2 -0
  52. package/src/domain/route.ts +90 -0
  53. package/src/domain/rules.ts +33 -0
  54. package/src/domain/run-events.ts +298 -0
  55. package/src/domain/run-machine.ts +668 -0
  56. package/src/domain/run-state.ts +1310 -0
  57. package/src/domain/scorecard.ts +197 -0
  58. package/src/domain/select.ts +229 -72
  59. package/src/domain/slopalytics.ts +72 -0
  60. package/src/domain/state.ts +5 -0
  61. package/src/domain/util.ts +22 -0
  62. package/src/domain/workspace.ts +130 -0
  63. package/src/entry/answer-command.ts +39 -0
  64. package/src/entry/catalog-command.ts +24 -165
  65. package/src/entry/check-bin.ts +17 -0
  66. package/src/entry/cli-kit.ts +18 -2
  67. package/src/entry/daemon-bin.ts +22 -0
  68. package/src/entry/deps.ts +2 -0
  69. package/src/entry/doctor-command.ts +20 -2
  70. package/src/entry/glyphs.ts +15 -2
  71. package/src/entry/host-arg.ts +28 -0
  72. package/src/entry/init-command.ts +20 -69
  73. package/src/entry/knowledge-command.ts +121 -5
  74. package/src/entry/lock-command.ts +103 -8
  75. package/src/entry/mcp/handshake.ts +6 -2
  76. package/src/entry/mcp/role-bin.ts +20 -0
  77. package/src/entry/mcp/role-server.ts +134 -0
  78. package/src/entry/mcp/server.ts +85 -21
  79. package/src/entry/mcp/session-tools.ts +149 -0
  80. package/src/entry/panel/openapi.ts +189 -0
  81. package/src/entry/panel/server.ts +410 -0
  82. package/src/entry/panel-command.ts +68 -0
  83. package/src/entry/pause-command.ts +67 -0
  84. package/src/entry/profile-command.ts +33 -18
  85. package/src/entry/role-cli-command.ts +159 -0
  86. package/src/entry/runs-command.ts +81 -27
  87. package/src/entry/scorecard-command.ts +187 -0
  88. package/src/entry/supervise-bin.ts +1 -1
  89. package/src/entry/supervise-command.ts +30 -3
  90. package/src/entry/workspace-command.ts +44 -0
  91. package/src/infra/boot-lock.ts +25 -0
  92. package/src/infra/delivery.ts +3 -0
  93. package/src/infra/dispatch-dir.ts +2 -0
  94. package/src/infra/env.ts +11 -3
  95. package/src/infra/git.ts +70 -0
  96. package/src/infra/heavy-lock.ts +133 -6
  97. package/src/infra/host-probe.ts +16 -0
  98. package/src/infra/launch.ts +63 -7
  99. package/src/infra/lock-activity.ts +88 -0
  100. package/src/infra/login-env.ts +48 -0
  101. package/src/infra/memory.ts +45 -0
  102. package/src/infra/ntfy.ts +40 -0
  103. package/src/infra/open-url.ts +42 -0
  104. package/src/infra/paths.ts +37 -2
  105. package/src/infra/role-mcp.ts +69 -0
  106. package/src/infra/slopalytics.ts +30 -0
  107. package/src/infra/store.ts +6 -1
  108. package/src/infra/supervisor.ts +107 -16
  109. package/src/services/admission.ts +133 -31
  110. package/src/services/backends.ts +4 -6
  111. package/src/services/budget.ts +26 -6
  112. package/src/services/capture.ts +1 -22
  113. package/src/services/catalog-service.ts +155 -254
  114. package/src/services/credentials.ts +15 -26
  115. package/src/services/daemon.ts +181 -0
  116. package/src/services/dispatch-service.ts +209 -17
  117. package/src/services/dispatches.ts +30 -2
  118. package/src/services/doctor-access.ts +8 -3
  119. package/src/services/doctor-backends.ts +1 -1
  120. package/src/services/doctor-checks.ts +47 -0
  121. package/src/services/doctor-docker.ts +221 -0
  122. package/src/services/doctor-role-mcp.ts +65 -0
  123. package/src/services/doctor.ts +72 -15
  124. package/src/services/end-push.ts +157 -0
  125. package/src/services/failover-rungs.ts +109 -0
  126. package/src/services/finalize.ts +24 -2
  127. package/src/services/gate-env.ts +217 -0
  128. package/src/services/gate-service.ts +231 -45
  129. package/src/services/global-install.ts +46 -13
  130. package/src/services/jev-service.ts +2 -4
  131. package/src/services/lane-edit.ts +154 -0
  132. package/src/services/lane-service.ts +291 -55
  133. package/src/services/milestone-check.ts +433 -0
  134. package/src/services/milestone-tree.ts +170 -0
  135. package/src/services/milestones.ts +167 -29
  136. package/src/services/notifier.ts +124 -4
  137. package/src/services/notify-service.ts +26 -0
  138. package/src/services/orchestrator-wait.ts +82 -0
  139. package/src/services/panel-setup.ts +116 -0
  140. package/src/services/pause.ts +161 -0
  141. package/src/services/peek.ts +79 -17
  142. package/src/services/plan-input.ts +151 -0
  143. package/src/services/ports.ts +60 -5
  144. package/src/services/preflight.ts +232 -24
  145. package/src/services/profile-service.ts +31 -3
  146. package/src/services/profile-store.ts +18 -6
  147. package/src/services/protocol.ts +123 -25
  148. package/src/services/provenance.ts +34 -89
  149. package/src/services/questions.ts +24 -0
  150. package/src/services/reconcile.ts +33 -1
  151. package/src/services/reentry.ts +7 -6
  152. package/src/services/role-access.ts +27 -0
  153. package/src/services/routing-service.ts +161 -42
  154. package/src/services/run-briefs.ts +477 -0
  155. package/src/services/run-client.ts +217 -0
  156. package/src/services/run-effects.ts +837 -0
  157. package/src/services/run-events-store.ts +174 -0
  158. package/src/services/run-evidence.ts +1 -1
  159. package/src/services/run-pin.ts +125 -0
  160. package/src/services/run-read.ts +221 -0
  161. package/src/services/run-report.ts +176 -0
  162. package/src/services/run-service.ts +204 -23
  163. package/src/services/run-store.ts +159 -18
  164. package/src/services/scorecard-metrics.ts +86 -0
  165. package/src/services/scorecard.ts +382 -0
  166. package/src/services/scratch.ts +60 -0
  167. package/src/services/sessions.ts +3 -1
  168. package/src/services/state.ts +21 -0
  169. package/src/services/summary.ts +76 -12
  170. package/src/services/tree-path.ts +27 -0
  171. package/src/services/verifier-brief.ts +23 -0
  172. package/src/services/verifier-step.ts +48 -0
  173. package/src/services/workspace-admission.ts +259 -0
  174. package/src/services/workspace-service.ts +259 -0
  175. package/src/services/workspace-store.ts +95 -0
  176. package/web/dist/assets/Brand-PKlUX5TE.js +1 -0
  177. package/web/dist/assets/Brand-PKlUX5TE.js.br +0 -0
  178. package/web/dist/assets/Brand-PKlUX5TE.js.gz +0 -0
  179. package/web/dist/assets/Modal-Dxc5so53.js +1 -0
  180. package/web/dist/assets/Modal-Dxc5so53.js.br +0 -0
  181. package/web/dist/assets/Modal-Dxc5so53.js.gz +0 -0
  182. package/web/dist/assets/Pressable-C8-T6Ed_.js +1 -0
  183. package/web/dist/assets/StatusChip-B5xfI_Mn.js +153 -0
  184. package/web/dist/assets/StatusChip-B5xfI_Mn.js.br +0 -0
  185. package/web/dist/assets/StatusChip-B5xfI_Mn.js.gz +0 -0
  186. package/web/dist/assets/_app-Ea5cpyKe.js +1 -0
  187. package/web/dist/assets/_app-Ea5cpyKe.js.br +0 -0
  188. package/web/dist/assets/_app-Ea5cpyKe.js.gz +0 -0
  189. package/web/dist/assets/_runId-pTG0fXVo.js +1 -0
  190. package/web/dist/assets/_runId-pTG0fXVo.js.br +0 -0
  191. package/web/dist/assets/_runId-pTG0fXVo.js.gz +0 -0
  192. package/web/dist/assets/drawer-Du3cdlok.js +1 -0
  193. package/web/dist/assets/drawer-Du3cdlok.js.br +0 -0
  194. package/web/dist/assets/drawer-Du3cdlok.js.gz +0 -0
  195. package/web/dist/assets/geist-cyrillic-ext-wght-normal-DjL33-gN.woff2 +0 -0
  196. package/web/dist/assets/geist-cyrillic-wght-normal-BEAKL7Jp.woff2 +0 -0
  197. package/web/dist/assets/geist-latin-ext-wght-normal-DC-KSUi6.woff2 +0 -0
  198. package/web/dist/assets/geist-latin-wght-normal-BgDaEnEv.woff2 +0 -0
  199. package/web/dist/assets/geist-mono-cyrillic-ext-wght-normal-X_5orZeX.woff2 +0 -0
  200. package/web/dist/assets/geist-mono-cyrillic-wght-normal-DiZS0aHC.woff2 +0 -0
  201. package/web/dist/assets/geist-mono-latin-ext-wght-normal-Bwz-egvJ.woff2 +0 -0
  202. package/web/dist/assets/geist-mono-latin-wght-normal-XN7g48iV.woff2 +0 -0
  203. package/web/dist/assets/geist-mono-symbols2-wght-normal-CO5SzqOn.woff2 +0 -0
  204. package/web/dist/assets/geist-mono-vietnamese-wght-normal-DadHysG0.woff2 +0 -0
  205. package/web/dist/assets/geist-vietnamese-wght-normal-6IgcOCM7.woff2 +0 -0
  206. package/web/dist/assets/index-B6GiwDkh.js +11 -0
  207. package/web/dist/assets/index-B6GiwDkh.js.br +0 -0
  208. package/web/dist/assets/index-B6GiwDkh.js.gz +0 -0
  209. package/web/dist/assets/index-C5IbylvM.css +2 -0
  210. package/web/dist/assets/index-C5IbylvM.css.br +0 -0
  211. package/web/dist/assets/index-C5IbylvM.css.gz +0 -0
  212. package/web/dist/assets/label-DFSYBc62.js +1 -0
  213. package/web/dist/assets/label-DFSYBc62.js.br +0 -0
  214. package/web/dist/assets/label-DFSYBc62.js.gz +0 -0
  215. package/web/dist/assets/link-D8EcKZUt.js +1 -0
  216. package/web/dist/assets/link-D8EcKZUt.js.br +0 -0
  217. package/web/dist/assets/link-D8EcKZUt.js.gz +0 -0
  218. package/web/dist/assets/login-DaN5HPPW.js +1 -0
  219. package/web/dist/assets/login-DaN5HPPW.js.br +0 -0
  220. package/web/dist/assets/login-DaN5HPPW.js.gz +0 -0
  221. package/web/dist/assets/query-Cu--ccT3.js +1 -0
  222. package/web/dist/assets/questions-shoO9OVj.js +1 -0
  223. package/web/dist/assets/questions-shoO9OVj.js.br +0 -0
  224. package/web/dist/assets/questions-shoO9OVj.js.gz +0 -0
  225. package/web/dist/assets/react-query.gen-Bu2yV-qJ.js +12 -0
  226. package/web/dist/assets/react-query.gen-Bu2yV-qJ.js.br +0 -0
  227. package/web/dist/assets/react-query.gen-Bu2yV-qJ.js.gz +0 -0
  228. package/web/dist/assets/rolldown-runtime-XKyaHtl9.js +1 -0
  229. package/web/dist/assets/run-status-C0DRuV7U.js +1 -0
  230. package/web/dist/assets/runs-C3rg2zZc.js +1 -0
  231. package/web/dist/assets/runs-C3rg2zZc.js.br +0 -0
  232. package/web/dist/assets/runs-C3rg2zZc.js.gz +0 -0
  233. package/web/dist/assets/setup-5I4Vl9fR.js +1 -0
  234. package/web/dist/assets/setup-5I4Vl9fR.js.br +0 -0
  235. package/web/dist/assets/setup-5I4Vl9fR.js.gz +0 -0
  236. package/web/dist/assets/table-BOxXhqS4.js +1 -0
  237. package/web/dist/assets/table-BOxXhqS4.js.br +0 -0
  238. package/web/dist/assets/table-BOxXhqS4.js.gz +0 -0
  239. package/web/dist/assets/textfield-BzRFkxBF.js +1 -0
  240. package/web/dist/assets/textfield-BzRFkxBF.js.br +0 -0
  241. package/web/dist/assets/textfield-BzRFkxBF.js.gz +0 -0
  242. package/web/dist/assets/time--9kSQbbp.js +1 -0
  243. package/web/dist/assets/use-css-variable-CpnN9WtA.js +5 -0
  244. package/web/dist/assets/use-css-variable-CpnN9WtA.js.br +0 -0
  245. package/web/dist/assets/use-css-variable-CpnN9WtA.js.gz +0 -0
  246. package/web/dist/assets/useMutation-CVPKlR34.js +1 -0
  247. package/web/dist/assets/useMutation-CVPKlR34.js.br +0 -0
  248. package/web/dist/assets/useMutation-CVPKlR34.js.gz +0 -0
  249. package/web/dist/index.html +15 -0
  250. package/catalog/ATTRIBUTION.md +0 -36
  251. package/catalog/scores.json +0 -3202
  252. package/catalog/sources.json +0 -97
  253. package/src/adapters/antigravity/events.ts +0 -131
  254. package/src/adapters/antigravity/home.ts +0 -46
  255. package/src/adapters/antigravity/index.ts +0 -320
  256. package/src/adapters/antigravity/models.ts +0 -37
  257. package/src/adapters/grok/events.ts +0 -112
  258. package/src/adapters/grok/home.ts +0 -132
  259. package/src/adapters/grok/index.ts +0 -339
  260. package/src/adapters/grok/models.ts +0 -48
  261. package/src/domain/calibration.ts +0 -125
  262. package/src/domain/sources.ts +0 -144
  263. package/src/entry/mcp/dispatch-tools.ts +0 -52
  264. package/src/entry/mcp/lane-tools.ts +0 -84
  265. package/src/entry/mcp/protocol-tools.ts +0 -57
  266. package/src/entry/mcp/run-tools.ts +0 -148
  267. package/src/entry/mcp/setup-tools.ts +0 -108
  268. package/src/entry/tui/commands.ts +0 -563
  269. package/src/entry/tui/effects.ts +0 -381
  270. package/src/entry/tui/fixtures.ts +0 -637
  271. package/src/entry/tui/profile-edits.ts +0 -146
  272. package/src/entry/tui/profile-tree.ts +0 -429
  273. package/src/entry/tui/providers/app.tsx +0 -162
  274. package/src/entry/tui/providers/boundary.tsx +0 -57
  275. package/src/entry/tui/providers/data.tsx +0 -160
  276. package/src/entry/tui/providers/keymap.tsx +0 -166
  277. package/src/entry/tui/providers/root.tsx +0 -45
  278. package/src/entry/tui/providers/theme.tsx +0 -17
  279. package/src/entry/tui/providers/toast.tsx +0 -77
  280. package/src/entry/tui/run.tsx +0 -114
  281. package/src/entry/tui/state.ts +0 -373
  282. package/src/entry/tui/stories.ts +0 -114
  283. package/src/entry/tui/text.ts +0 -130
  284. package/src/entry/tui/theme.ts +0 -140
  285. package/src/entry/tui/views/app.tsx +0 -208
  286. package/src/entry/tui/views/dialogs.tsx +0 -41
  287. package/src/entry/tui/views/profile-actions.ts +0 -331
  288. package/src/entry/tui/views/profiles.tsx +0 -520
  289. package/src/entry/tui/views/runs.tsx +0 -573
  290. package/src/entry/tui/views/save-dialog.tsx +0 -340
  291. package/src/entry/tui/views/status.tsx +0 -209
  292. package/src/entry/tui/widgets/chrome.tsx +0 -114
  293. package/src/entry/tui/widgets/dialog-confirm.tsx +0 -58
  294. package/src/entry/tui/widgets/dialog-prompt.tsx +0 -52
  295. package/src/entry/tui/widgets/dialog-select.tsx +0 -212
  296. package/src/entry/tui/widgets/dialog.tsx +0 -68
  297. package/src/entry/tui/widgets/line.tsx +0 -92
  298. package/src/entry/tui/widgets/list.tsx +0 -197
  299. package/src/entry/tui/widgets/spinner.tsx +0 -49
  300. package/src/entry/tui/widgets/toast.tsx +0 -62
  301. package/src/infra/sources/arena.ts +0 -87
  302. package/src/infra/sources/artificial-analysis.ts +0 -112
  303. package/src/infra/sources/cache.ts +0 -124
  304. package/src/infra/sources/csv.ts +0 -38
  305. package/src/infra/sources/epoch.ts +0 -82
  306. package/src/infra/sources/http.ts +0 -75
  307. package/src/infra/sources/litellm.ts +0 -58
  308. package/src/infra/sources/models-dev.ts +0 -59
  309. package/src/infra/sources/openrouter-endpoints.ts +0 -52
  310. package/src/infra/sources/openrouter-models.ts +0 -60
  311. package/src/infra/sources/rows.ts +0 -38
  312. package/src/infra/sources/vectara.ts +0 -69
  313. package/src/infra/sources/zip.ts +0 -48
  314. package/src/services/catalog-refresh.ts +0 -201
  315. package/src/services/doctor-sources.ts +0 -76
  316. package/src/services/runs-page.ts +0 -255
  317. package/src/services/source-derive.ts +0 -374
  318. package/src/services/source-sync.ts +0 -305
  319. package/src/services/standins.ts +0 -204
  320. package/src/services/treat-likes.ts +0 -97
@@ -0,0 +1,176 @@
1
+ import { readFileSync } from "node:fs";
2
+ import { join } from "node:path";
3
+ import type { JudgeDecision, RuleChange } from "../domain/judge.ts";
4
+ import { type Finding, issueLine, type RunEvent } from "../domain/run-events.ts";
5
+ import { findingKey, type RunState } from "../domain/run-state.ts";
6
+ import { writeTextAtomic } from "../infra/store.ts";
7
+ import { readChecks } from "./milestone-check.ts";
8
+ import { readRecords, type Run } from "./run-store.ts";
9
+
10
+ const read = (file: string): string => {
11
+ try {
12
+ return readFileSync(file, "utf8").trim();
13
+ } catch {
14
+ return "";
15
+ }
16
+ };
17
+
18
+ /** What a judge decided, in one line: the verb's own payload. */
19
+ function said(d: JudgeDecision): string {
20
+ switch (d.verb) {
21
+ case "route":
22
+ return d.routes.map((r) => `${r.finding} -> ${r.to}`).join(", ");
23
+ case "answer":
24
+ return d.answer;
25
+ case "ask_owner":
26
+ case "park":
27
+ return d.question;
28
+ case "rules":
29
+ return `${d.changes.length} change(s) proposed`;
30
+ default:
31
+ return d.why;
32
+ }
33
+ }
34
+
35
+ const quote = (text: string): string => `'${text.replace(/'/g, "'\\''")}'`;
36
+
37
+ /**
38
+ * The `## Rules review` section (plan 34): what the finished run's judge proposed, each change followed by the
39
+ * command that applies it. The run applies none; null when the run had no review (cancelled, failed).
40
+ */
41
+ function rulesSection(failed: boolean, proposals: RuleChange[] | undefined): string[] | null {
42
+ if (failed) return ["## Rules review", "", "No rules review: the judge gave no readable answer.", ""];
43
+ if (!proposals) return null;
44
+ if (proposals.length === 0)
45
+ return ["## Rules review", "", "The judge proposed no change to the standing rules.", ""];
46
+ return [
47
+ "## Rules review",
48
+ "",
49
+ "Standing rules to review: apply any of these with the command under it.",
50
+ ...proposals.flatMap((c, i) => [
51
+ `${i + 1}. ${c.op === "add" ? "ADD" : "REMOVE"} ${c.text}${c.why ? ` — ${c.why}` : ""}`,
52
+ ` \`catherd knowledge ${c.op === "add" ? "add" : "rm"} ${quote(c.text)}\``,
53
+ ]),
54
+ "",
55
+ ];
56
+ }
57
+
58
+ /** The "fixed" part of a milestone's `Rounds:` line shows at most this many findings. */
59
+ export const FIXED_LINES = 15;
60
+
61
+ /**
62
+ * The findings of each review round of milestone `m`: a `commit.made` opens a round, and every ok reviewer end
63
+ * (`reviewer`, `ui-reviewer`, the partial pass `reviewer-<M>-2`) adds to it. A round with no reviewer end is left out.
64
+ */
65
+ export function reviewRounds(events: RunEvent[], m: string): Finding[][] {
66
+ const reviewers = new Set<string>();
67
+ const rounds: Finding[][] = [];
68
+ let open: Finding[] | null = null;
69
+ for (const e of events) {
70
+ if (e.type === "dispatch.started") {
71
+ if ((e.role === "reviewer" || e.role === "ui-reviewer") && e.unit === m) reviewers.add(e.dispatchId);
72
+ } else if (e.type === "commit.made" && e.milestone === m) open = null;
73
+ else if (
74
+ e.type === "dispatch.ended" &&
75
+ reviewers.has(e.dispatchId) &&
76
+ e.status === "ok" &&
77
+ e.replyStatus !== "blocked" &&
78
+ e.replyStatus !== "refused"
79
+ ) {
80
+ if (!open) rounds.push((open = []));
81
+ open.push(...e.findings);
82
+ }
83
+ }
84
+ return rounds;
85
+ }
86
+
87
+ /** `Rounds: 3 review round(s) · fixed: <the BLOCKER and BUG lines an earlier round named and the last did not repeat>`. */
88
+ export function roundsLine(events: RunEvent[], m: string): string {
89
+ const rounds = reviewRounds(events, m);
90
+ const last = new Set((rounds.at(-1) ?? []).map((f) => findingKey(issueLine(f))));
91
+ const seen = new Set<string>();
92
+ const fixed: string[] = [];
93
+ for (const f of rounds.slice(0, -1).flat()) {
94
+ const key = findingKey(issueLine(f));
95
+ if (f.label === "NIT" || last.has(key) || seen.has(key)) continue;
96
+ seen.add(key);
97
+ fixed.push(issueLine(f));
98
+ }
99
+ const head = `Rounds: ${rounds.length} review round(s)`;
100
+ return fixed.length ? `${head} · fixed: ${fixed.slice(0, FIXED_LINES).join(" · ")}` : head;
101
+ }
102
+
103
+ /** Per command, the latest check record of this run and milestone that is no baseline; "Gate: not recorded" when none. */
104
+ export function gateLines(run: Run, m: string): string[] {
105
+ const latest = new Map<string, ReturnType<typeof readChecks>[number]>();
106
+ try {
107
+ for (const r of readChecks(run.meta.repo))
108
+ if (r.run === run.id && r.milestone === m && !r.baseline) latest.set(r.command, r);
109
+ } catch {
110
+ // a check store that cannot be read records nothing
111
+ }
112
+ return latest.size
113
+ ? [...latest.values()].map(
114
+ (r) =>
115
+ `Gate: \`${r.command}\` exit ${r.exit} on ${r.head.slice(0, 12)} · ${Math.round(r.durationMs / 1000)} s · secret scan: ${r.secrets ?? "not run"}`,
116
+ )
117
+ : ["Gate: not recorded"];
118
+ }
119
+
120
+ const oneLine = (s: string): string => s.replace(/\s*\n\s*/g, " ").trim();
121
+
122
+ /**
123
+ * `<run>/report.md`, the body of the merge request: the run's title, each landed milestone's digest, the known
124
+ * issues verbatim by milestone, and the audit: every judge decision, and what each milestone's check passed on
125
+ * only through the base or a rerun (`audits`). Returns its path.
126
+ */
127
+ export function writeRunReport(
128
+ run: Run,
129
+ s: RunState,
130
+ events: RunEvent[],
131
+ audits: Record<string, string[]> = {},
132
+ proposals?: RuleChange[],
133
+ ): string {
134
+ const landed = events.filter((e) => e.type === "milestone.landed");
135
+ const decided = events.filter((e) => e.type === "judge.decided");
136
+ const lines = [`# ${s.title || run.meta.title}`, ""];
137
+ for (const e of landed) {
138
+ const title = s.milestones.find((m) => m.id === e.milestone)?.title;
139
+ const digest = read(e.digestPath.startsWith("/") ? e.digestPath : join(run.dir, e.digestPath));
140
+ lines.push(
141
+ `## ${e.milestone}${title && title !== e.milestone ? `: ${title}` : ""}`,
142
+ "",
143
+ digest || `Landed at ${e.commit.slice(0, 12)}.`,
144
+ "",
145
+ roundsLine(events, e.milestone),
146
+ ...gateLines(run, e.milestone),
147
+ "",
148
+ );
149
+ }
150
+ if (landed.length === 0) lines.push("No milestone landed.", "");
151
+ lines.push("## Known issues", "");
152
+ const issues = landed.flatMap((e) => e.knownIssues.map((k) => `- ${e.milestone}: ${k}`));
153
+ lines.push(...(issues.length ? issues : ["None."]), "");
154
+ // plan 34: the latest lint of each milestone's preflight
155
+ const lint = [
156
+ ...new Set(events.flatMap((e) => (e.type === "preflight.done" ? [e.milestone] : []))),
157
+ ].flatMap((m) => {
158
+ const latest = events.findLast((e) => e.type === "preflight.done" && e.milestone === m);
159
+ return latest?.type === "preflight.done" ? latest.lint.map((l) => `- ${m}: ${l}`) : [];
160
+ });
161
+ if (lint.length) lines.push("## Plan lint", "", ...lint, "");
162
+ lines.push("## Audit", "");
163
+ const audit = [
164
+ ...decided.map((e) => `- ${e.unit} (${e.kind}): ${e.decision.verb}: ${oneLine(said(e.decision))}`),
165
+ ...Object.entries(audits).flatMap(([m, lines]) => lines.map((l) => `- ${m} (check): ${oneLine(l)}`)),
166
+ // a worker's conflict with its plan task, in record order
167
+ ...readRecords(run).records.flatMap((r) =>
168
+ (r.design ?? []).map((d) => `- ${r.name} DESIGN: ${oneLine(d)}`),
169
+ ),
170
+ ];
171
+ lines.push(...(audit.length ? audit : ["No judge decision."]), "");
172
+ lines.push(...(rulesSection(s.rulesReview?.failed === true, proposals) ?? []));
173
+ const file = join(run.dir, "report.md");
174
+ writeTextAtomic(file, lines.join("\n"));
175
+ return file;
176
+ }
@@ -1,38 +1,118 @@
1
1
  import { existsSync, readFileSync } from "node:fs";
2
- import { relative } from "node:path";
2
+ import { relative, sep } from "node:path";
3
3
  import { CatherdError } from "../domain/errors.ts";
4
4
  import { assertId, parseRung } from "../domain/ids.ts";
5
+ import { assertLaneValues } from "../domain/lane.ts";
5
6
  import type { RunRecord } from "../domain/record.ts";
6
- import type { Role } from "../domain/roles.ts";
7
+ import { ROLES, type Role } from "../domain/roles.ts";
8
+ import { rulesFor, tagNames } from "../domain/rules.ts";
7
9
  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 { withFileLock } from "../infra/filelock.ts";
11
+ import { git, gitToplevel } from "../infra/git.ts";
12
+ import { writeJsonAtomic, writeTextAtomic } from "../infra/store.ts";
10
13
  import {
11
14
  dispatchState,
12
15
  type DispatchState,
13
16
  latestDispatch,
14
17
  listDispatches,
18
+ liveDispatches,
15
19
  recordHints,
16
20
  } from "./dispatches.ts";
17
21
  import type { Deps } from "./ports.ts";
18
22
  import { assertNativeHost } from "./backends.ts";
23
+ import { writeRunIndex } from "./catalog-service.ts";
19
24
  import {
20
25
  type AgentRun,
21
26
  appendAgentRun,
22
27
  appendKnowledge,
23
28
  createRun,
24
29
  findRun,
25
- knowledgeFile,
30
+ knowledgeFor,
26
31
  readRecords,
32
+ removeKnowledgeLine,
27
33
  runFile,
34
+ type Run,
35
+ runPaths,
36
+ supersededBy,
37
+ supersededFile,
28
38
  } from "./run-store.ts";
29
- import { protocolView } from "./protocol.ts";
39
+ import { protocolNext, protocolView } from "./protocol.ts";
40
+ import { writePin } from "./run-pin.ts";
30
41
  import { claimRun, currentSession } from "./sessions.ts";
31
- import { refreshState } from "./state.ts";
42
+ import { readNotes, refreshState } from "./state.ts";
43
+
44
+ /** The run `id` was superseded by, or undefined: an open run, or one that cannot be found. */
45
+ function nextInChain(id: string): string | undefined {
46
+ try {
47
+ return supersededBy(findRun(id))?.by;
48
+ } catch {
49
+ return undefined;
50
+ }
51
+ }
52
+
53
+ /**
54
+ * `runs supersede <run> --by <run>`: closes `run` with a pointer to the run that took it over (a planning
55
+ * run handed to the execution run in a worktree). Status hides it; dispatch refuses it. A run with live
56
+ * roles is refused: cancel them, or let them finish, first.
57
+ */
58
+ export async function supersedeRun(
59
+ deps: Deps,
60
+ i: { run: string; by: string },
61
+ ): Promise<{ run: string; by: string; at: string; hints?: string[] }> {
62
+ const run = findRun(i.run);
63
+ const by = findRun(i.by);
64
+ if (run.id === by.id)
65
+ throw new CatherdError("E_INPUT_INVALID", `run ${run.id} cannot supersede itself`, {
66
+ fix: "pass the run that took over as --by",
67
+ });
68
+ // walk the chain the new pointer would join: any run it reaches back to `run` closes a cycle
69
+ const seen = new Set<string>([by.id]);
70
+ for (let hop = supersededBy(by)?.by; hop !== undefined && !seen.has(hop);) {
71
+ if (hop === run.id)
72
+ throw new CatherdError("E_INPUT_INVALID", `run ${by.id} is itself superseded by ${run.id}`, {
73
+ fix: "supersede the older run by the newer one",
74
+ });
75
+ seen.add(hop);
76
+ hop = nextInChain(hop);
77
+ }
78
+ // under the run's admission lock: a dispatch admitted meanwhile is either live here, or sees the pointer
79
+ const at = await withFileLock(runPaths(run.dir).admission, () => {
80
+ assertNoLiveRoles(
81
+ deps,
82
+ run,
83
+ `cancel them (cancel(run, name)) or let them finish, then supersede ${run.id}`,
84
+ );
85
+ return writeSuperseded(deps, run, by.id);
86
+ });
87
+ return { run: run.id, by: by.id, at, ...(await supersededHints(run, by.id)) };
88
+ }
89
+
90
+ /** Held under `run`'s admission lock: no role of it may be live when it closes. */
91
+ function assertNoLiveRoles(deps: Deps, run: Run, fix: string): void {
92
+ const live = liveDispatches(run, deps.now());
93
+ if (live.length)
94
+ throw new CatherdError(
95
+ "E_INPUT_INVALID",
96
+ `run ${run.id} still has live roles: ${live.map((d) => d.admit.name).join(", ")}`,
97
+ { fix },
98
+ );
99
+ }
100
+
101
+ /** Held under `run`'s admission lock: the pointer to the run that took it over; its time. */
102
+ function writeSuperseded(deps: Deps, run: Run, by: string): string {
103
+ const when = new Date(deps.now()).toISOString();
104
+ writeJsonAtomic(supersededFile(run), { schema: 1, by, at: when });
105
+ return when;
106
+ }
107
+
108
+ async function supersededHints(run: Run, by: string): Promise<{ hints?: string[] }> {
109
+ const { hints } = await refreshState(run, { next: `superseded by ${by}: continue there` });
110
+ return hints.length ? { hints } : {};
111
+ }
32
112
 
33
113
  export async function startRun(
34
114
  deps: Deps,
35
- i: { repo: string; title: string; aLines: string[] },
115
+ i: { repo: string; title: string; aLines: string[]; from?: string; base?: string },
36
116
  ): Promise<{
37
117
  run: string;
38
118
  dir: string;
@@ -44,17 +124,53 @@ export async function startRun(
44
124
  throw new CatherdError("E_IO_PATH", `${i.repo} is not inside a git repository`, {
45
125
  fix: "pass the path of the repository to work in",
46
126
  });
47
- const run = createRun({
48
- repo: top,
49
- title: i.title,
50
- aLines: i.aLines,
51
- version: deps.version,
52
- now: new Date(deps.now()),
53
- startedBy: currentSession(deps),
54
- });
55
- await claimRun(deps, run);
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
+ }
139
+ // where the first milestone's commit range starts; a repo with no commit yet has none
140
+ const head = await git(top, ["rev-parse", "--verify", "--quiet", "HEAD^{commit}"]);
141
+ const open = async (): Promise<Run> => {
142
+ const run = createRun({
143
+ repo: top,
144
+ title: i.title,
145
+ aLines: i.aLines,
146
+ version: deps.version,
147
+ startHead: head.kind === "ok" ? head.out.trim() || null : null,
148
+ ...(i.base ? { base: i.base } : {}),
149
+ now: new Date(deps.now()),
150
+ startedBy: currentSession(deps),
151
+ });
152
+ // spec 1.5 "Pinned per run": what the run starts on stays what it dispatches on
153
+ writePin(deps, run);
154
+ writeRunIndex(run.dir, start.index);
155
+ await claimRun(deps, run);
156
+ return run;
157
+ };
158
+ // the run this one takes over must be free to close before anything is created, and stay so until its pointer
159
+ // is written: its admission lock is held from the check to the pointer, so no dispatch gets in between and
160
+ // leaves the new run open beside it (PR #50). It is the only lock taken first: the new run's are uncontended.
161
+ const from = i.from === undefined ? null : findRun(i.from);
162
+ const run = from
163
+ ? await withFileLock(runPaths(from.dir).admission, async () => {
164
+ assertNoLiveRoles(deps, from, `cancel them or let them finish, then run_start with from: ${from.id}`);
165
+ const opened = await open();
166
+ writeSuperseded(deps, from, opened.id);
167
+ return opened;
168
+ })
169
+ : await open();
170
+ const superseded = from ? await supersededHints(from, run.id) : {};
56
171
  // a failed state.md refresh never fails the start: the run exists and is usable, so a retry would orphan it
57
172
  const { hints } = await refreshState(run);
173
+ hints.push(...(superseded.hints ?? []));
58
174
  // spec 1.1 §10: the milestone loop, so the orchestrator starts on the protocol
59
175
  return { run: run.id, dir: run.dir, protocol: protocolView(run, []), ...(hints.length ? { hints } : {}) };
60
176
  }
@@ -63,7 +179,22 @@ export function writeRunFile(i: { run: string; path: string; content: string }):
63
179
  path: string;
64
180
  bytes: number;
65
181
  } {
66
- const file = runFile(findRun(i.run), i.path, "write");
182
+ const run = findRun(i.run);
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
+ );
195
+ // spec 1.5 "Lane editing": a lane's header values are checked when it is written, not first at preflight
196
+ const lane = /^lanes\/([^/]+)\.md$/.exec(rel);
197
+ if (lane) assertLaneValues(i.content, `lanes/${lane[1]}.md`);
67
198
  writeTextAtomic(file, i.content);
68
199
  return { path: file, bytes: Buffer.byteLength(i.content) };
69
200
  }
@@ -99,6 +230,28 @@ function capReply(reply: string, path: string): string {
99
230
  return `${head.slice(0, CAP_CHARS)}\n[capped: the full reply is ${path}]`;
100
231
  }
101
232
 
233
+ /** Whether `text` names `token` (a role name or lane id) as a whole word, a full stop after it allowed. */
234
+ export function names(text: string, token: string): boolean {
235
+ const t = token.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
236
+ return new RegExp(`(?<![\\w.-])${t}(?![\\w-]|\\.\\w)`).test(text);
237
+ }
238
+
239
+ /**
240
+ * Plan 22: state.md's Next never outlives the step it names. When it names a role whose record `result` returns
241
+ * (by its name or its lane), it moves on to the protocol's next step. A refresh that fails comes back as hints.
242
+ */
243
+ async function advanceNext(run: Run, record: RunRecord): Promise<string[]> {
244
+ const stale = (next: string) =>
245
+ names(next, record.name) || (record.lane !== null && names(next, record.lane));
246
+ if (!stale(readNotes(run).next)) return [];
247
+ const { hints } = await refreshState(run, (n) =>
248
+ stale(n.next)
249
+ ? { next: `after ${record.name} (${record.status}): ${protocolNext(run, n.parked ?? [])}` }
250
+ : {},
251
+ );
252
+ return hints;
253
+ }
254
+
102
255
  /**
103
256
  * A role's latest dispatch: its record once finished, its capped reply and its hints. Spec §3.7: reading a
104
257
  * finished record marks it read, with the collect lease (each record is marked read once, whoever reads it);
@@ -134,6 +287,7 @@ export async function result(
134
287
  if (x.admit.dispatchId !== d.admit.dispatchId) hints.push(...recordHints(run, x, r));
135
288
  }
136
289
  if (record) for (const h of recordHints(run, d, record)) if (!hints.includes(h)) hints.push(h);
290
+ if (record) for (const h of await advanceNext(run, record)) if (!hints.includes(h)) hints.push(h);
137
291
  const reply = dispatchPaths(d.dir).reply;
138
292
  const text = existsSync(reply) ? readFileSync(reply, "utf8") : "";
139
293
  return {
@@ -159,6 +313,10 @@ export function recordAgentRun(
159
313
  durationMs?: number;
160
314
  status?: AgentRun["status"];
161
315
  lane?: string;
316
+ /** plan 23: a native verifier's first reply line, so a VERDICT: BLOCKED: environment is told from a FAIL */
317
+ verdict?: string;
318
+ /** plan 23: the STATUS word of the subagent's reply */
319
+ replyStatus?: NonNullable<AgentRun["replyStatus"]>;
162
320
  },
163
321
  ): AgentRun {
164
322
  const run = findRun(i.run);
@@ -180,6 +338,8 @@ export function recordAgentRun(
180
338
  secs: i.durationMs === undefined ? null : Math.round(i.durationMs / 1000),
181
339
  status: i.status ?? "ok",
182
340
  lane: i.lane ?? null,
341
+ ...(i.verdict ? { verdict: i.verdict.trim().split("\n")[0] } : {}),
342
+ ...(i.replyStatus ? { replyStatus: i.replyStatus } : {}),
183
343
  };
184
344
  appendAgentRun(run, row);
185
345
  return row;
@@ -197,7 +357,7 @@ async function repoTop(repo: string): Promise<string> {
197
357
 
198
358
  /** What past runs of the repo learned; `repo` may be any path inside it. */
199
359
  export async function readKnowledge(repo: string): Promise<string> {
200
- const file = knowledgeFile(await repoTop(repo));
360
+ const file = await knowledgeFor(await repoTop(repo));
201
361
  const text = existsSync(file) ? readFileSync(file, "utf8") : "";
202
362
  return text.trim() ? text : "catherd: no knowledge recorded yet for this repo";
203
363
  }
@@ -205,7 +365,7 @@ export async function readKnowledge(repo: string): Promise<string> {
205
365
  /** Where the repo's knowledge.md is (it may not exist yet); `repo` may be any path inside it. */
206
366
  export async function knowledgePath(repo: string): Promise<{ repo: string; path: string }> {
207
367
  const top = await repoTop(repo);
208
- return { repo: top, path: knowledgeFile(top) };
368
+ return { repo: top, path: await knowledgeFor(top) };
209
369
  }
210
370
 
211
371
  /** The repo's knowledge.md as its lines, none when it is missing or blank; `repo` may be any path inside it. */
@@ -218,12 +378,33 @@ export async function knowledgeLines(repo: string): Promise<{ repo: string; path
218
378
  /** The source a line added by hand carries, where a landing puts its run's title and milestone. */
219
379
  const BY_HAND = "by hand";
220
380
 
221
- /** Appends one line of the user's own to the repo's knowledge.md, as `land`'s `learned` does; returns it. */
222
- 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> {
223
394
  const top = await repoTop(repo);
224
395
  if (!text.trim())
225
396
  throw new CatherdError("E_INPUT_INVALID", "the knowledge line is empty", {
226
397
  fix: 'pass one line of text, e.g. catherd knowledge add "the targeted test is bun test <file>"',
227
398
  });
228
- 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);
229
410
  }