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,10 +1,12 @@
1
1
  import type { OrchestrationHost } from "./host.ts";
2
2
  import { z } from "zod";
3
3
  import type { Budget } from "./budget.ts";
4
+ import type { Bars } from "./bars.ts";
4
5
  import { BILLING_KEYS, type BillingKey } from "./catalog.ts";
5
6
  import { BILLING_MODES, type BillingMode, DEFAULT_BILLING } from "./cost.ts";
6
7
  import { CatherdError } from "./errors.ts";
7
- import { ADAPTER_IDS, parseRung, tryParseRung } from "./ids.ts";
8
+ import { ADAPTER_IDS, EFFORT_ORDER, type EffortWord, effortRank, parseRung, tryParseRung } from "./ids.ts";
9
+ import { DIFFICULTIES } from "./lane.ts";
8
10
  import { ACCESS, type Access } from "./record.ts";
9
11
  import { DEFAULT_ACCESS, ROLES, type Role } from "./roles.ts";
10
12
  import { isPlain, slug } from "./util.ts";
@@ -45,6 +47,9 @@ const RoleDocSchema = z.looseObject({
45
47
  rungs: z.array(z.string()).optional(),
46
48
  defaultRung: z.string().optional(),
47
49
  network: z.boolean().optional(),
50
+ timeouts: z.looseObject({ idleMin: positive.optional(), wallMin: positive.optional() }).optional(),
51
+ // plan 33: unknown, so a hand edit never makes the file unreadable; resolveProfile reads what it can
52
+ bars: z.unknown().optional(),
48
53
  });
49
54
 
