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
@@ -155,6 +155,20 @@ function finalize(run: FinishedRun): Outcome {
155
155
  const f = foldOpencodeEvents(run.eventLines);
156
156
  const stopped = run.exit.reason;
157
157
  const tooOld = f.tooOld || OPENCODE_TOO_OLD.some((r) => r.test(run.stderr));
158
+ // plan 23: the provider kept failing; failover takes it as a usage limit, onto the next backend
159
+ if (stopped === "provider-unavailable")
160
+ return {
161
+ status: "limit",
162
+ thread: f.thread ?? run.request.thread,
163
+ tokens: f.tokens,
164
+ costUsd: f.costUsd,
165
+ images: [],
166
+ error: {
167
+ code: "provider-unavailable",
168
+ message: f.error?.message ?? "the provider retried with no progress",
169
+ },
170
+ reply: f.reply,
171
+ };
158
172
  const status: RunStatus =
159
173
  stopped === "cancelled"
160
174
  ? "cancelled"
@@ -201,6 +215,8 @@ function parse(line: string): EventDelta {
201
215
  const activity = opencodeActivity(e);
202
216
  if (activity) d.activity = activity;
203
217
  if (typeof e.sessionID === "string") d.thread = e.sessionID;
218
+ // plan 23: each provider retry starts a step again with nothing between; the supervisor counts them
219
+ if (e.type === "step_start") d.step = true;
204
220
  if (e.type === "step_finish") {
205
221
  d.tokens = opencodeTokens(e.part?.tokens);
206
222
  d.requestInput = d.tokens.input;
@@ -220,6 +236,21 @@ function parse(line: string): EventDelta {
220
236
  * older messages keep their errors, so only the newest assistant message counts, and with `sinceMs` only
221
237
  * if this run wrote it.
222
238
  */
239
+ /**
240
+ * Plan 23: the provider retry the newest assistant message this run wrote is waiting out (a 503, an
241
+ * overloaded backend), by its attempt number; null when it is not retrying.
242
+ */
243
+ function providerRetryOf(messages: unknown, sinceMs: number): number | null {
244
+ if (!Array.isArray(messages)) return null;
245
+ const m = messages.find((x) => x?.type === "assistant") as Record<string, any> | undefined;
246
+ if (!m?.retry || !(m.time?.created >= sinceMs)) return null;
247
+ return typeof m.retry.attempt === "number" ? m.retry.attempt : 1;
248
+ }
249
+
250
+ async function providerRetry(thread: string, sinceMs: number): Promise<number | null> {
251
+ return providerRetryOf((await opencodeApi("GET", `/api/session/${thread}/message`))?.data, sinceMs);
252
+ }
253
+
223
254
  function limitRetry(messages: unknown, sinceMs?: number): string | null {
224
255
  if (!Array.isArray(messages)) return null;
225
256
  const m = messages.find((x) => x?.type === "assistant") as Record<string, any> | undefined;
@@ -265,7 +296,12 @@ async function isBusy(thread: string, sinceMs?: number): Promise<boolean> {
265
296
  const active = (await opencodeApi("GET", "/api/session/active"))?.data;
266
297
  if (!active || typeof active !== "object" || !(thread in active)) return false;
267
298
  const messages = (await opencodeApi("GET", `/api/session/${thread}/message`))?.data;
268
- return Array.isArray(messages) && limitRetry(messages, sinceMs) === null;
299
+ // plan 23: a session only retrying its provider is not busy, so the idle timeout still runs
300
+ return (
301
+ Array.isArray(messages) &&
302
+ limitRetry(messages, sinceMs) === null &&
303
+ providerRetryOf(messages, sinceMs ?? 0) === null
304
+ );
269
305
  }
270
306
 
271
307
  /** Killing the v2 client does not stop its session; the service must be told (research §2.4). */
@@ -327,6 +363,7 @@ export const opencodeAdapter: BackendAdapter = {
327
363
  resume: { supported: true, sameAccessOnly: false, threadPattern: THREAD },
328
364
  interrupt: (thread) => interrupt(thread),
329
365
  isBusy: (thread, _cwd, sinceMs) => isBusy(thread, sinceMs),
366
+ providerRetry: (thread, _cwd, sinceMs) => providerRetry(thread, sinceMs),
330
367
  failoverFor,
331
368
  graceAfterFinalMs: null,
332
369
  reportsCost: true,
package/src/cli.ts CHANGED
@@ -20,29 +20,23 @@ const { VERSION } = await import("./infra/version.ts");
20
20
 
21
21
  type Command = ReturnType<typeof defineCommand>;
22
22
 
23
- /** The dashboard's flags, typed as any command's args so `main` stays a plain `Command`. */
24
- const dashboardArgs: ArgsDef = {
25
- ...HOST_ARG,
26
- plain: {
27
- type: "boolean",
28
- description: "the dashboard in ASCII without colour (NO_COLOR also drops colour)",
29
- },
30
- "reduced-motion": { type: "boolean", description: "the dashboard without animation" },
31
- };
23
+ /** HOST_ARG typed as any command's args, so `main` stays a plain `Command`. */
24
+ const MAIN_ARGS: ArgsDef = HOST_ARG;
32
25
 
33
26
  /**
34
- * Spec §8. Every subcommand loads lazily, so `mcp`, `lock` and `_supervise` never load OpenTUI or React
35
- * (spec §3.1); a bare `catherd` opens the TUI.
27
+ * Spec §8. Every subcommand loads lazily, so `mcp`, `lock` and `_supervise` never load the panel (spec §3.1);
28
+ * a bare `catherd` runs `catherd panel`.
36
29
  */
37
30
  export const main: Command = defineCommand({
38
31
  meta: {
39
32
  name: "catherd",
40
33
  version: VERSION,
41
- description: "Herds coding agents",
34
+ description: "Herds coding agents. catherd (or catherd panel) opens the panel in your browser.",
42
35
  },
43
- args: dashboardArgs,
36
+ args: MAIN_ARGS,
44
37
  subCommands: {
45
38
  init: () => import("./entry/init-command.ts").then((m) => m.initCommand),
39
+ panel: () => import("./entry/panel-command.ts").then((m) => m.panelCommand),
46
40
  status: () => import("./entry/runs-command.ts").then((m) => m.statusCommand),
47
41
  watch: () => import("./entry/runs-command.ts").then((m) => m.watchCommand),
48
42
  runs: () => import("./entry/runs-command.ts").then((m) => m.runsCommand),
@@ -50,17 +44,19 @@ export const main: Command = defineCommand({
50
44
  doctor: () => import("./entry/doctor-command.ts").then((m) => m.doctorCommand),
51
45
  catalog: () => import("./entry/catalog-command.ts").then((m) => m.catalogCommand),
52
46
  knowledge: () => import("./entry/knowledge-command.ts").then((m) => m.knowledgeCommand),
47
+ workspace: () => import("./entry/workspace-command.ts").then((m) => m.workspaceCommand),
53
48
  lock: () => import("./entry/lock-command.ts").then((m) => m.lockCommand),
49
+ "run-file": () => import("./entry/role-cli-command.ts").then((m) => m.runFileCommand),
50
+ gate: () => import("./entry/role-cli-command.ts").then((m) => m.gateCommand),
51
+ answer: () => import("./entry/answer-command.ts").then((m) => m.answerCommand),
52
+ pause: () => import("./entry/pause-command.ts").then((m) => m.pauseCommand),
53
+ resume: () => import("./entry/pause-command.ts").then((m) => m.resumeCommand),
54
54
  "capture-fixtures": () =>
55
55
  import("./entry/capture-fixtures-command.ts").then((m) => m.captureFixturesCommand),
56
+ scorecard: () => import("./entry/scorecard-command.ts").then((m) => m.scorecardCommand),
56
57
  mcp: () => import("./entry/mcp/command.ts").then((m) => m.mcpCommand),
57
58
  _supervise: () => import("./entry/supervise-command.ts").then((m) => m.superviseCommand),
58
59
  },
59
- // citty runs this after any subcommand too; only a bare `catherd` loads and opens the dashboard
60
- async run(ctx) {
61
- if (ctx.args._.length > 0) return;
62
- await (await import("./entry/tui/run.tsx")).tuiRun(ctx);
63
- },
64
60
  });
65
61
 
66
62
  const resolve = async <T>(v: T | Promise<T> | (() => T | Promise<T>)): Promise<T> =>
@@ -136,13 +132,10 @@ export async function runCli(argv: string[]): Promise<number> {
136
132
  return EXIT.ok;
137
133
  }
138
134
  try {
139
- const command = parseArgs(own, dashboardArgs)._[0];
140
- // a bare `catherd` opens the dashboard, which takes only its own flags: a typo is not a terminal problem
135
+ const command = parseArgs(own, HOST_ARG)._[0];
136
+ // a bare `catherd` takes only --host: a typo is a usage error
141
137
  const flag = (a: string) => (a.startsWith("--") ? (a.slice(2).split("=")[0] ?? "") : "");
142
- // citty negates a boolean as `--no-<name>`
143
- const known = (name: string) =>
144
- Object.hasOwn(dashboardArgs, name) ||
145
- (name.startsWith("no-") && dashboardArgs[name.slice(3)]?.type === "boolean");
138
+ const known = (name: string) => Object.hasOwn(HOST_ARG, name);
146
139
  const unknown =
147
140
  command === undefined
148
141
  ? own.find((a, i) => a.startsWith("-") && !known(flag(a)) && own[i - 1] !== "--host")
@@ -152,9 +145,11 @@ export async function runCli(argv: string[]): Promise<number> {
152
145
  return EXIT.usage;
153
146
  }
154
147
  // `lock` forwards signals to its command itself; everything else stops at once on Ctrl-C. The command is
155
- // the first word that is not a flag: `catherd --plain lock -- …` is `lock` too.
156
- if (command !== "lock") process.once("SIGINT", () => process.exit(EXIT.interrupted));
157
- const parsed = parseArgs(own, dashboardArgs);
148
+ // the first word that is not a flag: `catherd --host codex lock -- …` is `lock` too.
149
+ // the panel stops itself on Ctrl-C
150
+ if (command !== "lock" && command !== undefined)
151
+ process.once("SIGINT", () => process.exit(EXIT.interrupted));
152
+ const parsed = parseArgs(own, HOST_ARG);
158
153
  if (parsed.host !== undefined) {
159
154
  const forwarded = own.filter(
160
155
  (a, i) => a !== "--host" && own[i - 1] !== "--host" && !a.startsWith("--host="),
@@ -168,6 +163,11 @@ export async function runCli(argv: string[]): Promise<number> {
168
163
  ...(dashdash < 0 ? [] : argv.slice(dashdash)),
169
164
  );
170
165
  }
166
+ if (command === undefined) {
167
+ const { runPanel } = await import("./entry/panel-command.ts");
168
+ await runPanel({ port: 4317, open: true, host: parsed.host });
169
+ return EXIT.ok;
170
+ }
171
171
  await runCommand(main, { rawArgs });
172
172
  return Number(process.exitCode ?? EXIT.ok);
173
173
  } catch (e) {
@@ -1,7 +1,7 @@
1
1
  import { parseRung, tryParseRung } from "./ids.ts";
2
2
  import { agentName, type Profile } from "./profile.ts";
3
3
  import type { Access } from "./record.ts";
4
- import { nativeDisallowedTools, rolePrompt } from "./role-prompts.ts";
4
+ import { NOT_THE_ORCHESTRATOR, nativeDisallowedTools, rolePrompt } from "./role-prompts.ts";
5
5
  import { ROLES, type Role } from "./roles.ts";
6
6
 
7
7
  export interface AgentFile {
@@ -32,6 +32,8 @@ export function renderAgent(o: {
32
32
  "",
33
33
  rolePrompt(o.role, o.version),
34
34
  "",
35
+ NOT_THE_ORCHESTRATOR,
36
+ "",
35
37
  ].join("\n");
36
38
  }
37
39
 
@@ -1,36 +1,33 @@
1
- import { type Dim, DIMS, type Score } from "./catalog.ts";
2
- import { DIFFICULTIES, type Difficulty, KINDS, type Kind } from "./lane.ts";
3
-
4
- /**
5
- * Spec 1.2 §5.1: the dimensions each kind's default bars span, for Track A (`copy`, `build`) and Track B
6
- * (`logic`, `hard`). `steer` has no default bar: it is shown, and a user may set one.
7
- */
8
- export const DEFAULT_BAR_DIMS: Record<Kind, { A: Dim[]; B: Dim[] }> = {
9
- repo_code: { A: ["repo_code"], B: ["repo_code", "honesty", "agentic"] },
10
- terminal: { A: ["terminal"], B: ["terminal", "honesty", "agentic"] },
11
- ui: { A: ["frontend", "repo_code"], B: ["frontend", "repo_code", "honesty", "agentic"] },
12
- prose: { A: ["repo_code"], B: ["repo_code", "honesty"] },
13
- research: { A: ["repo_code"], B: ["repo_code", "honesty"] },
14
- };
15
-
16
- /** The dimensions a difficulty's default bar spans for a kind. */
17
- export const barDimsOf = (kind: Kind, d: Difficulty): Dim[] =>
18
- d === "copy" || d === "build" ? DEFAULT_BAR_DIMS[kind].A : DEFAULT_BAR_DIMS[kind].B;
19
-
20
- /** Every dimension some default bar uses: the values an enabled rung needs (spec 1.2 §6.1). */
21
- export const BAR_DIMS: Dim[] = DIMS.filter((d) =>
22
- KINDS.some((k) => DIFFICULTIES.some((x) => barDimsOf(k, x).includes(d))),
23
- );
24
-
25
- /** Spec 1.2 §5.2: the percentile of the scored rungs each difficulty's threshold sits at. */
26
- export const BAR_PERCENTILE: Record<Difficulty, number> = { copy: 25, build: 50, logic: 60, hard: 75 };
27
-
28
- const PERCENTILE_WORD: Record<Difficulty, string> = {
29
- copy: "the 25th percentile",
30
- build: "the median",
31
- logic: "the 60th percentile",
32
- hard: "the 75th percentile",
33
- };
1
+ import { z } from "zod";
2
+ import type { Catalog } from "./catalog.ts";
3
+ import { DIFFICULTIES, type Difficulty } from "./lane.ts";
4
+
5
+ /** The percentile of the shipped models' intelligences each difficulty's bar sits at. */
6
+ export const BAR_PERCENTILE: Record<Difficulty, number> = { copy: 20, build: 45, logic: 60, hard: 65 };
7
+
8
+ /** A profile's or a role's own percentiles (0 to 100), one difficulty at a time; a difficulty left out keeps its shipped percentile. */
9
+ export const BarsSchema = z.partialRecord(z.enum(DIFFICULTIES), z.number().min(0).max(100));
10
+ export type Bars = z.infer<typeof BarsSchema>;
11
+
12
+ /** The percentiles in force as stored: the shipped ones, with `p.bars` laid over them; they may decrease. */
13
+ export const effectiveBars = (p: { bars?: Bars }): Record<Difficulty, number> => ({
14
+ ...BAR_PERCENTILE,
15
+ ...p.bars,
16
+ });
17
+
18
+ /** Each pair `[easier, harder]` of neighbouring difficulties whose percentile goes down. */
19
+ export const barInversions = (at: Record<Difficulty, number>): [Difficulty, Difficulty][] =>
20
+ DIFFICULTIES.slice(1).flatMap((d, i): [Difficulty, Difficulty][] =>
21
+ at[d] < at[DIFFICULTIES[i] as Difficulty] ? [[DIFFICULTIES[i] as Difficulty, d]] : [],
22
+ );
23
+
24
+ /** The percentiles a route reads: the effective ones made non-decreasing, so a harder lane never routes weaker. */
25
+ export function percentilesOf(p: { bars?: Bars }): Record<Difficulty, number> {
26
+ const at = effectiveBars(p);
27
+ let floor = 0;
28
+ for (const d of DIFFICULTIES) at[d] = floor = Math.max(at[d], floor);
29
+ return at;
30
+ }
34
31
 
35
32
  /** The `p`th percentile of `xs`, interpolated linearly between the closest ranks; NaN for none. */
36
33
  export function percentile(xs: readonly number[], p: number): number {
@@ -42,78 +39,7 @@ export function percentile(xs: readonly number[], p: number): number {
42
39
  return (s[lo] as number) + ((s[hi] as number) - (s[lo] as number)) * (pos - lo);
43
40
  }
44
41
 
45
- /**
46
- * Spec 1.2 §5.2 "measured or better": a value published for this exact rung in the anchor's unit. That is a
47
- * `verified` or `measured` value, and a hand-typed `secondary` one (no `source`: the 1.0 word for a value
48
- * published by someone other than the vendor, which is 1.2's `measured`). Plan 14 Ruling 1.
49
- */
50
- export const measuredOrBetter = (s: Score): boolean =>
51
- s.confidence === "verified" ||
52
- s.confidence === "measured" ||
53
- (s.confidence === "secondary" && s.source === undefined);
54
-
55
- /** Each dimension's values that bars are derived from: one per canonical rung, measured or better. */
56
- export function barPool(scores: readonly Score[]): Record<Dim, number[]> {
57
- const out = Object.fromEntries(DIMS.map((d) => [d, [] as number[]])) as Record<Dim, number[]>;
58
- const seen = new Set<string>();
59
- for (const s of scores) {
60
- const key = `${s.rung} ${s.dim}`;
61
- if (!measuredOrBetter(s) || seen.has(key)) continue;
62
- seen.add(key);
63
- out[s.dim].push(s.value);
64
- }
65
- return out;
66
- }
67
-
68
- /** A bar value, kept to four significant digits (DeepSWE 60.95, an Arena rating 1668, agentic 0.08622). */
69
- const tidy = (v: number): number => Number(v.toPrecision(4));
70
-
71
- export type Bars = Record<Kind, Record<Difficulty, Partial<Record<Dim, number>>>>;
72
- /** `scores.json` `barsWhy`: per dimension and difficulty, where its threshold came from. */
73
- export type BarsWhy = Partial<Record<Dim, Partial<Record<Difficulty, string>>>>;
74
-
75
- /**
76
- * Spec 1.2 §5.1–§5.2: the default bars. Each dimension's threshold at a difficulty is that difficulty's
77
- * percentile of the pool (`barPool`), the same in every kind whose bar spans the dimension. A dimension with
78
- * no pooled value gets no threshold (and a `barsWhy` line saying so). `benchmark` names each dimension's unit
79
- * and `date` the day the data was read.
80
- */
81
- export function deriveBars(
82
- pool: Record<Dim, number[]>,
83
- benchmark: (d: Dim) => string,
84
- date: string,
85
- ): { bars: Bars; barsWhy: BarsWhy } {
86
- const value: Partial<Record<Dim, Partial<Record<Difficulty, number>>>> = {};
87
- const barsWhy: BarsWhy = {};
88
- for (const dim of BAR_DIMS)
89
- for (const d of DIFFICULTIES) {
90
- if (!KINDS.some((k) => barDimsOf(k, d).includes(dim))) continue;
91
- const xs = pool[dim];
92
- const why = (barsWhy[dim] ??= {});
93
- if (xs.length === 0) {
94
- why[d] = `no rung is measured or better on ${benchmark(dim)} (${date}): no threshold`;
95
- continue;
96
- }
97
- const v = tidy(percentile(xs, BAR_PERCENTILE[d]));
98
- (value[dim] ??= {})[d] = v;
99
- why[d] =
100
- `${PERCENTILE_WORD[d]} of ${xs.length} rungs measured or better on ${benchmark(dim)} (${date}): ${v}`;
101
- }
102
- const bars = Object.fromEntries(
103
- KINDS.map((k) => [
104
- k,
105
- Object.fromEntries(
106
- DIFFICULTIES.map((d) => [
107
- d,
108
- Object.fromEntries(
109
- barDimsOf(k, d).flatMap((dim) => {
110
- const v = value[dim]?.[d];
111
- return v === undefined ? [] : [[dim, v]];
112
- }),
113
- ),
114
- ]),
115
- ),
116
- ]),
117
- ) as Bars;
118
- return { bars, barsWhy };
42
+ /** A difficulty's bar: an intelligence percentile of the catalog's pool, 0 when there is no index. */
43
+ export function barOf(c: Catalog, d: Difficulty, at: Record<Difficulty, number> = BAR_PERCENTILE): number {
44
+ return c.pool.length === 0 ? 0 : percentile(c.pool, at[d]);
119
45
  }
@@ -0,0 +1,72 @@
1
+ /** Why a role stopped without finishing (spec 2.0 decision 3); the machine maps each to a transition. */
2
+ export const BLOCKED_REASONS = [
3
+ "needs_user",
4
+ "too_hard",
5
+ "provider_error",
6
+ "no_access",
7
+ "check_broken",
8
+ "out_of_scope",
9
+ ] as const;
10
+ export type BlockedReason = (typeof BLOCKED_REASONS)[number];
11
+
12
+ export type StopAction = "climb" | "failover" | "park" | "wait";
13
+
14
+ /** What the machine does with each reason (spec 2.0 decision 8): climb, retry then fail over, park, or wait. */
15
+ export const REASON_ACTION: Readonly<Record<BlockedReason, StopAction>> = {
16
+ needs_user: "park",
17
+ too_hard: "climb",
18
+ provider_error: "failover",
19
+ no_access: "park",
20
+ check_broken: "wait",
21
+ out_of_scope: "park",
22
+ };
23
+
24
+ const REASON_AT_START = /^[`*\s]*([a-z][a-z_-]*)[`*]*\s*(?:[:—–-]\s*)?([\s\S]*)$/i;
25
+
26
+ /** The reason a `STATUS: blocked — <reason>: <why>` line names, and its why; null when it names none. */
27
+ export function parseBlockedReason(why: string | null): { reason: BlockedReason; why: string } | null {
28
+ const m = why === null ? null : REASON_AT_START.exec(why.trim());
29
+ if (!m) return null;
30
+ const word = (m[1] as string).toLowerCase().replace(/-/g, "_");
31
+ const reason = BLOCKED_REASONS.find((r) => r === word);
32
+ return reason ? { reason, why: (m[2] ?? "").trim() } : null;
33
+ }
34
+
35
+ /** The reply contract's blocked line, ported from t3herd `prompts/_no_questions.md`. */
36
+ export const BLOCKED_LINE = `Nobody reads your thread until you finish: never ask a question and never wait for an answer. When you cannot finish, leave the finished work in place, say what is left, and end with STATUS: blocked — <reason>: <why>, where <reason> is one of: ${[
37
+ "needs_user (only the user can decide; <why> is the question)",
38
+ "too_hard (you tried and cannot solve it; a stronger model should)",
39
+ "provider_error (your model, tools or sandbox failed)",
40
+ "no_access (you cannot reach what you need: the running app, a service, credentials)",
41
+ "check_broken (the check cannot pass for a reason outside your task; name each failing file:line)",
42
+ "out_of_scope (the task needs changes outside the files you own)",
43
+ ].join(", ")}.`;
44
+
45
+ const ANSI = new RegExp(`${String.fromCharCode(27)}\\[[0-9;]*m`, "g");
46
+
47
+ /** An `Owns:` entry as a path: cut at the first glob, trailing slashes dropped. */
48
+ const pathOf = (entry: string): string => entry.replace(/[*?[{].*$/, "").replace(/\/+$/, "");
49
+
50
+ /**
51
+ * The sibling `Owns:` entries a failed check blames, or [] when it blames none, or also blames one of the lane's
52
+ * own. Output names a file by its tail (`pkg/x.go:3`, `src/a.tsx(4,2)`), so a sibling's entry counts from two
53
+ * path segments, and an own entry already from its base name (t3herd `foreignFailure`).
54
+ */
55
+ export function foreignFailure(log: string, own: string[], siblings: string[]): string[] {
56
+ const lines = log
57
+ .replace(ANSI, "")
58
+ .split("\n")
59
+ .filter((l) => !/^\s*(✓|ok\s|PASS\b)/.test(l));
60
+ const named = (p: string, min: number): boolean => {
61
+ const parts = p.split("/");
62
+ for (let i = 0; i <= parts.length - min; i++) {
63
+ const tail = parts.slice(i).join("/");
64
+ if (lines.some((l) => l.includes(tail))) return true;
65
+ }
66
+ return false;
67
+ };
68
+ const mine = own.map(pathOf).filter(Boolean);
69
+ if (mine.some((f) => named(f, 1))) return [];
70
+ const theirs = siblings.map(pathOf).filter((f) => f && !mine.includes(f));
71
+ return [...new Set(theirs.filter((f) => named(f, Math.min(2, f.split("/").length))))];
72
+ }
@@ -0,0 +1,51 @@
1
+ import { headerLine } from "./lane.ts";
2
+ import { slug } from "./util.ts";
3
+ import { BaseRefSchema } from "./workspace.ts";
4
+
5
+ /** The run title as a folder and branch stem: at most 40 characters, `run` when nothing is left. */
6
+ export const runSlug = (title: string): string => slug(title).slice(0, 40) || "run";
7
+
8
+ /** A milestone's branch: the run's slug and the milestone, lower case (`wallet-split-m1`). */
9
+ export const milestoneBranch = (title: string, m: string): string => `${runSlug(title)}-${m.toLowerCase()}`;
10
+
11
+ /** A branch on origin as `run_start`'s base: a plain name, never a remote ref (`origin/…`) or a full ref. */
12
+ export const BaseBranchSchema = BaseRefSchema.refine(
13
+ (s) => !s.startsWith("origin/") && !s.startsWith("refs/"),
14
+ );
15
+
16
+ /**
17
+ * The `Setup:` command in the lines right under plan.md's `# ` title, up to the first blank line or heading
18
+ * (backticks stripped); null without one. A blank line after the title ends the header, so body prose never counts.
19
+ */
20
+ export function planSetup(planText: string): string | null {
21
+ const lines = planText.split("\n");
22
+ const start = lines.findIndex((l) => /^#\s+/.test(l)) + 1;
23
+ let end = start;
24
+ while (end < lines.length && (lines[end] ?? "").trim() !== "" && !(lines[end] ?? "").startsWith("#")) end++;
25
+ const re = headerLine("setup");
26
+ for (const line of lines.slice(start, end)) {
27
+ const m = re.exec(line);
28
+ if (m)
29
+ return (
30
+ (m[1] ?? "")
31
+ .trim()
32
+ .replace(/^`+|`+$/g, "")
33
+ .trim() || null
34
+ );
35
+ }
36
+ return null;
37
+ }
38
+
39
+ /** Whether the owner's answer to a landed milestone's question is the word that has catherd push its branch. */
40
+ export const isPushAnswer = (answer: string): boolean => answer.trim().toLowerCase() === "push";
41
+
42
+ /** The command that opens a milestone's MR, for the origin's host: gh on github.com, else glab. */
43
+ export function mrCommand(o: { originUrl: string; base: string; branch: string; body: string }): string {
44
+ return /(^|[@/.])github\.com[:/]/.test(o.originUrl)
45
+ ? `gh pr create --base ${o.base} --head ${o.branch} --body-file ${o.body}`
46
+ : `glab mr create --source-branch ${o.branch} --target-branch ${o.base} --description-file ${o.body}`;
47
+ }
48
+
49
+ /** The milestone a unit (`run`, `M1`, `M1.L2`) belongs to; null for the run itself. */
50
+ export const unitMilestone = (unit: string): string | null =>
51
+ unit === "run" ? null : (unit.split(".")[0] ?? null);
@@ -0,0 +1,106 @@
1
+ import { replyContract, roleSections } from "./role-prompts.ts";
2
+ import { ROLE_MCP_SERVER, roleMcpTools } from "./role-tools.ts";
3
+ import type { Role } from "./roles.ts";
4
+
5
+ /** What admission adds to a role's brief, besides its reply contract (spec 1.5 plan 21). */
6
+ export interface BriefContext {
7
+ run: string;
8
+ name: string;
9
+ role: Role;
10
+ /** the lane file as it stands at admission, header and body */
11
+ lane: { id: string; text: string } | null;
12
+ /** the role's scratch folder, its TMPDIR, on a backend that grants it */
13
+ scratch: string | null;
14
+ /** whether the role's harness lists the catherd_role tools */
15
+ roleServer: boolean;
16
+ /** the catherd version whose `lock` a worker's text names */
17
+ version: string;
18
+ /** the plan's preamble, which applies to every task (plan 32); null when the run has none for this role */
19
+ context: string | null;
20
+ }
21
+
22
+ /** The CLI form of each role server tool, which any role can run from its shell. */
23
+ function cliForm(tool: string, run: string): string {
24
+ switch (tool) {
25
+ case "read_run_file":
26
+ return `catherd run-file read ${run} <path>`;
27
+ case "write_run_file":
28
+ return `catherd run-file write ${run} <path> (the content on stdin)`;
29
+ case "read_knowledge":
30
+ return "catherd knowledge show";
31
+ case "gate_check":
32
+ return `catherd gate check ${run} --milestone <M> --item <item> --command <command> --paths <path,…>`;
33
+ case "gate_pass":
34
+ return `catherd gate pass ${run} --item <item> --command <command> --paths <path,…> --evidence <evidence>`;
35
+ case "gate_run":
36
+ return `catherd gate run ${run} --milestone <M> --command <command> [--at <base checkout>]`;
37
+ default:
38
+ return tool;
39
+ }
40
+ }
41
+
42
+ const LANE_OPEN = (id: string) => `<lane-file path="lanes/${id}.md">`;
43
+ const LANE_CLOSE = "</lane-file>";
44
+
45
+ /**
46
+ * The lane file inlined (spec 1.5 plan 21): an isolated headless worker cannot read the run folder, so its Owns,
47
+ * fast check and body travel in the brief itself.
48
+ */
49
+ export function laneBlock(id: string, text: string): string {
50
+ return [
51
+ `Your lane file, lanes/${id}.md, as it stood when you were dispatched:`,
52
+ LANE_OPEN(id),
53
+ text.trimEnd(),
54
+ LANE_CLOSE,
55
+ ].join("\n");
56
+ }
57
+
58
+ /** Who the role is, where its temp files go, and how it reaches its run's files and gate. */
59
+ export function roleNotes(c: BriefContext): string {
60
+ const tools = roleMcpTools(c.role);
61
+ const lines = [`catherd: you are ${c.name} of run ${c.run}.`];
62
+ if (c.scratch)
63
+ lines.push(
64
+ `Your scratch folder is ${c.scratch}, which is your $TMPDIR: put temporary files, logs and builds there, never in /tmp. catherd removes it with the run, so anything the orchestrator must keep goes in your reply.`,
65
+ );
66
+ const cli = tools.map((t) => cliForm(t, c.run)).join("; ");
67
+ lines.push(
68
+ c.roleServer
69
+ ? `Your catherd tools: ${tools.map((t) => `mcp__${ROLE_MCP_SERVER}__${t}`).join(", ")}. If they are not listed, run the same from your shell: ${cli}.`
70
+ : `Your catherd commands, from your shell: ${cli}.`,
71
+ );
72
+ return lines.join("\n");
73
+ }
74
+
75
+ const TASK_OPEN = "<task>\n";
76
+ const TASK_CLOSE = "\n</task>";
77
+
78
+ /**
79
+ * The brief admission writes: the role's sections around its task (the daemon's text, the lane file and the
80
+ * role's notes), each once. A failover stand-in that reruns an earlier brief gets the lane file as it stands now and
81
+ * its own notes, never two copies.
82
+ */
83
+ export function composeBrief(brief: string, c: BriefContext): string {
84
+ const contract = replyContract(c.role);
85
+ let body = brief.trimEnd();
86
+ const open = body.indexOf(TASK_OPEN);
87
+ const close = body.lastIndexOf(TASK_CLOSE);
88
+ if (open >= 0 && close > open) body = body.slice(open + TASK_OPEN.length, close).trim();
89
+ // a 1.5-era brief rerun ends with its contract
90
+ if (body.endsWith(contract)) body = body.slice(0, -contract.length).trimEnd();
91
+ if (c.lane) {
92
+ const open = LANE_OPEN(c.lane.id);
93
+ const start = body.lastIndexOf(open);
94
+ const end = body.indexOf(LANE_CLOSE, start);
95
+ if (start >= 0 && end >= 0) {
96
+ const lead = body.lastIndexOf("\n\n", start);
97
+ body = `${body.slice(0, lead >= 0 ? lead : start)}${body.slice(end + LANE_CLOSE.length)}`.trimEnd();
98
+ }
99
+ }
100
+ const notesStart = body.lastIndexOf("\n\ncatherd: you are ");
101
+ if (notesStart >= 0) body = body.slice(0, notesStart).trimEnd();
102
+ const task = [body, ...(c.lane ? [laneBlock(c.lane.id, c.lane.text)] : []), roleNotes(c)]
103
+ .filter(Boolean)
104
+ .join("\n\n");
105
+ return `${roleSections(c.role, c.version, { context: c.context, task })}\n`;
106
+ }