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,21 +1,15 @@
1
- import {
2
- existsSync,
3
- lstatSync,
4
- readdirSync,
5
- readlinkSync,
6
- realpathSync,
7
- type Stats,
8
- statSync,
9
- } from "node:fs";
10
- import { join } from "node:path";
1
+ import { lstatSync, readdirSync, readlinkSync, realpathSync, type Stats, statSync } from "node:fs";
2
+ import { basename, join } from "node:path";
11
3
  import { z } from "zod";
12
4
  import { CatherdError, isCatherdError } from "../domain/errors.ts";
13
5
  import { normalizeOwned, overlaps } from "../domain/lane.ts";
14
6
  import { git, gitHead, statusSnapshot } from "../infra/git.ts";
15
7
  import { repoDir } from "../infra/paths.ts";
16
8
  import { appendJsonl, ensureJsonlHeader, readJsonl } from "../infra/store.ts";
9
+ import { gateEnvLines, readGateEnv } from "./gate-env.ts";
17
10
  import type { Deps } from "./ports.ts";
18
11
  import { findRun, type Run } from "./run-store.ts";
12
+ import { treeOf } from "./tree-path.ts";
19
13
 
20
14
  // Spec 1.1 §7: the verifier's gate ledger. A gate item that passed on the same content, with the same
21
15
  // command, is carried over instead of run again; every check is a verifier step `peek` and `status` show.
