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
@@ -1,5 +1,7 @@
1
+ import type { BlockedReason } from "../domain/blocked.ts";
1
2
  import type { HostContext } from "../domain/host.ts";
2
3
  import type { Provenance } from "./provenance.ts";
4
+ import type { Bars } from "../domain/bars.ts";
3
5
  import type { Budget } from "../domain/budget.ts";
4
6
  import type { SessionEnv } from "../infra/claude-session.ts";
5
7
  import type { BillingMode } from "../domain/cost.ts";
@@ -7,10 +9,12 @@ import type { Verdict } from "../domain/jev.ts";
7
9
  import type { Difficulty, Kind } from "../domain/lane.ts";
8
10
  import type { Access } from "../domain/record.ts";
9
11
  import type { Role } from "../domain/roles.ts";
12
+ import type { RoleScope } from "../domain/role-scope.ts";
10
13
  import type { Change, ProfileDoc, ProfilePatch } from "../domain/profile.ts";
11
14
  import type { Issue } from "../domain/profile-rules.ts";
12
15
  import type { RouteJev, RouteSource } from "../domain/route.ts";
13
- import type { SyncReport } from "./source-sync.ts";
16
+ import type { IndexFile } from "../domain/slopalytics.ts";
17
+ import type { IndexStatus } from "./catalog-service.ts";
14
18
 
15
19
  /** What the run lifecycle reads from a profile. Rungs are `backend:model#effort`; a role's in ladder order. */
