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,433 @@
1
+ import { existsSync, readFileSync, unlinkSync, writeFileSync } from "node:fs";
2
+ import { join } from "node:path";
3
+ import { z } from "zod";
4
+ import { CatherdError } from "../domain/errors.ts";
5
+ import { checkFailLines, newFails } from "../domain/fail-lines.ts";
6
+ import { git } from "../infra/git.ts";
7
+ import { heavySlots, LOCK_HELD_ENV, withHeavySlot, withRoleLock } from "../infra/heavy-lock.ts";
8
+ import { launchCheckWorker } from "../infra/launch.ts";
9
+ import { repoDir } from "../infra/paths.ts";
10
+ import { isAlive, processStartTime } from "../infra/proc.ts";
11
+ import {
12
+ appendJsonl,
13
+ ensureJsonlHeader,
14
+ ensurePrivateDir,
15
+ readJsonl,
16
+ writeJsonAtomic,
17
+ } from "../infra/store.ts";
18
+ import { checkEnvFor } from "./preflight.ts";
19
+ import { activeName, readProfileDoc } from "./profile-store.ts";
20
+ import { findRun, type Run } from "./run-store.ts";
21
+ import { treeOf } from "./tree-path.ts";
22
+
23
+ /** One run of a milestone's full check on one commit (spec 2.0 decision 18), or of the base's own (a baseline). */
24
+ export const CheckRecordSchema = z.looseObject({
25
+ at: z.string(),
26
+ run: z.string(),
27
+ milestone: z.string(),
28
+ command: z.string(),
29
+ /** the full sha the check ran on */
30
+ head: z.string(),
31
+ exit: z.number(),
32
+ durationMs: z.number(),
33
+ fails: z.array(z.string()),
34
+ log: z.string(),
35
+ baseline: z.literal(true).optional(),
36
+ /** the first run's exit, when a failure with new lines was rerun once */
37
+ rerunOf: z.number().optional(),
38
+ /** "clean" | "found" | "skipped: <why>"; non-baseline records only */
39
+ secrets: z.string().optional(),
40
+ });
41
+ export type CheckRecord = z.infer<typeof CheckRecordSchema>;
42
+
43
+ /** `<data>/repos/<repo key>/checks.jsonl`, beside gates.jsonl. */
44
+ export const checksFile = (toplevel: string): string => join(repoDir(toplevel), "checks.jsonl");
45
+ export const checkLogDir = (toplevel: string): string => join(repoDir(toplevel), "checks");
46
+
47
+ export function readChecks(toplevel: string): CheckRecord[] {
48
+ return readJsonl<unknown>(checksFile(toplevel)).rows.flatMap((row) => {
49
+ const r = CheckRecordSchema.safeParse(row);
50
+ return r.success ? [r.data] : [];
51
+ });
52
+ }
53
+
54
+ export function appendCheckRecord(toplevel: string, rec: CheckRecord): void {
55
+ const file = checksFile(toplevel);
56
+ ensureJsonlHeader(file, "checks");
57
+ appendJsonl(file, rec);
58
+ }
59
+
60
+ /**
61
+ * The commit the baseline and the secret scan start from: the one place the base is chosen. A run in the repo scans
62
+ * from where it started; a base run's milestone from where its branch left origin's base (plan 35), null when git
63
+ * cannot say.
64
+ */
65
+ export async function checkBase(run: Run, m?: string): Promise<string | null> {
66
+ if (!run.meta.base || m === undefined) return run.meta.startHead ?? null;
67
+ const r = await git(treeOf(run, m), ["merge-base", `origin/${run.meta.base}`, "HEAD"]);
68
+ return r.kind === "ok" ? r.out.trim() || null : null;
69
+ }
70
+
71
+ /** The baseline record of `command` on `base`; another commit's is none. Without a base, the newest. */
72
+ export function baselineFor(toplevel: string, command: string, base: string | null): CheckRecord | null {
73
+ const all = readChecks(toplevel).filter((r) => r.baseline && r.command === command);
74
+ return (base ? all.filter((r) => r.head === base) : all).at(-1) ?? null;
75
+ }
76
+
77
+ /** The scanner a test replaces. */
78
+ export const checkTools = { gitleaks: (): string | null => Bun.which("gitleaks") };
79
+
80
+ /** `gitleaks` over `base..HEAD` in `cwd`: "found" blocks, a missing or failing scanner is an audit note. */
81
+ export async function secretScan(cwd: string, base: string | null): Promise<string> {
82
+ if (!base) return "skipped: no base commit to scan from";
83
+ const bin = checkTools.gitleaks();
84
+ if (!bin) return "skipped: gitleaks is not installed";
85
+ const p = Bun.spawn(
86
+ [bin, "git", "--no-banner", "--redact", "--exit-code", "1", `--log-opts=${base}..HEAD`],
87
+ { cwd, stdin: "ignore", stdout: "ignore", stderr: "ignore" },
88
+ );
89
+ const code = await p.exited;
90
+ return code === 0 ? "clean" : code === 1 ? "found" : `skipped: gitleaks exited ${code}`;
91
+ }
92
+
93
+ export interface CheckJob {
94
+ run: string;
95
+ repo: string;
96
+ cwd: string;
97
+ milestone: string;
98
+ command: string;
99
+ head: string;
100
+ base: string | null;
101
+ baseline: boolean;
102
+ }
103
+
104
+ const invalid = (message: string, fix: string) => new CatherdError("E_INPUT_INVALID", message, { fix });
105
+
106
+ /** One logged run of the command: its merged output goes to the log, its exit code is kept. */
107
+ async function runLogged(job: CheckJob, log: string, tmp: string | null): Promise<number> {
108
+ const env = await checkEnvFor(job.repo);
109
+ const p = Bun.spawn(["sh", "-c", 'exec > "$1" 2>&1; eval "$2"', "sh", log, job.command], {
110
+ cwd: job.cwd,
111
+ env: tmp ? { ...env, TMPDIR: tmp, GOTMPDIR: tmp } : env,
112
+ stdin: "ignore",
113
+ stdout: "ignore",
114
+ stderr: "ignore",
115
+ detached: true,
116
+ });
117
+ return (await p.exited) ?? 1;
118
+ }
119
+
120
+ /**
121
+ * A record names the commit it ran on: the checkout must still be at `job.head`, with no tracked edits (a
122
+ * baseline's worktree is the base's own), or what ran is not what the record would claim.
123
+ */
124
+ async function assertCheckout(job: CheckJob, when: string): Promise<void> {
125
+ const moved = (why: string) =>
126
+ invalid(
127
+ `the checkout moved ${when} the check (${why}); nothing is recorded for ${job.head.slice(0, 12)}`,
128
+ "commit the milestone, then call gate_run again",
129
+ );
130
+ const head = await git(job.cwd, ["rev-parse", "HEAD"]);
131
+ if (head.kind !== "ok") throw moved("HEAD is unreadable");
132
+ if (head.out.trim() !== job.head) throw moved(`HEAD is ${head.out.trim().slice(0, 12)}`);
133
+ if (job.baseline) return;
134
+ const dirty = await git(job.cwd, ["status", "--porcelain", "--untracked-files=no"]);
135
+ if (dirty.kind !== "ok" || dirty.out.trim()) throw moved("tracked files changed");
136
+ }
137
+
138
+ /**
139
+ * Runs a milestone's check under the verifier lock and a heavy slot, records its exit code and failing lines
140
+ * against `head`, reruns a failure with lines the base lacks once, and scans the run's commits for secrets.
141
+ */
142
+ export async function execMilestoneCheck(job: CheckJob): Promise<CheckRecord> {
143
+ const dir = checkLogDir(job.repo);
144
+ ensurePrivateDir(dir);
145
+ const one = async () => {
146
+ const log = join(
147
+ dir,
148
+ `${job.milestone}-${job.head.slice(0, 12)}${job.baseline ? "-base" : ""}-${Date.now()}.log`,
149
+ );
150
+ const start = Date.now();
151
+ // the locks may have been waited for while the checkout moved on, and the command may move it
152
+ const guarded = async (tmp: string | null): Promise<number> => {
153
+ await assertCheckout(job, "before");
154
+ const code = await runLogged(job, log, tmp);
155
+ await assertCheckout(job, "during");
156
+ return code;
157
+ };
158
+ let exit: number;
159
+ if (process.env[LOCK_HELD_ENV] === "1") exit = await guarded(null);
160
+ else {
161
+ let setting: number | "cpus/2" = "cpus/2";
162
+ try {
163
+ setting = readProfileDoc(activeName(job.repo)).lock?.heavy ?? "cpus/2";
164
+ } catch {
165
+ // a profile that cannot be read leaves the default
166
+ }
167
+ exit = await withRoleLock("verifier", `run:${job.run}`, () =>
168
+ withHeavySlot(heavySlots(setting), (_slot, tmp) => guarded(tmp), {
169
+ tmpBase: process.env.TMPDIR,
170
+ }),
171
+ );
172
+ }
173
+ const out = readFileSync(log, "utf8");
174
+ return { exit, durationMs: Date.now() - start, fails: checkFailLines(out), log };
175
+ };
176
+ let r = await one();
177
+ let rerunOf: number | undefined;
178
+ if (r.exit !== 0 && !job.baseline) {
179
+ // a failure with lines the base lacks gets one rerun: load flakes are common on a shared host
180
+ const base = baselineFor(job.repo, job.command, job.base);
181
+ if (!base || newFails(r.fails, base.fails).length > 0) {
182
+ rerunOf = r.exit;
183
+ r = await one();
184
+ }
185
+ }
186
+ const rec: CheckRecord = {
187
+ at: new Date().toISOString(),
188
+ run: job.run,
189
+ milestone: job.milestone,
190
+ command: job.command,
191
+ head: job.head,
192
+ ...r,
193
+ ...(job.baseline ? { baseline: true as const } : { secrets: await secretScan(job.cwd, job.base) }),
194
+ ...(rerunOf !== undefined ? { rerunOf } : {}),
195
+ };
196
+ appendCheckRecord(job.repo, rec);
197
+ return rec;
198
+ }
199
+
200
+ /**
201
+ * The machine half of the land gate for milestone `m` on `head`: every command the run checked on it exits 0, or
202
+ * fails only in lines the base fails in too. `audit` lists what passed only because of the base or a rerun.
203
+ */
204
+ export async function machineGate(
205
+ run: Run,
206
+ m: string,
207
+ head: string,
208
+ ): Promise<{ missing: string[]; audit: string[] }> {
209
+ const missing: string[] = [];
210
+ const audit: string[] = [];
211
+ const latest = new Map<string, CheckRecord>();
212
+ for (const r of readChecks(run.meta.repo))
213
+ if (r.run === run.id && r.milestone === m && r.head === head && !r.baseline) latest.set(r.command, r);
214
+ if (latest.size === 0) {
215
+ missing.push(
216
+ `no machine check of ${m} on ${head.slice(0, 12)}: the verifier runs its full check with gate_run(run, "${m}", <command>) on the committed HEAD`,
217
+ );
218
+ return { missing, audit };
219
+ }
220
+ const start = await checkBase(run, m);
221
+ for (const rec of latest.values()) {
222
+ if (rec.secrets === "found")
223
+ missing.push(
224
+ `gitleaks found secrets in ${(start ?? "").slice(0, 12)}..${head.slice(0, 12)}; see ${rec.log}`,
225
+ );
226
+ if (rec.secrets?.startsWith("skipped")) audit.push(`secret scan ${rec.secrets}`);
227
+ if (rec.rerunOf !== undefined)
228
+ audit.push(`\`${rec.command}\` exited ${rec.rerunOf} once and was rerun (exit ${rec.exit})`);
229
+ if (rec.exit === 0) continue;
230
+ const base = baselineFor(run.meta.repo, rec.command, start);
231
+ const fresh = newFails(rec.fails, base?.fails ?? null);
232
+ if (!base || base.exit === 0)
233
+ missing.push(
234
+ `\`${rec.command}\` exits ${rec.exit} on ${head.slice(0, 12)}${fresh.length ? `: ${fresh.slice(0, 5).join(" | ")}` : ""}`,
235
+ );
236
+ else if (fresh.length)
237
+ missing.push(`failing lines the base does not have: ${fresh.slice(0, 8).join(" | ")}`);
238
+ // a failure catherd cannot read is never matched against the base: it could be anything
239
+ else if (rec.fails.length === 0)
240
+ missing.push(
241
+ `\`${rec.command}\` exits ${rec.exit} with no failing line catherd can compare; see ${rec.log}`,
242
+ );
243
+ else
244
+ audit.push(
245
+ `\`${rec.command}\` exits ${rec.exit} here and ${base.exit} on the base ${base.head.slice(0, 12)}; every failing line is pre-existing (${rec.fails.length})`,
246
+ );
247
+ }
248
+ return { missing, audit };
249
+ }
250
+
251
+ /** What a caller waits for, and how often it looks; a worker that has not written its pid yet gets `graceMs`. */
252
+ export const checkLimits = { waitMs: 600_000, pollMs: 1_000, graceMs: 30_000 };
253
+
254
+ export type GateRunResult =
255
+ | { running: true; log: string | null; hint: string }
256
+ | {
257
+ running: false;
258
+ cached: boolean;
259
+ record: CheckRecord;
260
+ missing: string[];
261
+ audit: string[];
262
+ hints?: string[];
263
+ };
264
+
265
+ interface Flight {
266
+ schema: 1;
267
+ pid: number;
268
+ startTime: string | null;
269
+ at: string;
270
+ job: CheckJob;
271
+ }
272
+
273
+ const flightOf = (job: CheckJob): string =>
274
+ join(
275
+ checkLogDir(job.repo),
276
+ `inflight-${new Bun.CryptoHasher("sha1")
277
+ .update(JSON.stringify([job.run, job.milestone, job.command, job.head, job.baseline]))
278
+ .digest("hex")}.json`,
279
+ );
280
+
281
+ function readFlight(file: string): Flight | null {
282
+ try {
283
+ const f = JSON.parse(readFileSync(file, "utf8")) as Flight;
284
+ return typeof f?.pid === "number" && typeof f.at === "string" ? f : null;
285
+ } catch {
286
+ return null;
287
+ }
288
+ }
289
+
290
+ /** A flight is live while its worker is, or while a worker that has not written its pid yet is within its grace. */
291
+ const flightLive = (f: Flight): boolean =>
292
+ f.pid > 0 ? isAlive(f.pid, f.startTime) : Date.now() - Date.parse(f.at) < checkLimits.graceMs;
293
+
294
+ /**
295
+ * The verifier's full check of a milestone on the committed HEAD (spec 2.0 decision 18): the same run,
296
+ * milestone, command and HEAD answer the recorded result; else a detached worker runs it. Past `waitMs` the
297
+ * answer is `running`, and the next call waits for that same run.
298
+ */
299
+ export async function runMilestoneCheck(
300
+ i: { run: string; milestone: string; command: string; at?: string; force?: boolean },
301
+ o: { onWait?: () => void } = {},
302
+ ): Promise<GateRunResult> {
303
+ const run = findRun(i.run);
304
+ const repo = run.meta.repo;
305
+ const cwd = i.at ?? treeOf(run, i.milestone);
306
+ const baseline = i.at !== undefined;
307
+ const headOut = await git(cwd, ["rev-parse", "HEAD"]);
308
+ if (headOut.kind !== "ok" || !headOut.out.trim())
309
+ throw invalid(`cannot read HEAD in ${cwd}`, "run it in a git checkout with a commit");
310
+ const head = headOut.out.trim();
311
+ const base = await checkBase(run, i.milestone);
312
+ if (baseline) {
313
+ if (!base || head !== base)
314
+ throw invalid(
315
+ `${cwd} is at ${head.slice(0, 12)}, not at the run's base ${base ? base.slice(0, 12) : "(none)"}`,
316
+ `git worktree add --detach <dir> ${base ?? "<base>"}, then pass it as at`,
317
+ );
318
+ } else {
319
+ const dirty = await git(cwd, ["status", "--porcelain", "--untracked-files=no"]);
320
+ if (dirty.kind !== "ok") throw invalid(`git status failed in ${cwd}`, "check that git works there");
321
+ if (dirty.out.trim())
322
+ throw invalid(
323
+ "tracked files have uncommitted changes, and a check record names a commit",
324
+ "commit the milestone first",
325
+ );
326
+ }
327
+ const mine = (r: CheckRecord): boolean =>
328
+ r.run === i.run &&
329
+ r.milestone === i.milestone &&
330
+ r.command === i.command &&
331
+ r.head === head &&
332
+ Boolean(r.baseline) === baseline;
333
+ const result = async (record: CheckRecord, cached: boolean): Promise<GateRunResult> => {
334
+ const gate = baseline ? { missing: [], audit: [] } : await machineGate(run, i.milestone, head);
335
+ const hints =
336
+ record.exit !== 0 && !baseline && !baselineFor(repo, i.command, base) && base
337
+ ? [
338
+ `the base's own failures are not recorded: git worktree add --detach <dir> ${base.slice(0, 12)}, run gate_run with at: <dir> there, then git worktree remove <dir>`,
339
+ ]
340
+ : [];
341
+ return { running: false, cached, record, ...gate, ...(hints.length ? { hints } : {}) };
342
+ };
343
+ if (!i.force) {
344
+ const prior = readChecks(repo).filter(mine).at(-1);
345
+ if (prior) return result(prior, true);
346
+ }
347
+ const job: CheckJob = {
348
+ run: i.run,
349
+ repo,
350
+ cwd,
351
+ milestone: i.milestone,
352
+ command: i.command,
353
+ head,
354
+ base,
355
+ baseline,
356
+ };
357
+ const file = flightOf(job);
358
+ ensurePrivateDir(checkLogDir(repo));
359
+ let started: string | null = null;
360
+ for (let tries = 0; tries < 3 && started === null; tries++) {
361
+ const at = new Date().toISOString();
362
+ try {
363
+ writeFileSync(file, JSON.stringify({ schema: 1, pid: -1, startTime: null, at, job } satisfies Flight), {
364
+ flag: "wx",
365
+ mode: 0o600,
366
+ });
367
+ } catch (e) {
368
+ if ((e as NodeJS.ErrnoException).code !== "EEXIST") throw e;
369
+ const other = readFlight(file);
370
+ if (other && flightLive(other)) {
371
+ started = other.at;
372
+ break;
373
+ }
374
+ try {
375
+ unlinkSync(file);
376
+ } catch {
377
+ // another caller removed it first
378
+ }
379
+ continue;
380
+ }
381
+ const pid = launchCheckWorker(file);
382
+ // the worker may be done and its flight removed already: never write the file back
383
+ if (existsSync(file))
384
+ writeJsonAtomic(file, { schema: 1, pid, startTime: processStartTime(pid), at, job } satisfies Flight);
385
+ started = at;
386
+ }
387
+ if (started === null)
388
+ throw new CatherdError("E_IO_UNEXPECTED", "the check could not be started", { fix: `remove ${file}` });
389
+ const deadline = Date.now() + checkLimits.waitMs;
390
+ for (;;) {
391
+ const f = existsSync(file) ? readFlight(file) : null;
392
+ if (!f) break;
393
+ if (!flightLive(f)) {
394
+ try {
395
+ unlinkSync(file);
396
+ } catch {
397
+ // already gone
398
+ }
399
+ break;
400
+ }
401
+ if (Date.now() >= deadline)
402
+ return {
403
+ running: true,
404
+ log: checkLogDir(repo),
405
+ hint: "the check is still running; call gate_run again with the same arguments to wait for it",
406
+ };
407
+ o.onWait?.();
408
+ await Bun.sleep(Math.min(checkLimits.pollMs, Math.max(1, deadline - Date.now())));
409
+ }
410
+ const record = readChecks(repo)
411
+ .filter((r) => mine(r) && r.at >= started)
412
+ .at(-1);
413
+ if (!record)
414
+ throw new CatherdError("E_IO_UNEXPECTED", "the check worker stopped without a record", {
415
+ fix: `see worker.log in ${checkLogDir(repo)}`,
416
+ });
417
+ return result(record, false);
418
+ }
419
+
420
+ /** The detached worker: runs the job its flight file holds, records it, and removes the flight. */
421
+ export async function checkWorker(flight: string): Promise<void> {
422
+ const f = readFlight(flight);
423
+ if (!f) throw new Error(`no readable flight file ${flight}`);
424
+ try {
425
+ await execMilestoneCheck(f.job);
426
+ } finally {
427
+ try {
428
+ unlinkSync(flight);
429
+ } catch {
430
+ // the caller removed a stale one
431
+ }
432
+ }
433
+ }
@@ -0,0 +1,170 @@
1
+ import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
2
+ import { join } from "node:path";
3
+ import { milestoneBranch, planSetup } from "../domain/branch.ts";
4
+ import { CatherdError } from "../domain/errors.ts";
5
+ import { availableMemoryMb } from "../infra/memory.ts";
6
+ import { currentBranch, fetchBase, git, gitRun } from "../infra/git.ts";
7
+ import { heavySlots, withHeavySlot } from "../infra/heavy-lock.ts";
8
+ import { ensurePrivateDir } from "../infra/store.ts";
9
+ import type { Deps } from "./ports.ts";
10
+ import { planDocs } from "./plan-input.ts";
11
+ import { checkEnvFor, runCheck } from "./preflight.ts";
12
+ import { runProfile } from "./run-pin.ts";
13
+ import { type Run, runPaths } from "./run-store.ts";
14
+ import { readyFile, treeDir, treeOf } from "./tree-path.ts";
15
+
16
+ /** How long a plan's `Setup:` command may run (tests shorten it: it is the seam for a slow install). */
17
+ export const treeLimits = { setupMs: 15 * 60_000 };
18
+
19
+ /** The machine's available memory in MB; tests replace it. */
20
+ export const memoryReader = { read: (): number => availableMemoryMb() };
21
+
22
+ const PUSH_MS = 120_000;
23
+ const TAIL = 1_500;
24
+
25
+ /** `git -C <tree> push -u origin <branch>`: the one push catherd makes, on the owner's word. Tests replace it. */
26
+ export const branchPusher = {
27
+ push: async (tree: string, branch: string): Promise<void> => {
28
+ const r = await gitRun(tree, ["push", "-u", "origin", branch], PUSH_MS);
29
+ if (r.code !== 0)
30
+ throw new CatherdError(
31
+ "E_TREE",
32
+ `git push of ${branch} failed: ${r.timedOut ? `timed out after ${PUSH_MS / 1000} s` : tail(`${r.out}${r.err}`)}`,
33
+ { fix: "fix the remote or push it yourself, then answer" },
34
+ );
35
+ },
36
+ };
37
+
38
+ const tail = (s: string): string => s.trim().slice(-TAIL);
39
+
40
+ export { treeDir, treeOf };
41
+
42
+ export interface TreeClaim {
43
+ tree: string;
44
+ branch: string;
45
+ base: string;
46
+ hints: string[];
47
+ }
48
+
49
+ const tree_ = (message: string, fix: string) => new CatherdError("E_TREE", message, { fix });
50
+
51
+ const seconds = (ms: number): string =>
52
+ ms >= 60_000 ? `${Math.round(ms / 60_000)} min` : `${Math.ceil(ms / 1000)} s`;
53
+
54
+ /** The first `Setup:` line of plan.md, else of the plan's input copies. */
55
+ function setupOf(run: Run): string | null {
56
+ let own = "";
57
+ try {
58
+ own = readFileSync(runPaths(run.dir).plan, "utf8");
59
+ } catch {
60
+ // no plan.md: the input copies may have the line
61
+ }
62
+ for (const text of [own, ...planDocs(run).map((d) => d.text)]) {
63
+ const cmd = planSetup(text);
64
+ if (cmd) return cmd;
65
+ }
66
+ return null;
67
+ }
68
+
69
+ /** Submodules, then the plan's Setup: command, in a heavy slot with the gate's check env. */
70
+ async function setUp(deps: Deps, run: Run, tree: string): Promise<void> {
71
+ const setup = setupOf(run);
72
+ const submodules = existsSync(join(tree, ".gitmodules"));
73
+ if (!setup && !submodules) return;
74
+ const env = await checkEnvFor(run.meta.repo);
75
+ await withHeavySlot(heavySlots(runProfile(deps, run).heavy), async (_slot, tmp) => {
76
+ if (submodules) {
77
+ const r = await gitRun(tree, ["submodule", "update", "--init", "--recursive"], treeLimits.setupMs);
78
+ if (r.code !== 0)
79
+ throw tree_(
80
+ `git submodule update failed in ${tree}: ${r.timedOut ? "timed out" : tail(`${r.out}${r.err}`)}`,
81
+ "make the submodule host reachable, then answer",
82
+ );
83
+ }
84
+ if (!setup) return;
85
+ const r = await runCheck(tree, setup, treeLimits.setupMs, { ...env, TMPDIR: tmp, GOTMPDIR: tmp });
86
+ if (r.timedOut || r.code !== 0)
87
+ throw tree_(
88
+ `Setup: ${setup} ${r.timedOut ? `timed out after ${seconds(treeLimits.setupMs)}` : `exited ${r.code}`} in ${tree}: ${tail(r.tail.join("\n"))}`,
89
+ "correct the Setup: line in plan.md or the machine, then answer",
90
+ );
91
+ });
92
+ }
93
+
94
+ /**
95
+ * Makes (or finds again) milestone `m`'s worktree: a branch from `origin/<base>` after a fetch, set up once. A claim a
96
+ * crash interrupted reuses the folder and runs Setup: again, since `.ready` was never written. `known` says a
97
+ * `tree.ready` happened for `m` before, so a branch of its name is this run's own.
98
+ */
99
+ export async function claimTree(deps: Deps, run: Run, m: string, o: { known: boolean }): Promise<TreeClaim> {
100
+ const base = run.meta.base;
101
+ if (!base)
102
+ throw new CatherdError("E_INPUT_INVALID", `run ${run.id} has no base branch`, {
103
+ fix: "start the run with run_start base",
104
+ });
105
+ const repo = run.meta.repo;
106
+ const branch = milestoneBranch(run.meta.title, m);
107
+ const tree = treeDir(run, m);
108
+ const hints: string[] = [];
109
+ if (!(await fetchBase(repo, base)))
110
+ hints.push(`fetch of origin/${base} failed: using the copy from the last fetch`);
111
+ await git(repo, ["worktree", "prune"]);
112
+ if (existsSync(tree)) {
113
+ const have = await currentBranch(tree);
114
+ if (have !== branch)
115
+ throw tree_(
116
+ `${tree} has ${have ?? "no branch"} checked out, not ${branch}`,
117
+ "move the folder away, then answer",
118
+ );
119
+ // a `git worktree add` cut off mid-checkout leaves the right HEAD and a part of the files: not complete
120
+ if (!existsSync(readyFile(run, m))) {
121
+ const r = await gitRun(tree, ["reset", "--hard", "-q", "HEAD"]);
122
+ if (r.code !== 0)
123
+ throw tree_(
124
+ `git reset of ${tree} failed: ${tail(`${r.out}${r.err}`)}`,
125
+ "fix the folder, then answer",
126
+ );
127
+ }
128
+ } else {
129
+ ensurePrivateDir(join(run.dir, "trees"));
130
+ const exists = await git(repo, ["rev-parse", "--verify", "--quiet", `refs/heads/${branch}`]);
131
+ if (exists.kind === "ok" && !o.known)
132
+ throw tree_(
133
+ `branch ${branch} exists and is not this run's`,
134
+ `rename it (git branch -m ${branch} ${branch}-old) or delete it, then answer`,
135
+ );
136
+ const args =
137
+ exists.kind === "ok"
138
+ ? ["worktree", "add", tree, branch]
139
+ : ["worktree", "add", "--no-track", "-b", branch, tree, `origin/${base}`];
140
+ const r = await gitRun(repo, args);
141
+ if (r.code !== 0)
142
+ throw tree_(
143
+ `git worktree add failed for ${branch}: ${tail(`${r.out}${r.err}`)}`,
144
+ "fix the repository, then answer",
145
+ );
146
+ }
147
+ if (!existsSync(readyFile(run, m))) {
148
+ await setUp(deps, run, tree);
149
+ mkdirSync(join(run.dir, "trees"), { recursive: true });
150
+ writeFileSync(readyFile(run, m), "");
151
+ }
152
+ return { tree, branch, base, hints };
153
+ }
154
+
155
+ /**
156
+ * Removes a merged milestone's worktree with `git worktree remove` (never `--force`), keeping its branch. A tree with
157
+ * uncommitted or untracked files stays, with a hint that says so.
158
+ */
159
+ export async function removeTree(run: Run, m: string): Promise<{ removed: boolean; hint: string | null }> {
160
+ const tree = treeDir(run, m);
161
+ if (!existsSync(tree)) return { removed: false, hint: null };
162
+ const r = await gitRun(run.meta.repo, ["worktree", "remove", tree]);
163
+ if (r.code !== 0)
164
+ return {
165
+ removed: false,
166
+ hint: `kept ${tree}: ${tail(`${r.err}${r.out}`)}; git worktree remove it once that is settled`,
167
+ };
168
+ rmSync(readyFile(run, m), { force: true });
169
+ return { removed: true, hint: null };
170
+ }