50
55
  export const ProfileDocSchema = z.looseObject({
@@ -56,6 +61,9 @@ export const ProfileDocSchema = z.looseObject({
56
61
  roles: z.record(z.string(), RoleDocSchema).optional(),
57
62
  harness: z.record(z.string(), z.looseObject({ isolated: z.boolean().optional() })).optional(),
58
63
  failover: z.record(z.string(), z.string()).optional(),
64
+ // unknown values, like `bars`: an entry that is no effort word never makes the file unreadable
65
+ maxEffort: z.record(z.string(), z.unknown()).optional(),
66
+ bars: z.unknown().optional(),
59
67
  budget: z
60
68
  .looseObject({ minutes: positive.optional(), tokens: positive.optional(), usd: positive.optional() })
61
69
  .optional(),
@@ -65,9 +73,20 @@ export const ProfileDocSchema = z.looseObject({
65
73
  .looseObject({ heavy: z.union([z.number().int().positive(), z.literal("cpus/2")]).optional() })
66
74
  .optional(),
67
75
  notify: z.array(z.string()).optional(),
76
+ // plan 35: unknown, so a hand edit never makes the file unreadable; resolveProfile reads what it can
77
+ limits: z.unknown().optional(),
68
78
  });
69
79
  export type ProfileDoc = z.infer<typeof ProfileDocSchema>;
70
80
 
81
+ /** plan 35: the profile's memory numbers for starting milestones side by side (megabytes). */
82
+ const MEMORY_KEYS = ["reserveMb", "perMilestoneMb", "floorMb"] as const;
83
+ type MemoryKey = (typeof MEMORY_KEYS)[number];
84
+ const positiveInt = (v: unknown): v is number => typeof v === "number" && Number.isInteger(v) && v > 0;
85
+ const storedMemory = (doc: ProfileDoc): Record<string, unknown> => {
86
+ const memory = isPlain(doc.limits) ? doc.limits.memory : undefined;
87
+ return isPlain(memory) ? memory : {};
88
+ };
89
+
71
90
  const OBJECTIVES = ["cost", "speed"] as const;
72
91
  const JEV_USES = ["auto", "off"] as const;
73
92
  const known = <T extends string>(values: readonly T[], v: string | undefined): v is T =>
@@ -85,6 +104,16 @@ const STORED_FALLBACK = {
85
104
  billing: (key: string): BillingMode => DEFAULT_BILLING[key as BillingKey] ?? "metered",
86
105
  } as const;
87
106
 
107
+ const barOk = (k: string, v: unknown): boolean =>
108
+ (DIFFICULTIES as readonly string[]).includes(k) && typeof v === "number" && v >= 0 && v <= 100;
109
+
110
+ const isEffort = (v: unknown): v is EffortWord => typeof v === "string" && effortRank(v) >= 0;
111
+
112
+ /** The entries of a stored `bars` this catherd can read; anything else is not a bar. */
113
+ function readBars(v: unknown): Bars {
114
+ return isPlain(v) ? (Object.fromEntries(Object.entries(v).filter((e) => barOk(e[0], e[1]))) as Bars) : {};
115
+ }
116
+
88
117
  /** A stored value this catherd does not know, with what it is read as. */
89
118
  export interface UnknownValue {
90
119
  path: string;
@@ -102,9 +131,25 @@ export function unknownValues(doc: ProfileDoc): UnknownValue[] {
102
131
  check("jev.use", JEV_USES, doc.jev?.use, STORED_FALLBACK.jevUse);
103
132
  for (const [k, v] of Object.entries(doc.billing ?? {}))
104
133
  check(`billing.${k}`, BILLING_MODES, v, STORED_FALLBACK.billing(k));
134
+ for (const [k, v] of Object.entries(doc.maxEffort ?? {}))
135
+ if (!isEffort(v)) {
136
+ const value = typeof v === "string" ? v : JSON.stringify(v);
137
+ out.push({ path: `maxEffort.${k}`, value, readAs: "no ceiling" });
138
+ }
139
+ const checkBars = (path: string, v: unknown, readAs: string) => {
140
+ if (v === undefined) return;
141
+ if (!isPlain(v)) return void out.push({ path, value: JSON.stringify(v), readAs });
142
+ for (const [k, x] of Object.entries(v))
143
+ if (!barOk(k, x)) out.push({ path: `${path}.${k}`, value: String(x), readAs });
144
+ };
145
+ checkBars("bars", doc.bars, "the shipped percentile");
146
+ for (const role of ROLES) checkBars(`roles.${role}.bars`, doc.roles?.[role]?.bars, "the profile's bar");
105
147
  for (const role of ROLES)
106
148
  check(`roles.${role}.access`, ACCESS, doc.roles?.[role]?.access, STORED_FALLBACK.access);
107
149
  for (const n of doc.notify ?? []) check("notify", NOTIFY, n, "skipped");
150
+ for (const [k, v] of Object.entries(storedMemory(doc)))
151
+ if (!(MEMORY_KEYS as readonly string[]).includes(k) || !positiveInt(v))
152
+ out.push({ path: `limits.memory.${k}`, value: JSON.stringify(v), readAs: "the default" });
108
153
  return out;
109
154
  }
110
155
 
@@ -117,8 +162,21 @@ export type RoleConfig = {
117
162
  defaultRung?: string;
118
163
  /** spec §5: false drops a workspace-write role's network, loopback and Docker grants; absent means granted */
119
164
  network?: false;
165
+ /** plan 23: this role's own idle and wall timeouts, over the profile's `timeouts` (a long gate, a slow model) */
166
+ timeouts?: { idleMin?: number; wallMin?: number };
167
+ /** plan 33: this role's own bar percentiles, over the profile's `bars` (decision 11) */
168
+ bars?: Bars;
120
169
  };
121
170
 
171
+ /** The idle and wall timeouts a role runs under: its own `timeouts`, else the profile's. */
172
+ export const roleTimeouts = (
173
+ profile: { timeouts: { idleMin: number; wallMin: number } },
174
+ role: { timeouts?: { idleMin?: number; wallMin?: number } } | undefined,
175
+ ): { idleMin: number; wallMin: number } => ({
176
+ idleMin: role?.timeouts?.idleMin ?? profile.timeouts.idleMin,
177
+ wallMin: role?.timeouts?.wallMin ?? profile.timeouts.wallMin,
178
+ });
179
+
122
180
  /** A profile with every default filled in: what the run engine, the CLI and the agent files read. */
123
181
  export interface Profile {
124
182
  name: string;
@@ -128,11 +186,17 @@ export interface Profile {
128
186
  roles: Record<Role, RoleConfig>;
129
187
  harness: Record<string, { isolated: boolean }>;
130
188
  failover: Record<string, string>;
189
+ /** plan 33: `<backend>:<model>` → the highest effort routing starts it at; a model not named has no ceiling */
190
+ maxEffort: Record<string, EffortWord>;
191
+ /** plan 33: bar percentiles per difficulty, over the shipped ones (`percentilesOf`) */
192
+ bars: Bars;
131
193
  budget: Budget;
132
194
  timeouts: { idleMin: number; wallMin: number };
133
195
  preflight: { confirm: boolean };
134
196
  lock: { heavy: number | "cpus/2" };
135
197
  notify: NotifyMoment[];
198
+ /** plan 35: only the memory numbers the profile sets; the defaults are `MEMORY_LIMITS` */
199
+ limits: { memory: Partial<Record<MemoryKey, number>> };
136
200
  }
137
201
 
138
202
  const LUNA_HIGH = "codex:gpt-6-luna#high";
@@ -156,14 +220,15 @@ export const BUILTIN_ROLES: Record<Role, RoleConfig> = {
156
220
  artist: { enabled: true, access: DEFAULT_ACCESS.artist, rungs: [SOL("medium")] },
157
221
  writer: { enabled: true, access: DEFAULT_ACCESS.writer, rungs: [LUNA_HIGH] },
158
222
  researcher: { enabled: true, access: DEFAULT_ACCESS.researcher, rungs: [LUNA_HIGH] },
223
+ judge: { enabled: true, access: DEFAULT_ACCESS.judge, rungs: ["claude-code:claude-opus-5-5#medium"] },
159
224
  };
160
225
 
161
226
  /**
162
- * Spec 1.1 §11: each shipped worker rung fails over to the best stand-in the shipped catalog offers
163
- * (`rankStandIns` over `catalogRungs`, pinned by test/domain/failover.test.ts): on another quota, paid
164
- * from a plan, and no downgrade on the dims the rung's bars use. Go serves GPT-6 Luna itself; Kimi K3
165
- * borrows Sol medium's scores (a shipped treat-like). Sol high and xhigh have no such stand-in on the
166
- * owner's plans, so they have none: a limit on them pauses the lane instead of dropping it a tier.
227
+ * Spec 1.1 §11: each shipped worker rung fails over to the best stand-in the Slopalytics index offers
228
+ * (`rankStandIns` over `catalogPairs`, pinned by test/domain/failover.test.ts): on another quota, paid from a
229
+ * plan, and at least as intelligent. Go serves GPT-6 Luna itself; Kimi K3 stands on its own index, above Sol
230
+ * medium. Sol high and xhigh have no such stand-in on the owner's plans, so they have none: a limit on them
231
+ * pauses the lane instead of dropping it a tier.
167
232
  */
168
233
  export const DEFAULT_FAILOVER: Record<string, string> = {
169
234
  [LUNA_HIGH]: "opencode:opencode-go/gpt-6-luna#high",
@@ -171,63 +236,22 @@ export const DEFAULT_FAILOVER: Record<string, string> = {
171
236
  };
172
237
 
173
238
  /**
174
- * Cursor's live slug and efforts for a paired family (`cursor-agent models`, 2026-10-01). Empty efforts means the
175
- * bare slug.
239
+ * Plan 33 Ruling 8: t3herd's effort caps under the shipped catalog's ids, on every backend that lists the model.
240
+ * The default profile writes them, so a failover to any backend can never start a `#max` Opus; a profile without
241
+ * the key has no ceiling.
176
242
  */
177
- const CURSOR_PAIR: Record<string, { id: string; efforts: readonly string[] }> = {
178
- "grok-4.7": { id: "grok-4.7", efforts: ["low", "medium", "high", "xhigh"] },
179
- "grok-4.6": { id: "cursor-grok-4.6", efforts: ["low", "medium", "high", "xhigh"] },
180
- "grok-4.5": { id: "cursor-grok-4.5", efforts: ["low", "medium", "high"] },
181
- "gemini-3.8-flash": { id: "gemini-3.8-flash", efforts: ["low", "medium", "high"] },
182
- "gemini-3.7-flash": { id: "gemini-3.7-flash", efforts: ["low", "medium", "high"] },
183
- "gemini-3.6-flash": { id: "gemini-3.6-flash", efforts: ["minimal", "low", "medium", "high"] },
184
- "gemini-3.1-pro": { id: "gemini-3.1-pro", efforts: [] },
243
+ export const DEFAULT_MAX_EFFORT: Readonly<Record<string, EffortWord>> = {
244
+ "claude:claude-opus-5-5": "high",
245
+ "claude-code:claude-opus-5-5": "high",
246
+ "opencode:opencode/claude-opus-5-5": "high",
247
+ "cursor:claude-opus-5-5": "high",
248
+ "claude:claude-sonnet-5-5": "high",
249
+ "claude-code:claude-sonnet-5-5": "high",
250
+ "opencode:opencode/claude-sonnet-5-5": "high",
251
+ "codex:gpt-6.1-sol": "xhigh",
185
252
  };
186
253
 
187
- /** The Cursor effort a paired rung can actually run: the same one when listed, else `high`, else the bare slug. */
188
- function cursorPairEffort(model: string, effort: string): string {
189
- const list = CURSOR_PAIR[model]?.efforts ?? [];
190
- if (list.length === 0) return "default";
191
- if (effort !== "default" && list.includes(effort)) return effort;
192
- return list.includes("high") ? "high" : (list[0] as string);
193
- }
194
-
195
- /**
196
- * Spec 1.3 §7.3: the same model through two backends bills two pools, so each stands in for the other on a
197
- * limit, at the same effort where both list it. Otherwise the stand-in runs at `high`: Grok 4.5 `xhigh` at Cursor
198
- * `high`, Cursor's Gemini 3.6 `minimal` at agy `high`; Gemini 3.1 Pro's bare Cursor slug pairs with agy `high`.
199
- * Grok and Gemini are paired with Cursor. Consulted after the profile's own failover and never written into a
200
- * profile, where a pair whose rung is on no ladder would warn "never runs"; never a stand-in for a shipped Codex,
201
- * Claude or opencode rung.
202
- */
203
- export const PAIRED_FAILOVER: Record<string, string> = Object.fromEntries(
204
- (
205
- [
206
- ["grok", ["grok-4.7", "grok-4.6", "grok-4.5"], ["low", "medium", "high", "xhigh"]],
207
- [
208
- "antigravity",
209
- ["gemini-3.8-flash", "gemini-3.7-flash", "gemini-3.6-flash"],
210
- ["low", "medium", "high"],
211
- ],
212
- ["antigravity", ["gemini-3.1-pro"], ["low", "high"]],
213
- ] as const
214
- ).flatMap(([backend, models, efforts]) =>
215
- models.flatMap((m) => {
216
- const cursor = CURSOR_PAIR[m] as { id: string; efforts: readonly string[] };
217
- const back = ["default", ...efforts].map(
218
- (e) => [`${backend}:${m}#${e}`, `cursor:${cursor.id}#${cursorPairEffort(m, e)}`] as const,
219
- );
220
- // `#default` too, as on the other side: a Cursor rung with no effort flag fails over the same way
221
- const fore = [...new Set(["default", ...cursor.efforts])].map((e) => {
222
- const there = e !== "default" && (efforts as readonly string[]).includes(e) ? e : "high";
223
- return [`cursor:${cursor.id}#${e}`, `${backend}:${m}#${there}`] as const;
224
- });
225
- return [...back, ...fore];
226
- }),
227
- ),
228
- );
229
-
230
- /** The five billing keys spec §7.1 writes out; cursor and grok arrive with their backends. */
254
+ /** The five billing keys spec §7.1 writes out; the rest default. */
231
255
  const WRITTEN_BILLING: BillingKey[] = ["codex", "claude", "claude-code", "opencode-go", "opencode"];
232
256
 
233
257
  /** Stored defaults for init/new; architect/verifier rungs resolve from the current host. */
@@ -245,6 +269,7 @@ export function defaultProfileDoc(name = "default"): ProfileDoc {
245
269
  opencode: { isolated: false },
246
270
  },
247
271
  failover: { ...DEFAULT_FAILOVER },
272
+ maxEffort: { ...DEFAULT_MAX_EFFORT },
248
273
  budget: {},
249
274
  timeouts: { idleMin: 15, wallMin: 90 },
250
275
  preflight: { confirm: false },
@@ -253,10 +278,10 @@ export function defaultProfileDoc(name = "default"): ProfileDoc {
253
278
  };
254
279
  }
255
280
 
256
- /** Remove only the four architect/verifier execution choices, preserving other stored fields. */
281
+ /** Remove only the architect, verifier and judge execution choices, preserving other stored fields. */
257
282
  export function hostDefaultsDoc(doc: ProfileDoc): ProfileDoc {
258
283
  const next = structuredClone(doc);
259
- for (const role of ["architect", "verifier"] as const) {
284
+ for (const role of ["architect", "verifier", "judge"] as const) {
260
285
  const r = next.roles?.[role];
261
286
  if (r) {
262
287
  delete r.rungs;
@@ -285,6 +310,9 @@ export function resolveProfile(doc: ProfileDoc, name: string, host: Orchestratio
285
310
  ? []
286
311
  : b.rungs;
287
312
  }
313
+ // the judge is Opus by default; on a Codex host it follows the host like the architect and the verifier do
314
+ if (role === "judge" && d?.rungs === undefined && host === "codex")
315
+ omitted = ["codex:gpt-6.1-sol#medium"];
288
316
  // a role that lists its own rungs never inherits the built-in default rung, which may not be among them
289
317
  const defaultRung = d?.defaultRung ?? (d?.rungs === undefined ? b.defaultRung : undefined);
290
318
  roles[role] = {
@@ -298,6 +326,15 @@ export function resolveProfile(doc: ProfileDoc, name: string, host: Orchestratio
298
326
  rungs: [...(d?.rungs ?? omitted)],
299
327
  ...(defaultRung ? { defaultRung } : {}),
300
328
  ...(d?.network === false ? { network: false as const } : {}),
329
+ ...(d?.timeouts?.idleMin !== undefined || d?.timeouts?.wallMin !== undefined
330
+ ? {
331
+ timeouts: {
332
+ ...(d.timeouts.idleMin !== undefined ? { idleMin: d.timeouts.idleMin } : {}),
333
+ ...(d.timeouts.wallMin !== undefined ? { wallMin: d.timeouts.wallMin } : {}),
334
+ },
335
+ }
336
+ : {}),
337
+ ...(Object.keys(readBars(d?.bars)).length > 0 ? { bars: readBars(d?.bars) } : {}),
301
338
  };
302
339
  }
303
340
  const harness: Profile["harness"] = {};
@@ -320,11 +357,23 @@ export function resolveProfile(doc: ProfileDoc, name: string, host: Orchestratio
320
357
  roles,
321
358
  harness,
322
359
  failover: { ...doc.failover },
360
+ maxEffort: Object.fromEntries(
361
+ Object.entries(doc.maxEffort ?? {}).filter((e): e is [string, EffortWord] => isEffort(e[1])),
362
+ ),
363
+ bars: readBars(doc.bars),
323
364
  budget,
324
365
  timeouts: { idleMin: doc.timeouts?.idleMin ?? 15, wallMin: doc.timeouts?.wallMin ?? 90 },
325
366
  preflight: { confirm: doc.preflight?.confirm ?? false },
326
367
  lock: { heavy: doc.lock?.heavy ?? "cpus/2" },
327
368
  notify: doc.notify === undefined ? [...NOTIFY] : doc.notify.filter((n) => known(NOTIFY, n)),
369
+ limits: {
370
+ memory: Object.fromEntries(
371
+ MEMORY_KEYS.flatMap((k) => {
372
+ const v = storedMemory(doc)[k];
373
+ return positiveInt(v) ? [[k, v]] : [];
374
+ }),
375
+ ),
376
+ },
328
377
  };
329
378
  }
330
379
 
@@ -337,6 +386,10 @@ export function agentName(profile: string, role: Role, rung: string): string {
337
386
  // Patches: what profile_set and `catherd profile set` accept. Strict at every level, so a misspelt key is
338
387
  // refused with E_INPUT_INVALID instead of dropped (plan-2 review m9). `null` removes a map entry or a nullable
339
388
  // field (a role's `network` among them: absent means granted).
389
+ const BarsPatchSchema = z
390
+ .partialRecord(z.enum(DIFFICULTIES), z.number().min(0).max(100).nullable())
391
+ .nullable();
392
+
340
393
  const RolePatchSchema = z
341
394
  .strictObject({
342
395
  enabled: z.boolean(),
@@ -344,6 +397,11 @@ const RolePatchSchema = z
344
397
  rungs: z.array(RungSchema),
345
398
  defaultRung: RungSchema.nullable(),
346
399
  network: z.boolean().nullable(),
400
+ timeouts: z
401
+ .strictObject({ idleMin: positive.nullable(), wallMin: positive.nullable() })
402
+ .partial()
403
+ .nullable(),
404
+ bars: BarsPatchSchema,
347
405
  })
348
406
  .partial();
349
407
 
@@ -354,6 +412,13 @@ export const ProfilePatchSchema = z.strictObject({
354
412
  roles: z.partialRecord(z.enum(ROLES), RolePatchSchema).optional(),
355
413
  harness: z.partialRecord(z.enum(HARNESS_KEYS), z.strictObject({ isolated: z.boolean() })).optional(),
356
414
  failover: z.record(RungSchema, RungSchema.nullable()).optional(),
415
+ maxEffort: z
416
+ .record(
417
+ z.string().refine((k) => tryParseRung(`${k}#x`) !== null, "a key is <backend>:<model>"),
418
+ z.enum(EFFORT_ORDER).nullable(),
419
+ )
420
+ .optional(),
421
+ bars: BarsPatchSchema.optional(),
357
422
  budget: z
358
423
  .strictObject({ minutes: positive.nullable(), tokens: positive.nullable(), usd: positive.nullable() })
359
424
  .partial()
@@ -362,6 +427,18 @@ export const ProfilePatchSchema = z.strictObject({
362
427
  preflight: z.strictObject({ confirm: z.boolean() }).optional(),
363
428
  lock: z.strictObject({ heavy: z.union([z.number().int().positive(), z.literal("cpus/2")]) }).optional(),
364
429
  notify: z.array(z.enum(NOTIFY)).optional(),
430
+ limits: z
431
+ .strictObject({
432
+ memory: z
433
+ .strictObject({
434
+ reserveMb: z.number().int().positive().nullable(),
435
+ perMilestoneMb: z.number().int().positive().nullable(),
436
+ floorMb: z.number().int().positive().nullable(),
437
+ })
438
+ .partial(),
439
+ })
440
+ .partial()
441
+ .optional(),
365
442
  });
366
443
  export type ProfilePatch = z.infer<typeof ProfilePatchSchema>;
367
444
 
@@ -382,7 +459,7 @@ export function applyPatch(doc: ProfileDoc, patch: ProfilePatch): ProfileDoc {
382
459
  }
383
460
 
384
461
  /** Map fields whose keys are rungs or billing keys, which may hold dots: the rest of the path is one key. */
385
- const MAP_FIELDS = new Set(["failover", "billing"]);
462
+ const MAP_FIELDS = new Set(["failover", "billing", "maxEffort"]);
386
463
  const LIST_FIELDS = (keys: string[]) =>
387
464
  (keys[0] === "roles" && keys[2] === "rungs") || (keys[0] === "notify" && keys.length === 1);
388
465
 
@@ -3,10 +3,11 @@ import { z } from "zod";
3
3
  export const ACCESS = ["read-only", "workspace-write", "full"] as const;
4
4
  export type Access = (typeof ACCESS)[number];
5
5
 
6
- const RUN_STATUSES = ["ok", "failed", "limit", "cli-too-old", "timeout", "cancelled"] as const;
6
+ export const RUN_STATUSES = ["ok", "failed", "limit", "cli-too-old", "timeout", "cancelled"] as const;
7
7
  export type RunStatus = (typeof RUN_STATUSES)[number];
8
8
 
9
- const REPLY_STATUSES = ["complete", "partial", "blocked", "refused"] as const;
9
+ /** plan 23: `flaky`, a worker's word for a check that failed, then passed when run alone */
10
+ export const REPLY_STATUSES = ["complete", "partial", "blocked", "refused", "flaky"] as const;
10
11
  export type ReplyStatus = (typeof REPLY_STATUSES)[number];
11
12
 
12
13
  export interface Tokens {
@@ -27,6 +28,8 @@ export const EXIT_REASONS = [
27
28
  "wall-timeout",
28
29
  "cancelled",
29
30
  "lost",
31
+ /** plan 23: the provider kept failing (retries with no progress); failover treats it as a usage limit */
32
+ "provider-unavailable",
30
33
  ] as const;
31
34
  export type ExitReason = (typeof EXIT_REASONS)[number];
32
35
  export interface ExitInfo {
@@ -65,6 +68,19 @@ export const RunRecordSchema = z.looseObject({
65
68
  gitUnavailable: z.boolean().optional(),
66
69
  replyStatus: z.enum(REPLY_STATUSES).nullable(),
67
70
  replyWhy: z.string().nullable(),
71
+ /** plan 23: the reply's ENV: line, when the environment stopped the role; absent otherwise */
72
+ environment: z.string().optional(),
73
+ /** a verifier's `HEAD: <sha>` line: the commit it checked (lower-case hex, 7-64 characters) */
74
+ head: z
75
+ .string()
76
+ .regex(/^[0-9a-f]{7,64}$/)
77
+ .optional(),
78
+ /** a worker's `EXTRA: <path> — <why>` lines: files outside its lane it had to change */
79
+ extras: z.array(z.object({ path: z.string(), why: z.string() })).optional(),
80
+ /** a fix-round worker's `REJECTED: <finding> — <why>` lines */
81
+ rejected: z.array(z.object({ finding: z.string(), why: z.string() })).optional(),
82
+ /** a worker's `DESIGN:` lines: where its plan task conflicts with the code */
83
+ design: z.array(z.string()).optional(),
68
84
  threadHeavy: z.boolean(),
69
85
  access: z.enum(ACCESS),
70
86
  isolated: z.boolean(),
@@ -79,7 +95,16 @@ export const RunRecordSchema = z.looseObject({
79
95
  });
80
96
  export type RunRecord = z.infer<typeof RunRecordSchema>;
81
97
 
82
- const STATUS_LINE = /^STATUS:\s*(complete|partial|blocked|refused)\s*(?:—|–|-)\s*(.*)$/;
98
+ const STATUS_LINE = /^STATUS:\s*(complete|partial|blocked|refused|flaky)\s*(?:—|–|-)\s*(.*)$/;
99
+
100
+ /**
101
+ * Plan 23: what stopped the role, when the environment did: an `ENV: <what>` line of its reply (a VPN, no
102
+ * Docker, a dead registry); null without one.
103
+ */
104
+ export function parseReplyEnvironment(reply: string): string | null {
105
+ const m = /^[ \t]*ENV:[ \t]*(\S.*?)[ \t]*$/m.exec(reply);
106
+ return m ? (m[1] as string) : null;
107
+ }
83
108
 
84
109
  /** The role's claim about its own work: the reply's last line, `STATUS: <s> — <why>`. */
85
110
  export function parseReplyStatus(reply: string): { status: ReplyStatus | null; why: string | null } {
@@ -87,3 +112,43 @@ export function parseReplyStatus(reply: string): { status: ReplyStatus | null; w
87
112
  const m = STATUS_LINE.exec(last);
88
113
  return m ? { status: m[1] as ReplyStatus, why: (m[2] ?? "").trim() } : { status: null, why: null };
89
114
  }
115
+
116
+ /** The last `HEAD: <hex>` line of a reply outside code fences, lower-cased: the commit a verifier says it checked. */
117
+ export function parseReplyHead(reply: string): string | null {
118
+ let fenced = false;
119
+ let head: string | null = null;
120
+ for (const line of reply.split("\n")) {
121
+ if (/^\s*(```|~~~)/.test(line)) fenced = !fenced;
122
+ else if (!fenced) {
123
+ const m = /^[ \t]*HEAD:[ \t]*([0-9a-fA-F]{7,64})[ \t]*$/.exec(line);
124
+ if (m) head = (m[1] as string).toLowerCase();
125
+ }
126
+ }
127
+ return head;
128
+ }
129
+
130
+ /** `<LABEL>: <a> — <b>` lines (optional bullet; the separator is `—`, `–` or `-` with whitespace around it). */
131
+ function labelled(reply: string, label: string): [string, string][] {
132
+ const re = new RegExp(
133
+ `^[ \\t]*(?:[-*][ \\t]*)?${label}:[ \\t]*(\\S.*?)(?:[ \\t]+(?:—|–|-)[ \\t]+(.*?))?[ \\t]*$`,
134
+ "gm",
135
+ );
136
+ return [...reply.matchAll(re)].map((m) => [m[1] as string, m[2] ?? ""]);
137
+ }
138
+
139
+ /** Every `EXTRA: <path> — <why>` line: a file outside the lane the worker had to change. */
140
+ export function parseReplyExtras(reply: string): { path: string; why: string }[] {
141
+ return labelled(reply, "EXTRA").map(([path, why]) => ({ path: path.replace(/`/g, ""), why }));
142
+ }
143
+
144
+ /** Every `REJECTED: <finding> — <why>` line of a fix round. */
145
+ export function parseReplyRejections(reply: string): { finding: string; why: string }[] {
146
+ return labelled(reply, "REJECTED").map(([finding, why]) => ({ finding, why }));
147
+ }
148
+
149
+ /** The trimmed value of every `DESIGN:` line: a conflict between a plan task and the code. */
150
+ export function parseReplyDesign(reply: string): string[] {
151
+ return [...reply.matchAll(/^[ \t]*(?:[-*][ \t]*)?DESIGN:[ \t]*(\S.*?)[ \t]*$/gm)].map(
152
+ (m) => m[1] as string,
153
+ );
154
+ }
@@ -0,0 +1,29 @@
1
+ import { parseReplyStatus } from "./record.ts";
2
+
3
+ /**
4
+ * Plan 22: a final reply never overwrites the report. `finals` are the messages that ended each of the run's
5
+ * turns, in order (a background command's notification can start another turn after the report); `last` is the
6
+ * reply the CLI kept, the last of them. With more than one, the reply is the longest STATUS-bearing message, the
7
+ * later ones after it under `later:`, and the report's STATUS line last, so the record's STATUS is the report's.
8
+ * Otherwise `last` is the reply, unchanged.
9
+ */
10
+ export function composeReply(finals: string[], last: string): string {
11
+ if (finals.length < 2) return last;
12
+ let report = -1;
13
+ for (const [i, f] of finals.entries())
14
+ if (
15
+ parseReplyStatus(f).status !== null &&
16
+ (report < 0 || f.trim().length >= finals[report]!.trim().length)
17
+ )
18
+ report = i;
19
+ if (report < 0 || report === finals.length - 1) return last;
20
+ const lines = finals[report]!.trimEnd().split("\n");
21
+ const status = lines.pop() as string;
22
+ const later = finals
23
+ .slice(report + 1)
24
+ .map((f) => f.trim())
25
+ .filter(Boolean);
26
+ if (!later.length) return last;
27
+ const body = lines.join("\n").trimEnd();
28
+ return [...(body ? [body, ""] : []), "later:", later.join("\n\n"), "", status.trim(), ""].join("\n");
29
+ }