@@ -35,6 +29,10 @@ type GatePass = z.infer<typeof GatePassSchema>;
35
29
  export interface VerifierStep {
36
30
  at: string;
37
31
  item: string;
32
+ /** the item's command; steps written before 1.5 lack it */
33
+ command?: string;
34
+ /** the item's paths, cleaned (plan 23); steps written before it lack them */
35
+ paths?: string[];
38
36
  carried: boolean;
39
37
  /** a carried item: the commit its pass was recorded on */
40
38
  commit?: string;
@@ -46,10 +44,11 @@ export interface VerifierStep {
46
44
  export const gatesFile = (toplevel: string): string => join(repoDir(toplevel), "gates.jsonl");
47
45
  const stepsFile = (run: Run): string => join(run.dir, "verifier.jsonl");
48
46
 
49
- /** Repo-relative paths, `.` for the whole repo; anything that leaves the repo is refused. */
47
+ /** Repo-relative paths, `.` (or `./`) for the whole repo; anything that leaves the repo is refused. */
50
48
  function cleanPaths(paths: string[]): string[] {
49
+ const whole = (p: string) => p.trim() === "." || p.trim() === "./";
51
50
  try {
52
- return [...new Set(paths.map((p) => (p.trim() === "." ? "." : normalizeOwned(p))))].sort();
51
+ return [...new Set(paths.map((p) => (whole(p) ? "." : normalizeOwned(p))))].sort();
53
52
  } catch (e) {
54
53
  if (isCatherdError(e))
55
54
  throw new CatherdError("E_INPUT_INVALID", e.message.replace("owned path", "gate path"), {
@@ -148,7 +147,10 @@ const isLink = (file: string): boolean => {
148
147
 
149
148
  /** Whether git ignores `p` (a tracked file never is). */
150
149
  async function ignored(repo: string, p: string): Promise<boolean> {
151
- return (await git(repo, ["check-ignore", "-q", "--", p.replace(/\/$/, "")])).kind === "ok";
150
+ const bare = p.replace(/\/$/, "");
151
+ const asked = async (q: string) => (await git(repo, ["check-ignore", "-q", "--", q])).kind === "ok";
152
+ // a directory pattern (`dist/`) matches a path that is not on disk yet only through a child of it
153
+ return (await asked(bare)) || (!onDisk(join(repo, bare)) && (await asked(`${bare}/.catherd`)));
152
154
  }
153
155
 
154
156
  /** The most files a gate path's on-disk walk hashes. */
@@ -179,47 +181,101 @@ function walk(repo: string, p: string): string[] {
179
181
  }
180
182
 
181
183
  /**
182
- * The content hash of `paths`: each one's tree entry at HEAD (mode, type and object id), plus the content of every
183
- * uncommitted change under them, so a verifier checking a tree not yet committed gets a hash of what it ran.
184
- * A path git ignores, or one not at HEAD, is also hashed file by file from disk; ignored files under `.` or
185
- * under a tracked directory are not, so an ignored input is covered only when it is named.
184
+ * The lockfiles a gate item's dependencies come from, wherever the repo tracks them: they stand in for
185
+ * `node_modules` and the toolchain caches, which a gate path never needs to name (plan 23).
186
186
  */
187
- async function contentHash(repo: string, paths: string[]): Promise<string> {
187
+ export const LOCKFILES = [
188
+ "bun.lock",
189
+ "bun.lockb",
190
+ "package-lock.json",
191
+ "npm-shrinkwrap.json",
192
+ "pnpm-lock.yaml",
193
+ "yarn.lock",
194
+ "go.sum",
195
+ "Cargo.lock",
196
+ "poetry.lock",
197
+ "uv.lock",
198
+ "Gemfile.lock",
199
+ "composer.lock",
200
+ ];
201
+
202
+ /** `git ls-files -s -z` for `pathspecs`: each tracked file's mode, object id, stage and path, as the index holds them. */
203
+ async function tracked(repo: string, pathspecs: string[]): Promise<string> {
204
+ const r = await git(repo, ["ls-files", "-s", "-z", "--", ...pathspecs]);
205
+ if (r.kind !== "ok")
206
+ throw new CatherdError(
207
+ "E_IO_UNEXPECTED",
208
+ `git ls-files ${r.kind === "timed-out" ? "timed out" : "failed"} in ${repo}`,
209
+ { fix: `check that git works in ${repo}` },
210
+ );
211
+ return r.out;
212
+ }
213
+
214
+ /** Whether anything is at `file` on disk, a dangling symlink included. */
215
+ const onDisk = (file: string): boolean => {
216
+ try {
217
+ lstatSync(file);
218
+ return true;
219
+ } catch {
220
+ return false;
221
+ }
222
+ };
223
+
224
+ /**
225
+ * The content hash of `paths` (plan 23, a monorepo gate): the tracked files under each one, from `git ls-files -s`
226
+ * (mode, object id and path, so a committed chmod -x is new content), the repo's tracked lockfiles, and the
227
+ * content of every uncommitted change under them, so a verifier checking a tree not yet committed gets a hash of
228
+ * what it ran. An ignored path named explicitly (a `.env`, a build output, a `node_modules` folder) is hashed
229
+ * from disk, file by file within GATE_WALK_MAX, and as "absent" while it is not there; ignored files under `.`
230
+ * or under a tracked directory are never walked. A named symlink is hashed by what it points at too.
231
+ */
232
+ async function contentHash(repo: string, paths: string[]): Promise<{ hash: string; dirty: boolean }> {
188
233
  const h = new Bun.CryptoHasher("sha256");
234
+ // a dependency bump reaches every gate item through its lockfile
235
+ h.update(
236
+ `locks=${sha(
237
+ await tracked(
238
+ repo,
239
+ LOCKFILES.map((l) => `:(glob)**/${l}`),
240
+ ),
241
+ )}\n`,
242
+ );
189
243
  for (const p of paths) {
190
- // the tree entry, mode and type with the object id (`100755 blob <sha>`), so a committed chmod -x is new
191
- // content; a directory's tree id already covers its children's modes
192
- const entry = await git(
193
- repo,
194
- p === "."
195
- ? ["rev-parse", "HEAD^{tree}"]
196
- : ["ls-tree", "--full-tree", "HEAD", "--", p.replace(/\/$/, "")],
197
- );
198
- const at = entry.kind === "ok" ? entry.out.trim() : "";
199
- // a mistyped path (or a glob) would hash as a constant and carry a pass forever
200
- if (!at && !existsSync(join(repo, p)))
244
+ const bare = p.replace(/\/$/, "");
245
+ const listed = await tracked(repo, [p === "." ? "." : bare]);
246
+ const there = p === "." || onDisk(join(repo, bare));
247
+ const isIgnored = p !== "." && listed === "" && (await ignored(repo, p));
248
+ // a mistyped path (or a glob) would hash as a constant and carry a pass forever; an ignored output that
249
+ // is not built yet is part of the content, as "absent"
250
+ if (listed === "" && !there && !isIgnored)
201
251
  throw new CatherdError(
202
252
  "E_INPUT_INVALID",
203
253
  `gate path ${p} exists neither at HEAD nor in the working tree`,
204
254
  {
205
- fix: "check the spelling: pass repo-relative files or directories that exist, like src/ or package.json (no globs)",
255
+ fix: "check the spelling: pass repo-relative files or directories that exist, like src/ or package.json (no globs); a git-ignored output not built yet is fine",
206
256
  },
207
257
  );
208
- h.update(`${p}=${at || "missing"}\n`);
209
- // git status leaves ignored files out, so an ignored path (.env, a build output) or one not at HEAD is
210
- // hashed by what is on disk; "." keeps to HEAD and the not-ignored status. A symlink's HEAD entry is only
211
- // its link text, so a named symlink is hashed from disk too, by what it points at
212
- if (p !== "." && (!at || isLink(join(repo, p)) || (await ignored(repo, p)))) {
258
+ h.update(`${p}=${listed ? sha(listed) : there ? "untracked" : "absent"}\n`);
259
+ // git status leaves ignored files out, so an ignored path named here is hashed by what is on disk; "."
260
+ // keeps to the index and the not-ignored status. A tracked symlink's index entry is only its link text,
261
+ // so a named symlink is hashed from disk too, by what it points at
262
+ if (p !== "." && there && (isIgnored || isLink(join(repo, bare)))) {
213
263
  // one budget per gate path: its walk and every symlink target it follows count against GATE_WALK_MAX
214
264
  const budget: Budget = { path: p, files: 0, seen: new Set() };
215
265
  for (const f of walk(repo, p)) h.update(`disk ${f}=${await diskEntry(join(repo, f), budget)}\n`);
216
266
  }
217
267
  }
268
+ // an uncommitted lockfile change (a worker's `bun add`) reaches every item, as a committed one does
218
269
  const dirty = Object.keys(await statusSnapshot(repo))
219
- .filter((f) => paths.includes(".") || overlaps([f], paths).length > 0)
270
+ .filter(
271
+ (f) =>
272
+ paths.includes(".") ||
273
+ LOCKFILES.includes(basename(f.replace(/\/$/, ""))) ||
274
+ overlaps([f], paths).length > 0,
275
+ )
220
276
  .sort();
221
277
  for (const f of dirty) h.update(`dirty ${f}=${await dirtyEntry(join(repo, f), f)}\n`);
222
- return h.digest("hex");
278
+ return { hash: h.digest("hex"), dirty: dirty.length > 0 };
223
279
  }
224
280
 
225
281
  const readPasses = (repo: string): GatePass[] =>
@@ -241,37 +297,167 @@ export function latestVerifierStep(run: Run): VerifierStep | null {
241
297
  .at(-1) ?? null) as VerifierStep | null;
242
298
  }
243
299
 
300
+ /** One item the verifier checked for a milestone in this run: its name, its command, and whether it passed. */
301
+ export interface RecordedItem {
302
+ item: string;
303
+ command: string | null;
304
+ /** gate_check would carry it now: its last check's command has a pass on the current content of its paths */
305
+ passed: boolean;
306
+ }
307
+
308
+ const milestoneSteps = (run: Run, m: string): VerifierStep[] =>
309
+ readJsonl<VerifierStep>(stepsFile(run)).rows.filter(
310
+ (s) => typeof s?.item === "string" && s.milestone === m,
311
+ );
312
+
313
+ /** Each item's last step, in the order the items were first checked. */
314
+ const lastSteps = (steps: VerifierStep[]): VerifierStep[] =>
315
+ [...new Set(steps.map((s) => s.item))].map(
316
+ (item) => steps.findLast((s) => s.item === item) as VerifierStep,
317
+ );
318
+
319
+ /** The pass gate_check carries for `command` on `paths` as the tree is now, if any. */
320
+ async function carryingPass(
321
+ repo: string,
322
+ command: string,
323
+ paths: string[],
324
+ cwd: string = repo,
325
+ ): Promise<GatePass | undefined> {
326
+ const { hash } = await contentHash(cwd, paths);
327
+ return readPasses(repo).findLast((p) => p.command === command && p.hash === hash);
328
+ }
329
+
330
+ /**
331
+ * The items checked for milestone `m` in this run, in the order they were first checked (plan 23): a new
332
+ * verifier reuses their names, so the ledger carries what passed, and a re-check starts from the failed ones.
333
+ * An item counts as passed only when gate_check would carry it now, by the same rule: its last check's command
334
+ * has a pass on the current content of its paths, so a fix round that changed what it reads opens it again.
335
+ */
336
+ export async function recordedItems(run: Run, m: string): Promise<RecordedItem[]> {
337
+ const repo = run.meta.repo;
338
+ const passes = readPasses(repo);
339
+ return Promise.all(
340
+ lastSteps(milestoneSteps(run, m)).map(async (last) => {
341
+ const command = last.command ?? null;
342
+ // a step written before its paths were recorded: those of this run's latest pass of the item stand in
343
+ const paths =
344
+ last.paths ??
345
+ passes.findLast((p) => p.run === run.id && p.item === last.item && p.command === command)?.paths;
346
+ let passed = false;
347
+ if (command !== null && paths !== undefined) {
348
+ try {
349
+ passed = (await carryingPass(repo, command, paths, treeOf(run, m))) !== undefined;
350
+ } catch (e) {
351
+ // a path gone from the tree carries no pass
352
+ if (!isCatherdError(e)) throw e;
353
+ }
354
+ }
355
+ return { item: last.item, command, passed };
356
+ }),
357
+ );
358
+ }
359
+
360
+ /**
361
+ * The milestone's checked items with no pass recorded since their last check, from the step history alone
362
+ * (sync, for the protocol's next step); the verifier's listing and brief use recordedItems, which also re-opens
363
+ * a pass whose content has changed since.
364
+ */
365
+ export function failedItems(run: Run, m: string): string[] {
366
+ const passes = readPasses(run.meta.repo).filter((p) => p.run === run.id);
367
+ return lastSteps(milestoneSteps(run, m))
368
+ .filter(
369
+ (last) =>
370
+ !last.carried && !passes.some((p) => p.item === last.item && Date.parse(p.at) >= Date.parse(last.at)),
371
+ )
372
+ .map((last) => last.item);
373
+ }
374
+
244
375
  /**
245
376
  * `gate_check`: carried when this repo has a pass with the same command on the same content of `paths`;
246
- * records "verifier step: <item>" either way.
377
+ * records "verifier step: <item>" either way. With `milestone` it also lists the milestone's recorded items
378
+ * (plan 23), so a verifier reuses the names an earlier one checked.
247
379
  */
248
380
  export async function gateCheck(
249
381
  deps: Deps,
250
382
  i: { run: string; item: string; command: string; paths: string[]; milestone?: string },
251
- ): Promise<{ carried: true; passedAt: string; commit: string } | { carried: false }> {
383
+ ): Promise<
384
+ ({ carried: true; passedAt: string; commit: string } | { carried: false }) & { recorded?: RecordedItem[] }
385
+ > {
252
386
  const run = findRun(i.run);
253
387
  const paths = cleanPaths(i.paths);
254
- const hash = await contentHash(run.meta.repo, paths);
255
- const pass = readPasses(run.meta.repo).findLast((p) => p.command === i.command && p.hash === hash);
388
+ if (run.meta.base && !i.milestone)
389
+ throw new CatherdError("E_INPUT_INVALID", "a base run's gate_check names its milestone", {
390
+ fix: "pass milestone",
391
+ });
392
+ const pass = await carryingPass(run.meta.repo, i.command, paths, treeOf(run, i.milestone ?? null));
256
393
  recordStep(run, {
257
394
  at: new Date(deps.now()).toISOString(),
258
395
  item: i.item,
396
+ command: i.command,
397
+ paths,
259
398
  carried: pass !== undefined,
260
399
  ...(pass ? { commit: pass.commit } : {}),
261
400
  ...(i.milestone ? { milestone: i.milestone } : {}),
262
401
  });
263
- return pass ? { carried: true, passedAt: pass.at, commit: pass.commit } : { carried: false };
402
+ const carried = pass
403
+ ? { carried: true as const, passedAt: pass.at, commit: pass.commit }
404
+ : { carried: false as const };
405
+ return i.milestone ? { ...carried, recorded: await recordedItems(run, i.milestone) } : carried;
406
+ }
407
+
408
+ /**
409
+ * `gate_check` with a milestone and no item: the milestone's recorded items, recording no step, and the
410
+ * repo's gate environment as `NAME=value` lines (a secret as `NAME=$FROM`), which a native verifier exports.
411
+ */
412
+ export async function gateList(i: {
413
+ run: string;
414
+ milestone: string;
415
+ }): Promise<{ recorded: RecordedItem[]; env: string[] }> {
416
+ const run = findRun(i.run);
417
+ return {
418
+ recorded: await recordedItems(run, i.milestone),
419
+ env: gateEnvLines(await readGateEnv(run.meta.repo)),
420
+ };
264
421
  }
265
422
 
266
- /** `gate_pass`: records that `command` passed on the current content of `paths`, with its evidence. */
423
+ /** `gate_check` as both MCP servers expose it: item, command and paths together check an item; none of them,
424
+ * with a milestone, lists the milestone's recorded items. */
425
+ export async function gateCheckOrList(
426
+ deps: Deps,
427
+ a: { run: string; item?: string; command?: string; paths?: string[]; milestone?: string },
428
+ ): Promise<unknown> {
429
+ if (a.item === undefined && a.command === undefined && a.paths === undefined) {
430
+ if (!a.milestone)
431
+ throw new CatherdError("E_INPUT_INVALID", "gate_check needs an item, or a milestone to list", {
432
+ fix: "pass item, command and paths to check an item, or only run and milestone to list the recorded items",
433
+ });
434
+ return gateList({ run: a.run, milestone: a.milestone });
435
+ }
436
+ if (a.item === undefined || a.command === undefined || a.paths === undefined)
437
+ throw new CatherdError("E_INPUT_INVALID", "gate_check needs item, command and paths together", {
438
+ fix: "pass all three to check an item, or none of them (with milestone) to list the recorded items",
439
+ });
440
+ return gateCheck(deps, { ...a, item: a.item, command: a.command, paths: a.paths });
441
+ }
442
+
443
+ /**
444
+ * `gate_pass`: records that `command` passed on the current content of `paths`, with its evidence. A pass on
445
+ * uncommitted changes under `paths` names its commit `<HEAD>+uncommitted`: HEAD alone is not what it ran on.
446
+ */
267
447
  export async function gatePass(
268
448
  deps: Deps,
269
449
  i: { run: string; item: string; command: string; paths: string[]; evidence: string },
270
450
  ): Promise<{ recorded: true; hash: string; commit: string }> {
271
451
  const run = findRun(i.run);
272
452
  const paths = cleanPaths(i.paths);
273
- const hash = await contentHash(run.meta.repo, paths);
274
- const commit = (await gitHead(run.meta.repo)) ?? "none";
453
+ // a base run's milestone has its own tree: the one of the latest step that checked this item with this command
454
+ const step = readJsonl<VerifierStep>(stepsFile(run)).rows.findLast(
455
+ (s) => s?.item === i.item && s.command === i.command,
456
+ );
457
+ const cwd = treeOf(run, step?.milestone ?? null);
458
+ const { hash, dirty } = await contentHash(cwd, paths);
459
+ const head = (await gitHead(cwd)) ?? "none";
460
+ const commit = dirty ? `${head}+uncommitted` : head;
275
461
  const file = gatesFile(run.meta.repo);
276
462
  ensureJsonlHeader(file, "gates");
277
463
  appendJsonl(file, {
@@ -23,25 +23,51 @@ export type GlobalInstall =
23
23
  | { state: "shadowed"; onPath: string | null }
24
24
  | { state: "failed"; reason: string };
25
25
 
26
- async function run(cmd: string[]): Promise<{ ok: boolean; stdout: string; output: string }> {
26
+ /** How long `bun add -g` may take: a blackholed or proxied registry would otherwise hold `init` for as long as Bun retries. */
27
+ export const installLimits = { timeoutMs: 120_000 };
28
+
29
+ /** How long a `catherd --version` probe may take before its version counts as unknown. */
30
+ export const versionLimits = { timeoutMs: 10_000 };
31
+
32
+ /**
33
+ * `cmd` with its output. Past `timeoutMs` it is killed and reported as failed with the reason; its output is
34
+ * not awaited then, since a child it started may still hold the pipes open.
35
+ */
36
+ export async function run(
37
+ cmd: string[],
38
+ timeoutMs?: number,
39
+ ): Promise<{ ok: boolean; stdout: string; output: string }> {
27
40
  const p = Bun.spawn(cmd, {
28
41
  env: scrubSecrets(process.env),
29
42
  stdin: "ignore",
30
43
  stdout: "pipe",
31
44
  stderr: "pipe",
32
45
  });
33
- const [out, err, code] = await Promise.all([
34
- new Response(p.stdout).text(),
35
- new Response(p.stderr).text(),
36
- p.exited,
37
- ]);
38
- return { ok: code === 0, stdout: out, output: `${err}${out}` };
46
+ const out = new Response(p.stdout).text().catch(() => "");
47
+ const err = new Response(p.stderr).text().catch(() => "");
48
+ let timer: ReturnType<typeof setTimeout> | undefined;
49
+ const late = new Promise<"late">((resolve) => {
50
+ if (timeoutMs !== undefined) timer = setTimeout(() => resolve("late"), timeoutMs);
51
+ });
52
+ const code = await Promise.race([p.exited, late]).finally(() => clearTimeout(timer));
53
+ if (code === "late") {
54
+ p.kill("SIGKILL");
55
+ const what = cmd.slice(1).join(" ");
56
+ return {
57
+ ok: false,
58
+ stdout: "",
59
+ output: `${what} timed out after ${Math.round((timeoutMs ?? 0) / 1000)} s`,
60
+ };
61
+ }
62
+ const [stdout, stderr] = await Promise.all([out, err]);
63
+ return { ok: code === 0, stdout, output: `${stderr}${stdout}` };
39
64
  }
40
65
 
41
66
  /** what `bin --version` prints on stdout (a warning on stderr must not make it look like another version) */
42
67
  async function versionOf(bin: string | null): Promise<string | null> {
43
68
  if (!bin) return null;
44
- const r = await run([bin, "--version"]).catch(() => null);
69
+ // a wedged `catherd` on PATH must not hold `init`: past the limit its version is unknown
70
+ const r = await run([bin, "--version"], versionLimits.timeoutMs).catch(() => null);
45
71
  return r?.ok ? r.stdout.trim() || null : null;
46
72
  }
47
73
 
@@ -71,10 +97,12 @@ export const realGlobalInstall: GlobalInstallDeps = {
71
97
  return versionOf(Bun.which("catherd", { PATH }));
72
98
  },
73
99
  install: (version) =>
74
- run([process.execPath, "add", "-g", `catherd-cli@${version}`]).catch((e: unknown) => ({
75
- ok: false,
76
- output: errorMessage(e),
77
- })),
100
+ run([process.execPath, "add", "-g", `catherd-cli@${version}`], installLimits.timeoutMs).catch(
101
+ (e: unknown) => ({
102
+ ok: false,
103
+ output: errorMessage(e),
104
+ }),
105
+ ),
78
106
  };
79
107
 
80
108
  /**
@@ -86,7 +114,12 @@ export async function ensureGlobal(
86
114
  deps: GlobalInstallDeps,
87
115
  installing: () => void,
88
116
  ): Promise<GlobalInstall> {
89
- if ((await deps.globalVersion()) === version) return { state: "current" };
117
+ if ((await deps.globalVersion()) === version) {
118
+ // current, but the launcher runs the catherd PATH finds first: another version there (or none) still
119
+ // makes it fall back to bunx, so say so as after an install
120
+ const onPath = await deps.pathVersion().catch(() => null);
121
+ return onPath === version ? { state: "current" } : { state: "shadowed", onPath };
122
+ }
90
123
  installing();
91
124
  // never rejects: a failed install is reported, and init goes on
92
125
  const r = await deps.install(version).catch((e: unknown) => ({ ok: false, output: errorMessage(e) }));
@@ -16,7 +16,7 @@ import { assetPath } from "../infra/assets.ts";
16
16
  import { type JevTransport, jevRequest } from "../infra/jev-client.ts";
17
17
  import { addSecret, log } from "../infra/log.ts";
18
18
  import { appendJsonl, ensureJsonlHeader, readJsonl, readVersioned } from "../infra/store.ts";
19
- import { aaKey, credentialsPath, saveCredential, savedCredential } from "./credentials.ts";
19
+ import { credentialsPath, saveCredential, savedCredential } from "./credentials.ts";
20
20
 
21
21
  export { credentialsPath };
22
22
 
@@ -26,12 +26,10 @@ export const jevQuestions = (): JevFile =>
26
26
  (file ??= readVersioned(assetPath("catalog/jev.json"), JevFileSchema, 1));
27
27
 
28
28
  /**
29
- * Registers the saved keys (Jev's and Artificial Analysis's) with the redactor, for a process that reads
30
- * logs without ever using them.
29
+ * Registers the saved key (Jev's) with the redactor, for a process that reads logs without ever using it.
31
30
  */
32
31
  export function registerSavedSecrets(): void {
33
32
  addSecret(jevKey());
34
- addSecret(aaKey());
35
33
  }
36
34
 
37
35
  /** The key saved in credentials.json, or why that file cannot be read (unparsable, newer schema). */
@@ -0,0 +1,154 @@
1
+ import { existsSync, readdirSync, readFileSync } from "node:fs";
2
+ import { join } from "node:path";
3
+ import { CatherdError } from "../domain/errors.ts";
4
+ import { assertId } from "../domain/ids.ts";
5
+ import {
6
+ assertLaneValues,
7
+ headerBlock,
8
+ headerLine,
9
+ type LaneHeader,
10
+ normalizeOwned,
11
+ overlaps,
12
+ parseLaneHeader,
13
+ } from "../domain/lane.ts";
14
+ import { cell } from "../domain/util.ts";
15
+ import { withFileLock } from "../infra/filelock.ts";
16
+ import { writeTextAtomic } from "../infra/store.ts";
17
+ import { pendingDispatches } from "./dispatches.ts";
18
+ import type { Deps } from "./ports.ts";
19
+ import { findRun, type Run, runPaths } from "./run-store.ts";
20
+
21
+ /** lanes/<lane>.md in the run folder (admission's laneFile, kept here so finalize can import this module). */
22
+ const laneFile = (run: Run, lane: string): string => join(runPaths(run.dir).lanes, `${lane}.md`);
23
+
24
+ // Spec 1.5 "Lane editing": one header line at a time, validated, instead of `sed` on the run folder; and an
25
+ // Owns list that can grow mid-lane, with the overlap re-checked.
26
+
27
+ /** The header lines lane_set writes, and their labels in the lane file. */
28
+ export const LANE_FIELDS = {
29
+ owns: "Owns",
30
+ fast_check: "Fast check",
31
+ kind: "Kind",
32
+ difficulty: "Difficulty",
33
+ after: "After",
34
+ allow: "Allow",
35
+ } as const;
36
+ export type LaneField = keyof typeof LANE_FIELDS;
37
+
38
+ /** `text` with `label`'s header line set to `value`: replaced where it is, else added atop the header block. */
39
+ export function setHeaderLine(text: string, label: string, value: string): string {
40
+ const line = `${label}: ${value}`;
41
+ const lines = text.split("\n");
42
+ const [start, end] = headerBlock(lines);
43
+ const re = headerLine(label);
44
+ const at = lines.slice(start, end).findIndex((l) => re.test(l));
45
+ if (at >= 0) lines[start + at] = line;
46
+ else lines.splice(start, 0, line);
47
+ return lines.join("\n");
48
+ }
49
+
50
+ function readLane(run: Run, lane: string): { file: string; text: string } {
51
+ assertId("lane", lane);
52
+ const file = laneFile(run, lane);
53
+ if (!existsSync(file))
54
+ throw new CatherdError("E_LANE_INVALID", `no lane file lanes/${lane}.md`, {
55
+ fix: "write it with write_run_file first",
56
+ });
57
+ return { file, text: readFileSync(file, "utf8") };
58
+ }
59
+
60
+ /**
61
+ * Paths of `owns` another running lane owns: refused as admission refuses them. Other lanes' Owns lines
62
+ * that overlap come back as hints: they are not running, so nothing collides yet.
63
+ */
64
+ function overlapCheck(deps: Deps, run: Run, lane: string, owns: string[]): string[] {
65
+ for (const d of pendingDispatches(run, deps.now())) {
66
+ if (d.admit.lane === lane) continue;
67
+ // a running lane holds what an earlier owns_add granted it, too
68
+ const held = d.admit.lane ? [...d.admit.owns, ...currentOwns(run, d.admit.lane)] : d.admit.owns;
69
+ const shared = overlaps(owns, held);
70
+ if (shared.length)
71
+ throw new CatherdError(
72
+ "E_ADMIT_OVERLAP",
73
+ `${lane} would own ${shared.join(", ")}, which ${d.admit.name} (running) owns`,
74
+ { fix: `add it once ${d.admit.name} finishes, or give the work to ${d.admit.lane ?? d.admit.name}` },
75
+ );
76
+ }
77
+ const dir = runPaths(run.dir).lanes;
78
+ const hints: string[] = [];
79
+ for (const f of existsSync(dir) ? readdirSync(dir) : []) {
80
+ const other = f.endsWith(".md") ? f.slice(0, -3) : null;
81
+ if (!other || other === lane) continue;
82
+ try {
83
+ const shared = overlaps(owns, parseLaneHeader(readFileSync(join(dir, f), "utf8")).owns);
84
+ if (shared.length) hints.push(`lanes/${f} also owns ${shared.join(", ")}: do not run the two together`);
85
+ } catch {
86
+ // an unreadable lane is preflight's to report
87
+ }
88
+ }
89
+ return hints;
90
+ }
91
+
92
+ /**
93
+ * `lane_set(run, lane, field, value)`: one header line, validated as `write_run_file` validates a lane; an
94
+ * Owns line also re-checks overlap with the running lanes. Returns the line and the header as it now reads.
95
+ */
96
+ export async function laneSet(
97
+ deps: Deps,
98
+ i: { run: string; lane: string; field: LaneField; value: string },
99
+ ): Promise<{ lane: string; line: string; header: LaneHeader; hints?: string[] }> {
100
+ const run = findRun(i.run);
101
+ const label = LANE_FIELDS[i.field];
102
+ if (!label)
103
+ throw new CatherdError("E_INPUT_INVALID", `no lane field "${i.field}"`, {
104
+ fix: `one of ${Object.keys(LANE_FIELDS).join(", ")}`,
105
+ });
106
+ const value = i.value.replace(/\s*\n\s*/g, " ").trim();
107
+ const { file } = readLane(run, i.lane);
108
+ return withFileLock(file, () => {
109
+ const next = setHeaderLine(readLane(run, i.lane).text, label, value);
110
+ const header = assertLaneValues(next, `lanes/${i.lane}.md`);
111
+ const hints = i.field === "owns" ? overlapCheck(deps, run, i.lane, header.owns) : [];
112
+ writeTextAtomic(file, next);
113
+ return { lane: i.lane, line: `${label}: ${value}`, header, ...(hints.length ? { hints } : {}) };
114
+ });
115
+ }
116
+
117
+ /**
118
+ * `owns_add(run, lane, paths, why)`: grows a lane's Owns mid-lane (a test file the plan's grep missed, a clone
119
+ * found after the gate ran), refused when a running lane owns one of them. The why is kept in the lane file.
120
+ * A running dispatch of the lane is held to the new list when it finishes.
121
+ */
122
+ export async function ownsAdd(
123
+ deps: Deps,
124
+ i: { run: string; lane: string; paths: string[]; why: string },
125
+ ): Promise<{ lane: string; owns: string[]; added: string[]; hints?: string[] }> {
126
+ const run = findRun(i.run);
127
+ if (!i.why.trim())
128
+ throw new CatherdError("E_INPUT_INVALID", "owns_add needs a why", {
129
+ fix: "say what made the lane need the paths, in one line",
130
+ });
131
+ const paths = i.paths.map(normalizeOwned);
132
+ const { file } = readLane(run, i.lane);
133
+ return withFileLock(file, () => {
134
+ const text = readLane(run, i.lane).text;
135
+ const owns = parseLaneHeader(text).owns;
136
+ const added = paths.filter((p) => !owns.includes(p));
137
+ const merged = [...owns, ...added];
138
+ const hints = overlapCheck(deps, run, i.lane, added);
139
+ const note = `\nOwns added ${new Date(deps.now()).toISOString()}: ${added.join(", ")} — ${cell(i.why)}\n`;
140
+ const next = setHeaderLine(text, "Owns", merged.join(", "));
141
+ assertLaneValues(next, `lanes/${i.lane}.md`);
142
+ if (added.length) writeTextAtomic(file, `${next.replace(/\n*$/, "\n")}${note}`);
143
+ return { lane: i.lane, owns: merged, added, ...(hints.length ? { hints } : {}) };
144
+ });
145
+ }
146
+
147
+ /** The lane's Owns as its file reads now: a finishing dispatch is held to what owns_add granted since. */
148
+ export function currentOwns(run: Run, lane: string): string[] {
149
+ try {
150
+ return parseLaneHeader(readFileSync(laneFile(run, lane), "utf8")).owns;
151
+ } catch {
152
+ return [];
153
+ }
154
+ }