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,25 +1,81 @@
1
1
  import { closeSync, openSync } from "node:fs";
2
2
  import { dirname, join } from "node:path";
3
3
  import { fileURLToPath } from "node:url";
4
+ import { ROLE_ENV } from "../domain/role-scope.ts";
4
5
  import { scrubSecrets } from "./env.ts";
5
6
  import { log as logRow } from "./log.ts";
6
7
  import { PRIVATE_FILE } from "./store.ts";
7
8
 
8
- /** A thin executable that supervises one spec; unlike src/cli.ts it never loads the TUI. */
9
+ /** A thin executable that supervises one spec; it never loads src/cli.ts. */
9
10
  export const SUPERVISE_ENTRY = fileURLToPath(new URL("../entry/supervise-bin.ts", import.meta.url));
10
11
 
11
- /** Starts `supervise-bin.ts <spec>` in its own session and returns at once; it outlives this process. */
12
- export function launchSupervisor(specPath: string): number {
13
- const log = openSync(join(dirname(specPath), "supervisor.log"), "a", PRIVATE_FILE);
14
- const p = Bun.spawn([process.execPath, SUPERVISE_ENTRY, specPath], {
12
+ /** The thin executable that drives one run; like the supervisor's, it never loads src/cli.ts. */
13
+ export const DAEMON_ENTRY = fileURLToPath(new URL("../entry/daemon-bin.ts", import.meta.url));
14
+
15
+ /** The thin executable that runs one milestone check (plan 30b); like the others it never loads src/cli.ts. */
16
+ export const CHECK_ENTRY = fileURLToPath(new URL("../entry/check-bin.ts", import.meta.url));
17
+
18
+ /**
19
+ * Starts `entry args` in its own session, its output appended to `logFile`, and returns at once: the process
20
+ * outlives this one.
21
+ */
22
+ export function launchDetached(
23
+ entry: string,
24
+ args: string[],
25
+ logFile: string,
26
+ env: Record<string, string>,
27
+ ): number {
28
+ const log = openSync(logFile, "a", PRIVATE_FILE);
29
+ const p = Bun.spawn([process.execPath, entry, ...args], {
15
30
  stdin: "ignore",
16
31
  stdout: log,
17
32
  stderr: log,
18
33
  detached: true,
19
- env: scrubSecrets(process.env),
34
+ env,
20
35
  });
21
36
  closeSync(log);
22
37
  p.unref();
23
- logRow("info", "launch", { spec: specPath, pid: p.pid });
24
38
  return p.pid;
25
39
  }
40
+
41
+ /** Starts `supervise-bin.ts <spec>` in its own session and returns at once; it outlives this process. */
42
+ export function launchSupervisor(specPath: string): number {
43
+ const pid = launchDetached(
44
+ SUPERVISE_ENTRY,
45
+ [specPath],
46
+ join(dirname(specPath), "supervisor.log"),
47
+ scrubSecrets(process.env),
48
+ );
49
+ logRow("info", "launch", { spec: specPath, pid });
50
+ return pid;
51
+ }
52
+
53
+ /**
54
+ * The daemon's env (spec 2.0, plan 30 ruling 4): the launching process's own, so Jev's key reaches its routing,
55
+ * without the session identity of whoever launched it, and never a role's scope: it is nobody's role.
56
+ */
57
+ export function daemonEnv(base: Record<string, string | undefined> = process.env): Record<string, string> {
58
+ const env = scrubSecrets(base);
59
+ if (base.TYPESAFE_API_KEY !== undefined) env.TYPESAFE_API_KEY = base.TYPESAFE_API_KEY;
60
+ delete env[ROLE_ENV];
61
+ return env;
62
+ }
63
+
64
+ /** Starts `daemon-bin.ts <run id>` for a run; its log is `<run dir>/daemon.log`. */
65
+ export function launchDaemon(runId: string, runDir: string): number {
66
+ const pid = launchDetached(DAEMON_ENTRY, [runId], join(runDir, "daemon.log"), daemonEnv());
67
+ logRow("info", "launch", { daemon: runId, pid });
68
+ return pid;
69
+ }
70
+
71
+ /** Starts `check-bin.ts <flight file>` detached, so a tool timeout that kills its caller does not lose the check. */
72
+ export function launchCheckWorker(flight: string): number {
73
+ const pid = launchDetached(
74
+ CHECK_ENTRY,
75
+ [flight],
76
+ join(dirname(flight), "worker.log"),
77
+ scrubSecrets(process.env),
78
+ );
79
+ logRow("info", "launch", { check: flight, pid });
80
+ return pid;
81
+ }
@@ -0,0 +1,88 @@
1
+ import { existsSync, readdirSync, readFileSync, rmSync } from "node:fs";
2
+ import { join } from "node:path";
3
+ import { locksDir } from "./paths.ts";
4
+ import { isAlive, processStartTime } from "./proc.ts";
5
+ import { ensurePrivateDir, writeJsonAtomic } from "./store.ts";
6
+
7
+ // Plan 23: a role's long gate runs behind `catherd lock`. While such a lock child is alive and writing, the
8
+ // role's wall timeout counts from its last output, not from the role's start. The lock writes here, in the
9
+ // locks dir every role may write; the supervisor reads it.
10
+
11
+ /** The env var the supervisor's spec carries, so a `catherd lock` inside the role knows its dispatch. */
12
+ export const DISPATCH_ID_ENV = "CATHERD_DISPATCH_ID";
13
+
14
+ /** A lock child reports at most this often, so a chatty command does not write a file per line. */
15
+ export const ACTIVITY_EVERY_MS = 1_000;
16
+
17
+ const SAFE_ID = /^[A-Za-z0-9][\w.-]{0,127}$/;
18
+
19
+ /** `<locks>/activity/<dispatch id>`: one file per live `catherd lock` of that dispatch. */
20
+ export const activityDir = (dispatchId: string): string => join(locksDir(), "activity", dispatchId);
21
+
22
+ interface Activity {
23
+ schema: 1;
24
+ pid: number;
25
+ startTime: string | null;
26
+ /** the last time the lock's command wrote output (its start, before the first line) */
27
+ at: string;
28
+ }
29
+
30
+ /**
31
+ * A reporter for this `catherd lock` process, or null outside a dispatch (no id, or one that is not a plain
32
+ * id: it names a path). `tick` records output, at most once per ACTIVITY_EVERY_MS; `done` removes the file.
33
+ */
34
+ export function activityReporter(
35
+ dispatchId: string | undefined,
36
+ now: () => number = Date.now,
37
+ everyMs = ACTIVITY_EVERY_MS,
38
+ ): { tick: () => void; done: () => void } | null {
39
+ if (!dispatchId || !SAFE_ID.test(dispatchId) || dispatchId.includes("..")) return null;
40
+ const dir = activityDir(dispatchId);
41
+ const file = join(dir, `${process.pid}.json`);
42
+ const startTime = processStartTime(process.pid);
43
+ let last = 0;
44
+ const write = (t: number): void => {
45
+ try {
46
+ ensurePrivateDir(dir);
47
+ writeJsonAtomic(file, {
48
+ schema: 1,
49
+ pid: process.pid,
50
+ startTime,
51
+ at: new Date(t).toISOString(),
52
+ } satisfies Activity);
53
+ last = t;
54
+ } catch {
55
+ // advisory: a lock that cannot report still runs its command
56
+ }
57
+ };
58
+ write(now());
59
+ return {
60
+ tick: () => {
61
+ const t = now();
62
+ if (t - last >= everyMs) write(t);
63
+ },
64
+ done: () => rmSync(file, { force: true }),
65
+ };
66
+ }
67
+
68
+ /**
69
+ * When a live `catherd lock` of the dispatch last wrote output, in ms; null when none is alive. A file whose
70
+ * process is gone is left alone (its lock may still be finishing its own cleanup) and never counts.
71
+ */
72
+ export function lastLockOutput(dispatchId: string): number | null {
73
+ const dir = activityDir(dispatchId);
74
+ if (!existsSync(dir)) return null;
75
+ let latest: number | null = null;
76
+ for (const name of readdirSync(dir)) {
77
+ if (!name.endsWith(".json")) continue;
78
+ try {
79
+ const a = JSON.parse(readFileSync(join(dir, name), "utf8")) as Activity;
80
+ if (a.schema !== 1 || !isAlive(a.pid, a.startTime)) continue;
81
+ const at = Date.parse(a.at);
82
+ if (!Number.isNaN(at)) latest = Math.max(latest ?? at, at);
83
+ } catch {
84
+ // a file being written, or damaged: skipped this poll
85
+ }
86
+ }
87
+ return latest;
88
+ }
@@ -0,0 +1,48 @@
1
+ import { scrubSecrets } from "./env.ts";
2
+
3
+ // Plan 23: what a login shell of the user's sees (DOCKER_HOST for OrbStack or rootless Docker, a proxy, a
4
+ // toolchain's PATH), which a server started by a desktop app or a plugin launcher may not inherit.
5
+ // `$SHELL -lc env` is run once per process and kept.
6
+
7
+ /** How long the login shell may take; one that hangs (a prompt in a profile) counts as an empty env. */
8
+ export const loginShell = { timeoutMs: 5_000 };
9
+
10
+ let captured: Record<string, string> | null = null;
11
+
12
+ /** Parses `env -0` output, or plain `env` lines from a shell whose env has no -0. */
13
+ export function parseEnv(out: string): Record<string, string> {
14
+ const env: Record<string, string> = {};
15
+ const rows = out.includes("\0") ? out.split("\0") : out.split("\n");
16
+ for (const row of rows) {
17
+ const eq = row.indexOf("=");
18
+ if (eq > 0) env[row.slice(0, eq)] = row.slice(eq + 1);
19
+ }
20
+ return env;
21
+ }
22
+
23
+ function capture(): Record<string, string> {
24
+ const shell = process.env.SHELL || "/bin/sh";
25
+ try {
26
+ const r = Bun.spawnSync([shell, "-lc", "env -0 2>/dev/null || env"], {
27
+ env: scrubSecrets(process.env),
28
+ stdin: "ignore",
29
+ stdout: "pipe",
30
+ stderr: "ignore",
31
+ timeout: loginShell.timeoutMs,
32
+ });
33
+ return r.exitCode === 0 ? scrubSecrets(parseEnv(r.stdout.toString())) : {};
34
+ } catch {
35
+ return {};
36
+ }
37
+ }
38
+
39
+ /** The user's login environment, without catherd's secrets; empty when the shell cannot say. Captured once. */
40
+ export function loginEnv(): Record<string, string> {
41
+ captured ??= capture();
42
+ return captured;
43
+ }
44
+
45
+ /** Forgets the captured env (tests, which change SHELL). */
46
+ export function resetLoginEnv(): void {
47
+ captured = null;
48
+ }
@@ -0,0 +1,45 @@
1
+ import { readFileSync } from "node:fs";
2
+ import { freemem, totalmem } from "node:os";
3
+
4
+ const MB = 2 ** 20;
5
+
6
+ const sysctlLevel = (): string | null => {
7
+ try {
8
+ const r = Bun.spawnSync(["sysctl", "-n", "kern.memorystatus_level"]);
9
+ return r.exitCode === 0 ? r.stdout.toString() : null;
10
+ } catch {
11
+ return null;
12
+ }
13
+ };
14
+
15
+ const procMeminfo = (): string | null => {
16
+ try {
17
+ return readFileSync("/proc/meminfo", "utf8");
18
+ } catch {
19
+ return null;
20
+ }
21
+ };
22
+
23
+ /**
24
+ * The memory the machine could give a new process, in MB. macOS reports almost nothing through `os.freemem()`
25
+ * (it counts cache as used), so it is the kernel's free percentage of the total; Linux has `MemAvailable`;
26
+ * anything unreadable falls back to `os.freemem()`. Never throws.
27
+ */
28
+ export function availableMemoryMb(
29
+ o: {
30
+ platform?: NodeJS.Platform;
31
+ sysctl?: () => string | null;
32
+ meminfo?: () => string | null;
33
+ } = {},
34
+ ): number {
35
+ const platform = o.platform ?? process.platform;
36
+ if (platform === "darwin") {
37
+ const level = Number.parseInt((o.sysctl ?? sysctlLevel)() ?? "", 10);
38
+ if (Number.isInteger(level) && level >= 0 && level <= 100)
39
+ return Math.floor(((level / 100) * totalmem()) / MB);
40
+ } else if (platform === "linux") {
41
+ const kb = /^MemAvailable:\s+(\d+) kB$/m.exec((o.meminfo ?? procMeminfo)() ?? "")?.[1];
42
+ if (kb !== undefined) return Math.floor(Number(kb) / 1024);
43
+ }
44
+ return Math.floor(freemem() / MB);
45
+ }
@@ -0,0 +1,40 @@
1
+ import type { NotifyMessage } from "../domain/notify.ts";
2
+ import { log } from "./log.ts";
3
+
4
+ /** A header value as ntfy reads it: plain ASCII as it is, anything else as an RFC 2047 encoded word. */
5
+ const headerValue = (s: string): string =>
6
+ /^[\x20-\x7e]*$/.test(s) ? s : `=?UTF-8?B?${Buffer.from(s).toString("base64")}?=`;
7
+
8
+ /**
9
+ * Posts one message to an ntfy topic. True once the server accepted it; any error, a non-2xx answer or a
10
+ * server slower than `timeoutMs` is false, never a throw, and the log never carries the topic (the url is a
11
+ * secret of the user's).
12
+ */
13
+ export async function postNotify(
14
+ url: string,
15
+ m: NotifyMessage,
16
+ o: { fetch?: typeof fetch; timeoutMs?: number } = {},
17
+ ): Promise<boolean> {
18
+ const timeoutMs = o.timeoutMs ?? 10_000;
19
+ let timer: ReturnType<typeof setTimeout> | undefined;
20
+ try {
21
+ const sent = (o.fetch ?? fetch)(url, {
22
+ method: "POST",
23
+ body: m.message,
24
+ headers: { Title: headerValue(m.title), Tags: m.tags.join(",") },
25
+ signal: AbortSignal.timeout(timeoutMs),
26
+ });
27
+ // a fetch that ignores its signal still gives up
28
+ const gaveUp = new Promise<never>((_, reject) => {
29
+ timer = setTimeout(() => reject(new Error(`no answer in ${timeoutMs} ms`)), timeoutMs);
30
+ });
31
+ const res = await Promise.race([sent, gaveUp]);
32
+ if (!res.ok) log("warn", "notify", { ntfy: res.status });
33
+ return res.ok;
34
+ } catch (e) {
35
+ log("warn", "notify", { ntfy: e instanceof Error ? e.name : "error" });
36
+ return false;
37
+ } finally {
38
+ clearTimeout(timer);
39
+ }
40
+ }
@@ -0,0 +1,42 @@
1
+ import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
2
+ import { tmpdir } from "node:os";
3
+ import { join } from "node:path";
4
+
5
+ /**
6
+ * A page that sends the browser on to `url`, so the opener's argv (readable by every local user, and kept in
7
+ * /proc/<pid>/cmdline) carries a path instead of the token. The dir is 0700 and the file 0600; `remove` deletes both.
8
+ */
9
+ export function writeRedirectPage(url: string): {
10
+ path: string;
11
+ remove(): void;
12
+ } {
13
+ const dir = mkdtempSync(join(tmpdir(), "catherd-panel-"));
14
+ const path = join(dir, "sign-in.html");
15
+ writeFileSync(
16
+ path,
17
+ `<!doctype html><meta charset="utf-8"><title>catherd panel</title><script>location.replace(${JSON.stringify(url)})</script>\n`,
18
+ { mode: 0o600 },
19
+ );
20
+ return { path, remove: () => rmSync(dir, { recursive: true, force: true }) };
21
+ }
22
+
23
+ /** The command that opens `url` in the user's browser on `platform`. */
24
+ export function openerFor(platform: NodeJS.Platform, url: string): string[] {
25
+ if (platform === "darwin") return ["open", url];
26
+ if (platform === "win32") return ["cmd", "/c", "start", "", url];
27
+ return ["xdg-open", url];
28
+ }
29
+
30
+ /** Opens `url` in the default browser, detached; a machine with no opener simply opens nothing. */
31
+ export function openUrl(url: string): void {
32
+ try {
33
+ Bun.spawn(openerFor(process.platform, url), {
34
+ stdin: "ignore",
35
+ stdout: "ignore",
36
+ stderr: "ignore",
37
+ detached: true,
38
+ }).unref();
39
+ } catch {
40
+ // no opener on PATH: the printed link still works
41
+ }
42
+ }
@@ -34,9 +34,44 @@ export function repoKey(toplevel: string): string {
34
34
  }
35
35
 
36
36
  export const repoDir = (toplevel: string): string => join(dataDir(), "repos", repoKey(toplevel));
37
+
38
+ /**
39
+ * One repository however it is cloned or checked out (spec 1.5 "Knowledge keyed by git origin"):
40
+ * `git@github.com:a/b.git`, `https://user@github.com/a/b` and `ssh://git@github.com/a/b.git/` are all
41
+ * `github.com/a/b`. The host is lower-cased; the path keeps its case.
42
+ */
43
+ export function normalizeOrigin(url: string): string {
44
+ let s = url
45
+ .trim()
46
+ .replace(/\/+$/, "")
47
+ .replace(/\.git$/, "");
48
+ const scheme = /^([a-z][a-z0-9+.-]*):\/\//i.exec(s)?.[1]?.toLowerCase() ?? null;
49
+ const scp = /^(?:[^@/]+@)?([^:/]+):(?!\/)(.+)$/.exec(s);
50
+ if (scp && !scheme) s = `${scp[1]}/${scp[2]}`;
51
+ else s = s.replace(/^[a-z][a-z0-9+.-]*:\/\//i, "").replace(/^[^@/]+@/, "");
52
+ const [host = "", ...path] = s.split("/");
53
+ // only the scheme's own default port goes: two servers on one host on other ports are two repos (PR #50)
54
+ const port = /:(\d+)$/.exec(host)?.[1];
55
+ const bare = port !== undefined && scheme !== null && DEFAULT_PORTS[scheme] === port;
56
+ return [(bare ? host.slice(0, -port.length - 1) : host).toLowerCase(), ...path].join("/");
57
+ }
58
+
59
+ /** Each git URL scheme's default port, which a URL may name or leave out for the same server. */
60
+ const DEFAULT_PORTS: Record<string, string> = {
61
+ ssh: "22",
62
+ "git+ssh": "22",
63
+ "ssh+git": "22",
64
+ https: "443",
65
+ http: "80",
66
+ git: "9418",
67
+ };
68
+
69
+ /** Where what runs of one repository learned lives, keyed by its origin, whatever worktree it runs in. */
70
+ export const originDir = (url: string): string =>
71
+ join(dataDir(), "repos", `origin-${repoKey(normalizeOrigin(url))}`);
37
72
  export const runsDir = (toplevel: string): string => join(repoDir(toplevel), "runs");
38
73
  export const logsDir = (): string => join(dataDir(), "logs");
39
74
  export const discoveryDir = (): string => join(dataDir(), "discovery");
40
75
  export const locksDir = (): string => join(dataDir(), "locks");
41
- /** Spec 1.2 §3.3: each source's last good answer, the sync state and lock, and what the sync derived. */
42
- export const sourcesDir = (): string => join(dataDir(), "sources");
76
+ /** The Slopalytics index: the last good answer of the proxy, one intelligence per model and effort. */
77
+ export const indexPath = (): string => join(dataDir(), "slopalytics.json");
@@ -0,0 +1,69 @@
1
+ import { fileURLToPath } from "node:url";
2
+ import { type RoleMcpContext, ROLE_MCP_SERVER, roleMcpTools } from "../domain/role-tools.ts";
3
+ import { scrubSecrets } from "./env.ts";
4
+ import { configDir, dataDir } from "./paths.ts";
5
+
6
+ export const ROLE_MCP_ENTRY = fileURLToPath(new URL("../entry/mcp/role-bin.ts", import.meta.url));
7
+
8
+ /** How long Codex waits for the role server to start (spec 1.5 plan 21, #42 finding 8). */
9
+ export const ROLE_MCP_STARTUP_SEC = 30;
10
+
11
+ export type RoleServerStart = { ok: true; ms: number } | { ok: false; error: string };
12
+
13
+ /**
14
+ * Doctor's probe: a cold start of the role server's entry, which loads the server's whole module graph and
15
+ * exits (`--probe`), timed against the startup timeout. Never a model turn.
16
+ */
17
+ export async function probeRoleServer(timeoutMs = ROLE_MCP_STARTUP_SEC * 1000): Promise<RoleServerStart> {
18
+ const started = performance.now();
19
+ const p = Bun.spawn([process.execPath, ROLE_MCP_ENTRY, "--probe"], {
20
+ stdin: "ignore",
21
+ stdout: "pipe",
22
+ stderr: "pipe",
23
+ env: { ...scrubSecrets(process.env), CATHERD_NO_SYNC: "1" },
24
+ });
25
+ const timer = setTimeout(() => p.kill("SIGKILL"), timeoutMs);
26
+ try {
27
+ const code = await p.exited;
28
+ const ms = performance.now() - started;
29
+ const out = await new Response(p.stdout).text();
30
+ if (code === 0 && out.trim() === "ready") return { ok: true, ms };
31
+ const err = (await new Response(p.stderr).text()).trim().split("\n").at(-1);
32
+ return {
33
+ ok: false,
34
+ error: p.signalCode === "SIGKILL" ? `no answer in ${timeoutMs} ms` : err || `exit ${code}`,
35
+ };
36
+ } finally {
37
+ clearTimeout(timer);
38
+ }
39
+ }
40
+
41
+ /** Launch the same installed package as the dispatching process, without resolving another release. */
42
+ export function roleMcpConfig(context: RoleMcpContext): {
43
+ command: string;
44
+ args: string[];
45
+ env: Record<string, string>;
46
+ } {
47
+ return {
48
+ command: process.execPath,
49
+ args: [ROLE_MCP_ENTRY, context.role, context.run],
50
+ env: { CATHERD_CONFIG_DIR: configDir(), CATHERD_DATA_DIR: dataDir() },
51
+ };
52
+ }
53
+
54
+ export function codexRoleMcpArgs(context: RoleMcpContext): string[] {
55
+ const config = roleMcpConfig(context);
56
+ const prefix = `mcp_servers.${ROLE_MCP_SERVER}`;
57
+ const settings = [
58
+ `${prefix}.command=${JSON.stringify(config.command)}`,
59
+ `${prefix}.args=${JSON.stringify(config.args)}`,
60
+ ...Object.entries(config.env).map(([key, value]) => `${prefix}.env.${key}=${JSON.stringify(value)}`),
61
+ `${prefix}.enabled=true`,
62
+ // required: a role without its run tools cannot work; doctor's probe shows it starts well inside the timeout
63
+ `${prefix}.required=true`,
64
+ `${prefix}.startup_timeout_sec=${ROLE_MCP_STARTUP_SEC}`,
65
+ `${prefix}.enabled_tools=${JSON.stringify(roleMcpTools(context.role))}`,
66
+ ...roleMcpTools(context.role).map((tool) => `${prefix}.tools.${tool}.approval_mode="approve"`),
67
+ ];
68
+ return settings.flatMap((setting) => ["-c", setting]);
69
+ }
@@ -0,0 +1,30 @@
1
+ import { existsSync } from "node:fs";
2
+ import { parseProxy, type IndexFile, IndexFileSchema, type SlopRecord } from "../domain/slopalytics.ts";
3
+ import { readVersioned, writeJsonAtomic } from "./store.ts";
4
+
5
+ export const SLOPALYTICS_URL = "https://slopalyticsproxy.vercel.app";
6
+
7
+ /** The proxy's answer, parsed. Throws on a transport error, a non-2xx, or an answer with no indexed pair. */
8
+ export async function fetchProxy(
9
+ o: { fetch?: typeof fetch; timeoutMs?: number } = {},
10
+ ): Promise<{ fetchedAt: string | null; models: SlopRecord[] }> {
11
+ const res = await (o.fetch ?? fetch)(SLOPALYTICS_URL, {
12
+ signal: AbortSignal.timeout(o.timeoutMs ?? 15_000),
13
+ });
14
+ if (!res.ok) throw new Error(`http ${res.status}`);
15
+ const parsed = parseProxy(await res.json());
16
+ if (parsed.models.length === 0) throw new Error("no indexed pairs");
17
+ return parsed;
18
+ }
19
+
20
+ /** The cached index, or null when there is none or it cannot be read (the next refresh rewrites it). */
21
+ export function readIndexFile(file: string): IndexFile | null {
22
+ if (!existsSync(file)) return null;
23
+ try {
24
+ return readVersioned(file, IndexFileSchema, 1);
25
+ } catch {
26
+ return null;
27
+ }
28
+ }
29
+
30
+ export const writeIndexFile = (file: string, f: IndexFile): void => writeJsonAtomic(file, f, { mode: 0o600 });
@@ -150,9 +150,14 @@ function endsMidLine(file: string): boolean {
150
150
  * tail left by an earlier crash is ended first, in the same write, so the new row stays whole.
151
151
  */
152
152
  export function appendJsonl(file: string, value: unknown): void {
153
+ appendJsonlBatch(file, [value]);
154
+ }
155
+
156
+ /** Several rows in one write, so a crash leaves all of them or at worst a torn last one. */
157
+ export function appendJsonlBatch(file: string, values: unknown[]): void {
153
158
  ensurePrivateDir(dirname(file));
154
159
  const prefix = endsMidLine(file) ? "\n" : "";
155
- appendPrivate(file, `${prefix}${JSON.stringify(value)}\n`);
160
+ appendPrivate(file, prefix + values.map((v) => `${JSON.stringify(v)}\n`).join(""));
156
161
  }
157
162
 
158
163
  const madePrivate = new Set<string>();