16
20
  export interface ProfileView {
@@ -18,7 +22,20 @@ export interface ProfileView {
18
22
  objective: "cost" | "speed";
19
23
  /** `defaultRung`, when set, is where a lane starts without a kind and difficulty (spec §5.4) */
20
24
  roles: Partial<
21
- Record<Role, { enabled: boolean; access: Access; rungs: string[]; defaultRung?: string; network?: false }>
25
+ Record<
26
+ Role,
27
+ {
28
+ enabled: boolean;
29
+ access: Access;
30
+ rungs: string[];
31
+ defaultRung?: string;
32
+ network?: false;
33
+ /** plan 23: the role's own timeouts over the profile's */
34
+ timeouts?: { idleMin?: number; wallMin?: number };
35
+ /** plan 33: the role's own bar percentiles over the profile's */
36
+ bars?: Bars;
37
+ }
38
+ >
22
39
  >;
23
40
  /** per billing key (spec §7.1); a missing key bills as DEFAULT_BILLING */
24
41
  billing: Partial<Record<string, BillingMode>>;
@@ -27,11 +44,17 @@ export interface ProfileView {
27
44
  isolated: Partial<Record<string, boolean>>;
28
45
  /** rung → its stand-in on a usage limit (spec §4.5) */
29
46
  failover: Record<string, string>;
47
+ /** `<backend>:<model>` → the highest effort routing starts it at (plan 33) */
48
+ maxEffort?: Record<string, string>;
49
+ /** plan 33: bar percentiles per difficulty (0 to 100) over the shipped ones */
50
+ bars?: Bars;
30
51
  budget: Budget;
31
52
  timeouts: { idleMin: number; wallMin: number };
32
53
  preflight: { confirm: boolean };
33
54
  heavy: number | "cpus/2";
34
55
  notify: string[];
56
+ /** plan 35: the memory numbers that cap milestones side by side, defaults filled */
57
+ limits: { memory: { reserveMb: number; perMilestoneMb: number; floorMb: number } };
35
58
  }
36
59
 
37
60
  /** What a profile write returns (spec §7.3): whether it saved, why not, what changed, and the agents it touched. */
@@ -70,6 +93,10 @@ export interface ProfilePort {
70
93
  set(name: string | undefined, patch: ProfilePatch, repo?: string | null): ProfileSaved;
71
94
  /** The native agent that runs `rung` for `role` under `repo`'s profile; null unless it is a `claude:` rung. */
72
95
  agentFor(repo: string | null, role: Role, rung: string): string | null;
96
+ /** The ntfy topic url of the phone messages: config's `notify.url`, a random ntfy.sh topic made once. */
97
+ notifyUrl(): string;
98
+ /** Replaces it; an http(s) url only. */
99
+ setNotifyUrl(url: string): void;
73
100
  }
74
101
 
75
102
  export interface RouteRequest {
@@ -82,6 +109,8 @@ export interface RouteRequest {
82
109
  lane: string | null;
83
110
  laneText: string | null;
84
111
  spentFraction: number;
112
+ /** spec 1.5 plan 24: the run's dispatches so far per quota, for a tie between rungs of equal scores */
113
+ usage?: Record<string, number>;
85
114
  }
86
115
 
87
116
  export interface RouteAnswer {
@@ -93,6 +122,14 @@ export interface RouteAnswer {
93
122
  /** the Jev question set asked, and its summed probabilities, for outcomes.jsonl (spec §5.6) */
94
123
  questionSet: string | null;
95
124
  jev: RouteJev | null;
125
+ /** spec 1.5 plan 24: where Jev disagreed with a declared lane header (`Jev said logic/hard`), else null */
126
+ jevSaid: string | null;
127
+ /** spec 1.5 plan 24: no rung clears the lane's bar, and the closest one */
128
+ noClear?: string;
129
+ /** spec 1.5 plan 24: rungs of equal scores on other quotas competed for the start, and what decided */
130
+ tie?: string;
131
+ /** spec 1.5 plan 24: the decision in one line, for the coordinator */
132
+ why: string;
96
133
  /** spec 1.2 §5.3, §8: the chosen rung's thresholds, values, sources, facts and run evidence */
97
134
  provenance?: Provenance;
98
135
  }
@@ -103,7 +140,6 @@ export interface CatalogFilter {
103
140
  role?: Role;
104
141
  backend?: string;
105
142
  text?: string;
106
- scoredOnly: boolean;
107
143
  limit: number;
108
144
  /** the git toplevel whose per-repository listings (opencode's) to read; none reads the global ones */
109
145
  repo?: string;
@@ -111,6 +147,8 @@ export interface CatalogFilter {
111
147
 
112
148
  export interface RoutingPort {
113
149
  route(req: RouteRequest): Promise<RouteAnswer>;
150
+ /** spec 1.5 plan 24: the lanes of one role in one call; Jev is asked about all of them at once */
151
+ routeMany(reqs: RouteRequest[]): Promise<RouteAnswer[]>;
114
152
  /** `use` is the repo profile's `jev.use`: "off" answers with the rule's default and never asks Jev */
115
153
  finding(
116
154
  runDir: string,
@@ -124,6 +162,18 @@ export interface RoutingPort {
124
162
  after: string,
125
163
  use: "auto" | "off",
126
164
  ): Promise<Verdict<"yes" | "no">>;
165
+ /** plan 30b: why a role stopped that gave no reason; `other` and an unsure answer are the rule's fallback */
166
+ stopCause(
167
+ runDir: string,
168
+ status: string,
169
+ reply: string,
170
+ use: "auto" | "off",
171
+ ): Promise<Verdict<BlockedReason | "other">>;
172
+ /**
173
+ * A run's start check: refreshes the index if it is due, reads it once, and names the enabled roles of `view`
174
+ * that no indexed pair can fill; `index` is what the run pins (null when there is none).
175
+ */
176
+ checkStart(view: ProfileView, repo: string): Promise<{ unusable: Role[]; index: IndexFile | null }>;
127
177
  /** `billing` prices the rungs (spec §5.3); absent keys bill as DEFAULT_BILLING */
128
178
  catalog(
129
179
  filter: CatalogFilter,
@@ -142,6 +192,11 @@ export interface Deps {
142
192
  /** Claude transport credentials and registry refresh; host identity lives in `host`. */
143
193
  session: SessionEnv | null;
144
194
  now: () => number;
145
- /** spec 1.2 §3.2 `catalog_sync`; default: the real sync (tests inject one that never reaches the network) */
146
- sync?: (o: { force: boolean }) => Promise<SyncReport>;
195
+ /** `catalog_sync`; default: the real refresh (tests inject one that never reaches the network) */
196
+ sync?: (o: { force: boolean }) => Promise<IndexStatus>;
197
+ /**
198
+ * Spec 1.5 plan 21: the role this process works for (CATHERD_ROLE, or a role's scratch TMPDIR); null or
199
+ * absent for the orchestrator's own server. A role never claims a run and never reaches a coordinator tool.
200
+ */
201
+ role?: RoleScope | null;
147
202
  }
@@ -1,16 +1,24 @@
1
1
  import { existsSync, readdirSync, readFileSync } from "node:fs";
2
2
  import { join } from "node:path";
3
3
  import { CatherdError, errorMessage } from "../domain/errors.ts";
4
- import { assertLaneHeader, LANE_HEADER_FIX, parseLaneHeader } from "../domain/lane.ts";
4
+ import { assertLaneHeader, LANE_HEADER_FIX, onlyAllowedHits, parseLaneHeader } from "../domain/lane.ts";
5
+ import { coverageProblems } from "../domain/plan-doc.ts";
6
+ import { lintLanes } from "../domain/plan-lint.ts";
5
7
  import { checkEnv } from "../infra/env.ts";
6
- import { heavySlots, withHeavySlot } from "../infra/heavy-lock.ts";
8
+ import { heavySlots, withHeavySlotWithin } from "../infra/heavy-lock.ts";
9
+ import { loginEnv } from "../infra/login-env.ts";
7
10
  import { killGroup } from "../infra/proc.ts";
11
+ import { readGateEnv, resolveGateEnv } from "./gate-env.ts";
12
+ import { landedMilestones } from "./milestones.ts";
13
+ import { planDocs } from "./plan-input.ts";
8
14
  import type { Deps } from "./ports.ts";
15
+ import { isDaemonRun, replayRun } from "./run-events-store.ts";
9
16
  import { TAIL_LINES } from "./run-debug.ts";
10
17
  import { findRun, type Run, runPaths } from "./run-store.ts";
18
+ import { treeOf } from "./tree-path.ts";
11
19
 
12
- /** Spec §4.7. Only `cannot-start` blocks the run. */
13
- export type PreflightOutcome = "pass" | "fails-as-expected" | "skipped" | "cannot-start";
20
+ /** Spec §4.7. Only `cannot-start` blocks the run; `lock-busy` (plan 23) means: run preflight again. */
21
+ export type PreflightOutcome = "pass" | "fails-as-expected" | "skipped" | "cannot-start" | "lock-busy";
14
22
 
15
23
  interface PreflightResult {
16
24
  lane: string;
@@ -22,12 +30,21 @@ interface PreflightResult {
22
30
  }
23
31
 
24
32
  export type PreflightReport =
25
- | { needsConfirmation: true; commands: { lane: string; check: string | null }[] }
33
+ | {
34
+ needsConfirmation: true;
35
+ commands: { lane: string; check: string | null }[];
36
+ /** plan 34: the plan lint's warnings */
37
+ lint: string[];
38
+ }
26
39
  | {
27
40
  needsConfirmation: false;
28
41
  commands: { lane: string; check: string | null }[];
29
42
  results: PreflightResult[];
30
43
  blocked: boolean;
44
+ /** plan 23: lanes whose fast check runs no linter while the repo has one */
45
+ warnings: string[];
46
+ /** plan 34: the plan lint's warnings */
47
+ lint: string[];
31
48
  };
32
49
 
33
50
  const CHECK_TIMEOUT_MS = 120_000;
@@ -47,9 +64,15 @@ interface LaneCheck {
47
64
  lane: string;
48
65
  check: string | null;
49
66
  owns: string[];
67
+ /** spec 1.5: the Allow: exceptions of the lane's absence check */
68
+ allow: string[];
50
69
  problem: string | null;
51
70
  /** spec 1.1 §6: why its Kind: or Difficulty: line is refused */
52
71
  invalid: string | null;
72
+ /** plan 32: the plan tasks it names on its `Tasks:` line */
73
+ tasks: string[];
74
+ /** plan 34: the lane file's text; null when it cannot be read */
75
+ text: string | null;
53
76
  }
54
77
 
55
78
  function laneChecks(run: Run): LaneCheck[] {
@@ -73,15 +96,30 @@ function laneChecks(run: Run): LaneCheck[] {
73
96
  lane,
74
97
  check: h.fastCheck,
75
98
  owns: h.owns,
99
+ allow: h.allow,
76
100
  problem: h.fastCheck ? null : `lanes/${f} has no Fast check: line`,
77
101
  invalid,
102
+ tasks: h.tasks,
103
+ text,
78
104
  };
79
105
  } catch (e) {
80
- return { lane, check: null, owns: [], problem: (e as Error).message, invalid };
106
+ return {
107
+ lane,
108
+ check: null,
109
+ owns: [],
110
+ allow: [],
111
+ problem: (e as Error).message,
112
+ invalid,
113
+ tasks: [],
114
+ text: null,
115
+ };
81
116
  }
82
117
  });
83
118
  }
84
119
 
120
+ /** How many output lines a check's Allow: exceptions are matched against; past it, the check fails as before. */
121
+ const ALLOW_LINES = 1_000;
122
+
85
123
  /** How long the pipes may stay open after the check's own process exits. */
86
124
  export const DRAIN_MS = 500;
87
125
 
@@ -116,10 +154,11 @@ export async function runCheck(
116
154
  repo: string,
117
155
  check: string,
118
156
  timeoutMs: number,
119
- ): Promise<{ code: number | null; timedOut: boolean; tail: string[] }> {
157
+ env: Record<string, string> = checkEnv(process.env, repo),
158
+ ): Promise<{ code: number | null; timedOut: boolean; tail: string[]; lines: string[] }> {
120
159
  const p = Bun.spawn(["sh", "-c", check], {
121
160
  cwd: repo,
122
- env: checkEnv(process.env, repo),
161
+ env,
123
162
  stdin: "ignore",
124
163
  stdout: "pipe",
125
164
  stderr: "pipe",
@@ -141,11 +180,10 @@ export async function runCheck(
141
180
  Bun.sleep(DRAIN_MS).then(() => false),
142
181
  ]);
143
182
  if (!drained) killGroup(p.pid, "SIGKILL"); // leftovers still in the check's group
144
- const tail = `${out.text()}${err.text()}`
145
- .split("\n")
146
- .filter((l) => l.trim())
147
- .slice(-TAIL_LINES);
148
- return { code: timedOut ? null : code, timedOut, tail };
183
+ // every line, up to a bound, for a lane's Allow: exceptions; the tail is what the report shows
184
+ const all = `${out.text()}${err.text()}`.split("\n").filter((l) => l.trim());
185
+ const lines = all.length <= ALLOW_LINES ? all : [];
186
+ return { code: timedOut ? null : code, timedOut, tail: all.slice(-TAIL_LINES), lines };
149
187
  } finally {
150
188
  clearTimeout(timer);
151
189
  out.stop();
@@ -153,10 +191,100 @@ export async function runCheck(
153
191
  }
154
192
  }
155
193
 
156
- export function classify(r: { code: number | null; timedOut: boolean; tail: string[] }): PreflightOutcome {
194
+ /**
195
+ * Plan 23: a check that failed on the machine, not on the code: no Docker (or none at DOCKER_HOST), no DNS, a
196
+ * permission the sandbox or the OS denied. Such a check cannot start; it never fails as expected.
197
+ */
198
+ const ENVIRONMENT = [
199
+ /command not found/i,
200
+ /Cannot connect to the Docker daemon/i,
201
+ /rootless Docker not found/i,
202
+ /Could not find a valid Docker environment/i,
203
+ /docker\.sock: connect: no such file or directory/i,
204
+ /Temporary failure in name resolution|Could not resolve host|no such host|Name or service not known|nodename nor servname|getaddrinfo (ENOTFOUND|EAI_AGAIN)/i,
205
+ /permission denied|operation not permitted|\bEACCES\b|\bEPERM\b/i,
206
+ ];
207
+
208
+ /** pnpm exits 0 on a `--filter` that matches no package: the lane creates it, so there is nothing to run yet. */
209
+ const EMPTY_FILTER = /No projects matched the filters/i;
210
+
211
+ /** The first tail line an ENVIRONMENT pattern matches, or null. */
212
+ export const environmentLine = (tail: string[]): string | null =>
213
+ tail.find((l) => ENVIRONMENT.some((re) => re.test(l))) ?? null;
214
+
215
+ /** One `pnpm … --filter …` command, nothing chained, piped or backgrounded beside it. */
216
+ const SINGLE_PNPM_FILTER = /^\s*pnpm\s(?:[^;&|\n`$()<>]*\s)?(?:--filter|-F)(?:[\s=])[^;&|\n`$()<>]*$/;
217
+
218
+ /**
219
+ * An unmatched pnpm filter is "skipped" only when it is provably the check's sole failure: the whole check
220
+ * exited 0, or the check is a single pnpm --filter command. In a compound check (`pnpm --filter new test;
221
+ * docker compose up`) a later command's failure decides.
222
+ */
223
+ export function classify(
224
+ r: { code: number | null; timedOut: boolean; tail: string[] },
225
+ command?: string,
226
+ ): PreflightOutcome {
157
227
  if (r.timedOut || r.code === 126 || r.code === 127) return "cannot-start";
228
+ const soleFailure = r.code === 0 || (command !== undefined && SINGLE_PNPM_FILTER.test(command));
229
+ if (soleFailure && r.tail.some((l) => EMPTY_FILTER.test(l))) return "skipped";
158
230
  if (r.code === 0) return "pass";
159
- return r.tail.some((l) => /command not found/i.test(l)) ? "cannot-start" : "fails-as-expected";
231
+ return environmentLine(r.tail) !== null ? "cannot-start" : "fails-as-expected";
232
+ }
233
+
234
+ /** Why a classified result says what it says, for its note. */
235
+ function noteOf(
236
+ r: { code: number | null; timedOut: boolean; tail: string[] },
237
+ outcome: PreflightOutcome,
238
+ timeoutMs: number,
239
+ ): string | null {
240
+ if (r.timedOut) return `timed out after ${timeoutMs / 1000} s`;
241
+ if (outcome === "skipped") return "the pnpm filter matches no package yet; the lane creates it";
242
+ const env = outcome === "cannot-start" ? environmentLine(r.tail) : null;
243
+ return env ? `environment: ${env.trim()}` : null;
244
+ }
245
+
246
+ /** How long one check waits for a heavy slot before it reports lock-busy; tests shorten it. */
247
+ export const preflightLimits = { lockWaitMs: 60_000 };
248
+
249
+ /**
250
+ * Plan 23: a check runs in the user's login environment (DOCKER_HOST and the rest a login shell sets), through
251
+ * the allowlist, plus the repo's gate environment. A login PATH comes first, with the server's own after it.
252
+ */
253
+ export async function checkEnvFor(repo: string): Promise<Record<string, string>> {
254
+ const login = loginEnv();
255
+ const base: Record<string, string | undefined> = { ...process.env, ...login };
256
+ const path = [
257
+ ...new Set([...(login.PATH ?? "").split(":"), ...(process.env.PATH ?? "").split(":")].filter(Boolean)),
258
+ ];
259
+ if (path.length) base.PATH = path.join(":");
260
+ return { ...checkEnv(base, repo), ...resolveGateEnv(await readGateEnv(repo), base).env };
261
+ }
262
+
263
+ /** What a fast check names when it lints: a lint script, or a linter by name. */
264
+ const LINT_STEP = /\b(lint|eslint|biome|oxlint|golangci-lint|ruff|clippy|vet|check)\b/i;
265
+
266
+ /** Root files that say the repo has a linter, by the linter they configure. */
267
+ const LINT_CONFIGS: [RegExp, string][] = [
268
+ [/^\.golangci\.(ya?ml|toml|json)$/, "golangci-lint"],
269
+ [/^(biome\.jsonc?)$/, "biome"],
270
+ [/^(eslint\.config\.[cm]?[jt]s|\.eslintrc(\.[a-z]+)?)$/, "eslint"],
271
+ [/^\.?oxlintrc\.json$/, "oxlint"],
272
+ [/^\.?ruff\.toml$/, "ruff"],
273
+ ];
274
+
275
+ /** The linter the repo has, by name, or null: a root lint config, or a `lint` script in package.json. */
276
+ export function repoLinter(repo: string): string | null {
277
+ for (const name of existsSync(repo) ? readdirSync(repo) : [])
278
+ for (const [re, linter] of LINT_CONFIGS) if (re.test(name)) return linter;
279
+ try {
280
+ const pkg = JSON.parse(readFileSync(join(repo, "package.json"), "utf8")) as {
281
+ scripts?: Record<string, unknown>;
282
+ };
283
+ if (typeof pkg.scripts?.lint === "string") return "the lint script";
284
+ } catch {
285
+ // no package.json, or one that does not parse: no lint script
286
+ }
287
+ return null;
160
288
  }
161
289
 
162
290
  /**
@@ -165,18 +293,60 @@ export function classify(r: { code: number | null; timedOut: boolean; tail: stri
165
293
  */
166
294
  export async function preflight(
167
295
  deps: Deps,
168
- i: { run: string; confirmed?: boolean; timeoutMs?: number },
296
+ i: { run: string; confirmed?: boolean; timeoutMs?: number; milestone?: string },
169
297
  ): Promise<PreflightReport> {
170
298
  const run = findRun(i.run);
171
299
  const profile = deps.profiles.forRepo(run.meta.repo);
172
- const lanes = laneChecks(run);
300
+ // plan 23: one milestone's lanes, or by default every lane whose milestone has not landed
301
+ const landed = new Set(landedMilestones(run));
302
+ const milestoneOf = (lane: string) => lane.split(".")[0] as string;
303
+ const lanes = laneChecks(run).filter((l) =>
304
+ i.milestone ? milestoneOf(l.lane) === i.milestone : !landed.has(milestoneOf(l.lane)),
305
+ );
173
306
  // spec 1.1 §6: refuse before running anything, naming every lane whose header the catalog cannot route
174
307
  const invalid = lanes.flatMap((l) => (l.invalid ? [l.invalid] : []));
175
308
  if (invalid.length) throw new CatherdError("E_LANE_INVALID", invalid.join("; "), { fix: LANE_HEADER_FIX });
176
309
  const commands = lanes.map((l) => ({ lane: l.lane, check: l.check }));
177
- if (profile.preflight.confirm && !i.confirmed) return { needsConfirmation: true, commands };
310
+ const { warnings: lint, stuck } = lintLanes(
311
+ lanes.flatMap((l) => (l.text === null ? [] : [{ id: l.lane, text: l.text }])),
312
+ laneChecks(run).map((l) => l.lane),
313
+ // a base run's milestone starts after the ones it names are merged, not only landed (plan 35)
314
+ run.meta.base
315
+ ? replayRun(run)
316
+ .milestones.filter((m) => m.status === "merged")
317
+ .map((m) => m.id)
318
+ : [...landed],
319
+ );
320
+ // plan 32: a daemon run starts no lane the plan does not back, before the confirm list and before any check
321
+ if (isDaemonRun(run)) {
322
+ const problems = coverageProblems(
323
+ planDocs(run),
324
+ lanes.map((l) => ({ id: l.lane, tasks: l.tasks })),
325
+ );
326
+ if (problems.length)
327
+ return {
328
+ needsConfirmation: false,
329
+ commands,
330
+ results: problems.map((p) => ({
331
+ lane: p.lane || (i.milestone ?? "plan"),
332
+ check: null,
333
+ outcome: "cannot-start" as const,
334
+ exitCode: null,
335
+ tail: [],
336
+ note: p.note,
337
+ })),
338
+ blocked: true,
339
+ warnings: [],
340
+ lint,
341
+ };
342
+ }
343
+ if (profile.preflight.confirm && !i.confirmed) return { needsConfirmation: true, commands, lint };
178
344
  const timeoutMs = i.timeoutMs ?? CHECK_TIMEOUT_MS;
345
+ // plan 35: a base run's milestone is checked in its own worktree
346
+ const cwd = treeOf(run, i.milestone ?? null);
179
347
  const results: PreflightResult[] = [];
348
+ // the login env is captured once per server; the gate env is read once per preflight
349
+ let env: Record<string, string> | undefined;
180
350
  for (const l of lanes) {
181
351
  const check = l.check;
182
352
  if (!check) {
@@ -194,28 +364,66 @@ export async function preflight(
194
364
  results.push({ lane: l.lane, check, outcome: "skipped", exitCode: null, tail: [], note: AS_ROOT });
195
365
  continue;
196
366
  }
197
- const unborn = l.owns.filter(
198
- (p) => check.includes(p.replace(/\/$/, "")) && !existsSync(join(run.meta.repo, p)),
199
- );
367
+ const unborn = l.owns.filter((p) => check.includes(p.replace(/\/$/, "")) && !existsSync(join(cwd, p)));
200
368
  if (unborn.length) {
201
369
  const note = `${unborn.join(", ")} does not exist yet; the lane creates it`;
202
370
  results.push({ lane: l.lane, check, outcome: "skipped", exitCode: null, tail: [], note });
203
371
  continue;
204
372
  }
205
- const r = await withHeavySlot(heavySlots(profile.heavy), () => runCheck(run.meta.repo, check, timeoutMs));
373
+ env ??= await checkEnvFor(run.meta.repo);
374
+ const checkEnvironment = env;
375
+ // plan 23: each check waits for its slot within its own budget, and says lock-busy past it
376
+ const slot = await withHeavySlotWithin(
377
+ heavySlots(profile.heavy),
378
+ preflightLimits.lockWaitMs,
379
+ (_slot, tmp) => runCheck(cwd, check, timeoutMs, { ...checkEnvironment, TMPDIR: tmp, GOTMPDIR: tmp }),
380
+ { tmpBase: process.env.TMPDIR },
381
+ );
382
+ if (slot.busy) {
383
+ const note = `every heavy slot stayed busy for ${preflightLimits.lockWaitMs / 1000} s (other lanes' checks); run preflight again when they finish`;
384
+ results.push({ lane: l.lane, check, outcome: "lock-busy", exitCode: null, tail: [], note });
385
+ continue;
386
+ }
387
+ const r = slot.value;
388
+ // spec 1.5: an absence check whose every hit is one of the lane's Allow: exceptions has passed
389
+ const classified = classify(r, check);
390
+ const allowed = classified === "fails-as-expected" && onlyAllowedHits(r.lines, l.allow);
391
+ const outcome = allowed ? "pass" : classified;
206
392
  results.push({
207
393
  lane: l.lane,
208
394
  check,
209
- outcome: classify(r),
395
+ outcome,
210
396
  exitCode: r.code,
211
397
  tail: r.tail,
212
- note: r.timedOut ? `timed out after ${timeoutMs / 1000} s` : null,
398
+ note: allowed ? `every hit is an Allow: exception (${r.lines.length})` : noteOf(r, outcome, timeoutMs),
213
399
  });
214
400
  }
401
+ // plan 34: an After: that can never finish stops the milestone, so the daemon parks it instead of waiting forever
402
+ for (const s of stuck)
403
+ results.push({
404
+ lane: s.lane,
405
+ check: null,
406
+ outcome: "cannot-start",
407
+ exitCode: null,
408
+ tail: [],
409
+ note: s.note,
410
+ });
411
+ // plan 23: lint reaches the worker only through its fast check
412
+ const linter = repoLinter(run.meta.repo);
413
+ const warnings = linter
414
+ ? lanes
415
+ .filter((l) => l.check && !LINT_STEP.test(l.check))
416
+ .map(
417
+ (l) =>
418
+ `${l.lane}: its fast check runs no linter, and the repo has one (${linter}): add the linter of every package the lane touches to its Fast check: line`,
419
+ )
420
+ : [];
215
421
  return {
216
422
  needsConfirmation: false,
217
423
  commands,
218
424
  results,
219
425
  blocked: results.some((r) => r.outcome === "cannot-start"),
426
+ warnings,
427
+ lint,
220
428
  };
221
429
  }
@@ -1,5 +1,6 @@
1
1
  import type { HostContext, OrchestrationHost } from "../domain/host.ts";
2
2
  import { agentFiles } from "../domain/agents.ts";
3
+ import { type MemoryLimits, memoryLimitsOf } from "../domain/milestone-memory.ts";
3
4
  import { existsSync, readFileSync, rmSync } from "node:fs";
4
5
  import { join } from "node:path";
5
6
  import { isDeepStrictEqual } from "node:util";
@@ -78,7 +79,7 @@ function saveAndLink(name: string, doc: ProfileDoc, host: OrchestrationHost, rep
78
79
 
79
80
  const validate = (doc: ProfileDoc, name: string, host: OrchestrationHost): Validation => {
80
81
  try {
81
- return validateHere(resolveProfile(doc, name, host), loadCatalog({ timings: false }), doc, host);
82
+ return validateHere(resolveProfile(doc, name, host), loadCatalog(), doc, host);
82
83
  } catch (e) {
83
84
  if (!(e instanceof CatherdError)) throw e;
84
85
  return { errors: [{ path: "roles", message: e.message, fix: e.fix }], warnings: [] };
@@ -105,7 +106,7 @@ function syncFor(
105
106
  return allowPrune ? pruneProfileAgents(name) : { linked: [], pruned: [], newSessionNeededFor: [] };
106
107
  }
107
108
 
108
- /** What a save returns: the port's ProfileSaved (one type for the CLI, the TUI and the MCP tools). */
109
+ /** What a save returns: the port's ProfileSaved (one type for the CLI, the panel and the MCP tools). */
109
110
  export type Saved = ProfileSaved;
110
111
 
111
112
  const unsaved = (v: Validation): Saved => ({
@@ -121,7 +122,7 @@ const unsaved = (v: Validation): Saved => ({
121
122
  export const CHANGED_ON_DISK = "profile";
122
123
 
123
124
  /**
124
- * Spec §7.3 `patch` (profile_set, `catherd profile set`, the TUI's save): validates first and writes
125
+ * Spec §7.3 `patch` (profile_set, `catherd profile set`, the panel's save): validates first and writes
125
126
  * nothing when invalid, unless the save repairs the profile (spec 1.2 §6.2): it removes at least one error
126
127
  * the stored profile had and adds none. Then it saves, and the result's `errors` are those still open. A
127
128
  * profile that does not exist yet starts from the default profile. With `expect` (the profile as a preview
@@ -301,14 +302,23 @@ function viewOf(p: Profile): ProfileView {
301
302
  jev: p.jev,
302
303
  isolated: Object.fromEntries(Object.entries(p.harness).map(([k, h]) => [k, h.isolated])),
303
304
  failover: p.failover,
305
+ maxEffort: p.maxEffort,
306
+ bars: p.bars,
304
307
  budget: p.budget,
305
308
  timeouts: p.timeouts,
306
309
  preflight: p.preflight,
307
310
  heavy: p.lock.heavy,
308
311
  notify: p.notify,
312
+ limits: { memory: pickMemory(memoryLimitsOf(p)) },
309
313
  };
310
314
  }
311
315
 
316
+ const pickMemory = ({ reserveMb, perMilestoneMb, floorMb }: MemoryLimits) => ({
317
+ reserveMb,
318
+ perMilestoneMb,
319
+ floorMb,
320
+ });
321
+
312
322
  /** The ProfilePort the run engine and the MCP tools use (spec §7.3: the single writer). */
313
323
  export function profileService(host: () => HostContext): ProfilePort {
314
324
  // without a name, each tool acts on the profile `repo` runs on (the active one outside a repo)
@@ -333,6 +343,7 @@ export function profileService(host: () => HostContext): ProfilePort {
333
343
  name === undefined ? activeName(repo) : requireProfile(name),
334
344
  repo,
335
345
  host().host,
346
+ { reach: true },
336
347
  );
337
348
  return { valid: v.errors.length === 0, ...v };
338
349
  },
@@ -340,6 +351,23 @@ export function profileService(host: () => HostContext): ProfilePort {
340
351
  set: (name, patch, repo = null) => patchProfile(name ?? activeName(repo), patch, { host: host().host }),
341
352
  agentFor: (repo, role, rung) =>
342
353
  parseRung(rung).backend === "claude" ? agentName(activeName(repo), role, rung) : null,
354
+ notifyUrl: () =>
355
+ locked(() => {
356
+ const config = readConfig();
357
+ if (config.notify?.url) return config.notify.url;
358
+ const url = `https://ntfy.sh/catherd-${Buffer.from(crypto.getRandomValues(new Uint8Array(10))).toString("hex")}`;
359
+ writeJsonAtomic(configFile(), { ...config, schema: 1, notify: { ...config.notify, url } });
360
+ return url;
361
+ }),
362
+ setNotifyUrl: (url) =>
363
+ locked(() => {
364
+ if (!/^https?:\/\//.test(url) || !URL.canParse(url))
365
+ throw new CatherdError("E_INPUT_INVALID", `"${url}" is not an http(s) url`, {
366
+ fix: "give the ntfy topic's url, like https://ntfy.sh/<topic>",
367
+ });
368
+ const config = readConfig();
369
+ writeJsonAtomic(configFile(), { ...config, schema: 1, notify: { ...config.notify, url } });
370
+ }),
343
371
  };
344
372
  }
345
373
 
@@ -15,7 +15,12 @@ import {
15
15
  ProfileDocSchema,
16
16
  resolveProfile,
17
17
  } from "../domain/profile.ts";
18
- import { type Issue, type Validation, validateProfile } from "../domain/profile-rules.ts";
18
+ import {
19
+ type Issue,
20
+ type ValidateOptions,
21
+ type Validation,
22
+ validateProfile,
23
+ } from "../domain/profile-rules.ts";
19
24
  import type { Catalog } from "../domain/catalog.ts";
20
25
  import type { Access } from "../domain/record.ts";
21
26
  import { ROLES, type Role } from "../domain/roles.ts";
@@ -34,7 +39,12 @@ export const projectsFile = (): string => join(configDir(), "projects.json");
34
39
  export const agentsRoot = (): string => join(configDir(), "agents");
35
40
  export const profileFile = (name: string): string => join(profilesDir(), `${name}.json`);
36
41
 
37
- const ConfigSchema = z.looseObject({ schema: z.literal(1), activeProfile: z.string().optional() });
42
+ const ConfigSchema = z.looseObject({
43
+ schema: z.literal(1),
44
+ activeProfile: z.string().optional(),
45
+ /** where the phone messages go (an ntfy topic), made at the first run */
46
+ notify: z.looseObject({ url: z.string().optional() }).optional(),
47
+ });
38
48
  const ProjectsSchema = z.looseObject({
39
49
  schema: z.literal(1),
40
50
  bindings: z.record(z.string(), z.string()).default({}),
@@ -144,7 +154,7 @@ export function isolationKeyErrors(
144
154
 
145
155
  /**
146
156
  * Spec 1.3 §9 Q2: each enabled role whose rung, or a failover stand-in of one, runs natively on a backend that
147
- * holds the role's access only when isolated (agy has no read-only flag).
157
+ * holds the role's access only when isolated (one with no read-only flag).
148
158
  */
149
159
  export function isolatedOnlyErrors(p: Profile): Issue[] {
150
160
  const out: Issue[] = [];
@@ -220,8 +230,9 @@ export function validateHere(
220
230
  c: Catalog,
221
231
  doc?: ProfileDoc,
222
232
  host: OrchestrationHost = "unknown",
233
+ o: ValidateOptions = {},
223
234
  ): Validation {
224
- const v = validateProfile(p, c, runnableBackends(), doc, host);
235
+ const v = validateProfile(p, c, runnableBackends(), doc, host, o);
225
236
  return {
226
237
  errors: [...v.errors, ...isolationKeyErrors(p), ...isolatedOnlyErrors(p)],
227
238
  warnings: [...v.warnings, ...budgetUsdWarnings(p)],
@@ -233,12 +244,13 @@ export function validateNamed(
233
244
  name: string | undefined,
234
245
  repo: string | null,
235
246
  host: OrchestrationHost,
247
+ o: ValidateOptions = {},
236
248
  ): Validation {
237
249
  const n = name ?? activeName(repo);
238
250
  const doc = readProfileDoc(n);
239
- const catalog = loadCatalog({ timings: false, ...(repo === null ? {} : { repo }) });
251
+ const catalog = loadCatalog(repo === null ? {} : { repo });
240
252
  try {
241
- return validateHere(resolveProfile(doc, n, host), catalog, doc, host);
253
+ return validateHere(resolveProfile(doc, n, host), catalog, doc, host, o);
242
254
  } catch (e) {
243
255
  if (!(e instanceof CatherdError)) throw e;
244
256
  return { errors: [{ path: "roles", message: e.message, fix: e.fix }], warnings: [] };