catherd-cli 1.5.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 (275) hide show
  1. package/README.md +164 -208
  2. package/THIRD_PARTY_NOTICES.md +217 -35
  3. package/catalog/jev.json +27 -1
  4. package/catalog/models.json +56 -247
  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 -421
  11. package/plugin/skills/catherd-setup/SKILL.md +20 -19
  12. package/src/adapters/all.ts +0 -4
  13. package/src/adapters/backend.ts +1 -2
  14. package/src/adapters/cursor/home.ts +1 -1
  15. package/src/adapters/cursor/index.ts +4 -4
  16. package/src/cli.ts +22 -27
  17. package/src/domain/bars.ts +33 -107
  18. package/src/domain/blocked.ts +72 -0
  19. package/src/domain/branch.ts +51 -0
  20. package/src/domain/brief.ts +21 -6
  21. package/src/domain/catalog.ts +69 -324
  22. package/src/domain/cost.ts +15 -30
  23. package/src/domain/errors.ts +2 -2
  24. package/src/domain/fail-lines.ts +26 -0
  25. package/src/domain/failover.ts +37 -87
  26. package/src/domain/findings.ts +47 -0
  27. package/src/domain/gate-brief.ts +1 -0
  28. package/src/domain/ids.ts +13 -2
  29. package/src/domain/jev.ts +47 -2
  30. package/src/domain/judge.ts +124 -0
  31. package/src/domain/lane.ts +22 -3
  32. package/src/domain/milestone-memory.ts +53 -0
  33. package/src/domain/notice.ts +25 -8
  34. package/src/domain/notify.ts +32 -0
  35. package/src/domain/plan-doc.ts +198 -0
  36. package/src/domain/plan-lint.ts +141 -0
  37. package/src/domain/profile-rules.ts +101 -160
  38. package/src/domain/profile.ts +116 -63
  39. package/src/domain/record.ts +53 -2
  40. package/src/domain/role-prompts.ts +131 -45
  41. package/src/domain/role-scope.ts +4 -34
  42. package/src/domain/role-tools.ts +2 -2
  43. package/src/domain/roles.ts +2 -0
  44. package/src/domain/route.ts +17 -0
  45. package/src/domain/rules.ts +33 -0
  46. package/src/domain/run-events.ts +298 -0
  47. package/src/domain/run-machine.ts +668 -0
  48. package/src/domain/run-state.ts +1310 -0
  49. package/src/domain/scorecard.ts +197 -0
  50. package/src/domain/select.ts +151 -146
  51. package/src/domain/slopalytics.ts +72 -0
  52. package/src/entry/answer-command.ts +39 -0
  53. package/src/entry/catalog-command.ts +25 -174
  54. package/src/entry/check-bin.ts +17 -0
  55. package/src/entry/cli-kit.ts +1 -1
  56. package/src/entry/daemon-bin.ts +22 -0
  57. package/src/entry/glyphs.ts +15 -2
  58. package/src/entry/init-command.ts +18 -68
  59. package/src/entry/knowledge-command.ts +45 -4
  60. package/src/entry/lock-command.ts +6 -7
  61. package/src/entry/mcp/role-server.ts +18 -0
  62. package/src/entry/mcp/server.ts +13 -40
  63. package/src/entry/mcp/session-tools.ts +149 -0
  64. package/src/entry/panel/openapi.ts +189 -0
  65. package/src/entry/panel/server.ts +410 -0
  66. package/src/entry/panel-command.ts +68 -0
  67. package/src/entry/profile-command.ts +10 -14
  68. package/src/entry/role-cli-command.ts +47 -3
  69. package/src/entry/runs-command.ts +3 -19
  70. package/src/entry/scorecard-command.ts +187 -0
  71. package/src/entry/supervise-bin.ts +1 -1
  72. package/src/infra/env.ts +0 -2
  73. package/src/infra/git.ts +64 -0
  74. package/src/infra/heavy-lock.ts +45 -7
  75. package/src/infra/launch.ts +63 -7
  76. package/src/infra/memory.ts +45 -0
  77. package/src/infra/ntfy.ts +40 -0
  78. package/src/infra/open-url.ts +42 -0
  79. package/src/infra/paths.ts +2 -2
  80. package/src/infra/slopalytics.ts +30 -0
  81. package/src/infra/store.ts +6 -1
  82. package/src/services/admission.ts +24 -9
  83. package/src/services/backends.ts +4 -6
  84. package/src/services/capture.ts +1 -22
  85. package/src/services/catalog-service.ts +155 -262
  86. package/src/services/credentials.ts +4 -20
  87. package/src/services/daemon.ts +181 -0
  88. package/src/services/dispatch-service.ts +5 -1
  89. package/src/services/doctor-backends.ts +1 -1
  90. package/src/services/doctor-checks.ts +47 -0
  91. package/src/services/doctor.ts +4 -9
  92. package/src/services/failover-rungs.ts +109 -0
  93. package/src/services/finalize.ts +12 -0
  94. package/src/services/gate-env.ts +1 -2
  95. package/src/services/gate-service.ts +21 -6
  96. package/src/services/jev-service.ts +2 -4
  97. package/src/services/lane-service.ts +34 -3
  98. package/src/services/milestone-check.ts +433 -0
  99. package/src/services/milestone-tree.ts +170 -0
  100. package/src/services/milestones.ts +35 -8
  101. package/src/services/notifier.ts +69 -2
  102. package/src/services/notify-service.ts +26 -0
  103. package/src/services/panel-setup.ts +116 -0
  104. package/src/services/plan-input.ts +151 -0
  105. package/src/services/ports.ts +30 -4
  106. package/src/services/preflight.ts +82 -8
  107. package/src/services/profile-service.ts +30 -3
  108. package/src/services/profile-store.ts +8 -3
  109. package/src/services/protocol.ts +4 -0
  110. package/src/services/provenance.ts +31 -90
  111. package/src/services/questions.ts +9 -0
  112. package/src/services/reconcile.ts +11 -0
  113. package/src/services/routing-service.ts +26 -15
  114. package/src/services/run-briefs.ts +477 -0
  115. package/src/services/run-client.ts +217 -0
  116. package/src/services/run-effects.ts +837 -0
  117. package/src/services/run-events-store.ts +174 -0
  118. package/src/services/run-evidence.ts +1 -1
  119. package/src/services/run-read.ts +221 -0
  120. package/src/services/run-report.ts +176 -0
  121. package/src/services/run-service.ts +55 -6
  122. package/src/services/run-store.ts +54 -7
  123. package/src/services/scorecard-metrics.ts +86 -0
  124. package/src/services/scorecard.ts +382 -0
  125. package/src/services/summary.ts +27 -3
  126. package/src/services/tree-path.ts +27 -0
  127. package/src/services/workspace-admission.ts +5 -13
  128. package/src/services/workspace-service.ts +5 -0
  129. package/web/dist/assets/Brand-PKlUX5TE.js +1 -0
  130. package/web/dist/assets/Brand-PKlUX5TE.js.br +0 -0
  131. package/web/dist/assets/Brand-PKlUX5TE.js.gz +0 -0
  132. package/web/dist/assets/Modal-Dxc5so53.js +1 -0
  133. package/web/dist/assets/Modal-Dxc5so53.js.br +0 -0
  134. package/web/dist/assets/Modal-Dxc5so53.js.gz +0 -0
  135. package/web/dist/assets/Pressable-C8-T6Ed_.js +1 -0
  136. package/web/dist/assets/StatusChip-B5xfI_Mn.js +153 -0
  137. package/web/dist/assets/StatusChip-B5xfI_Mn.js.br +0 -0
  138. package/web/dist/assets/StatusChip-B5xfI_Mn.js.gz +0 -0
  139. package/web/dist/assets/_app-Ea5cpyKe.js +1 -0
  140. package/web/dist/assets/_app-Ea5cpyKe.js.br +0 -0
  141. package/web/dist/assets/_app-Ea5cpyKe.js.gz +0 -0
  142. package/web/dist/assets/_runId-pTG0fXVo.js +1 -0
  143. package/web/dist/assets/_runId-pTG0fXVo.js.br +0 -0
  144. package/web/dist/assets/_runId-pTG0fXVo.js.gz +0 -0
  145. package/web/dist/assets/drawer-Du3cdlok.js +1 -0
  146. package/web/dist/assets/drawer-Du3cdlok.js.br +0 -0
  147. package/web/dist/assets/drawer-Du3cdlok.js.gz +0 -0
  148. package/web/dist/assets/geist-cyrillic-ext-wght-normal-DjL33-gN.woff2 +0 -0
  149. package/web/dist/assets/geist-cyrillic-wght-normal-BEAKL7Jp.woff2 +0 -0
  150. package/web/dist/assets/geist-latin-ext-wght-normal-DC-KSUi6.woff2 +0 -0
  151. package/web/dist/assets/geist-latin-wght-normal-BgDaEnEv.woff2 +0 -0
  152. package/web/dist/assets/geist-mono-cyrillic-ext-wght-normal-X_5orZeX.woff2 +0 -0
  153. package/web/dist/assets/geist-mono-cyrillic-wght-normal-DiZS0aHC.woff2 +0 -0
  154. package/web/dist/assets/geist-mono-latin-ext-wght-normal-Bwz-egvJ.woff2 +0 -0
  155. package/web/dist/assets/geist-mono-latin-wght-normal-XN7g48iV.woff2 +0 -0
  156. package/web/dist/assets/geist-mono-symbols2-wght-normal-CO5SzqOn.woff2 +0 -0
  157. package/web/dist/assets/geist-mono-vietnamese-wght-normal-DadHysG0.woff2 +0 -0
  158. package/web/dist/assets/geist-vietnamese-wght-normal-6IgcOCM7.woff2 +0 -0
  159. package/web/dist/assets/index-B6GiwDkh.js +11 -0
  160. package/web/dist/assets/index-B6GiwDkh.js.br +0 -0
  161. package/web/dist/assets/index-B6GiwDkh.js.gz +0 -0
  162. package/web/dist/assets/index-C5IbylvM.css +2 -0
  163. package/web/dist/assets/index-C5IbylvM.css.br +0 -0
  164. package/web/dist/assets/index-C5IbylvM.css.gz +0 -0
  165. package/web/dist/assets/label-DFSYBc62.js +1 -0
  166. package/web/dist/assets/label-DFSYBc62.js.br +0 -0
  167. package/web/dist/assets/label-DFSYBc62.js.gz +0 -0
  168. package/web/dist/assets/link-D8EcKZUt.js +1 -0
  169. package/web/dist/assets/link-D8EcKZUt.js.br +0 -0
  170. package/web/dist/assets/link-D8EcKZUt.js.gz +0 -0
  171. package/web/dist/assets/login-DaN5HPPW.js +1 -0
  172. package/web/dist/assets/login-DaN5HPPW.js.br +0 -0
  173. package/web/dist/assets/login-DaN5HPPW.js.gz +0 -0
  174. package/web/dist/assets/query-Cu--ccT3.js +1 -0
  175. package/web/dist/assets/questions-shoO9OVj.js +1 -0
  176. package/web/dist/assets/questions-shoO9OVj.js.br +0 -0
  177. package/web/dist/assets/questions-shoO9OVj.js.gz +0 -0
  178. package/web/dist/assets/react-query.gen-Bu2yV-qJ.js +12 -0
  179. package/web/dist/assets/react-query.gen-Bu2yV-qJ.js.br +0 -0
  180. package/web/dist/assets/react-query.gen-Bu2yV-qJ.js.gz +0 -0
  181. package/web/dist/assets/rolldown-runtime-XKyaHtl9.js +1 -0
  182. package/web/dist/assets/run-status-C0DRuV7U.js +1 -0
  183. package/web/dist/assets/runs-C3rg2zZc.js +1 -0
  184. package/web/dist/assets/runs-C3rg2zZc.js.br +0 -0
  185. package/web/dist/assets/runs-C3rg2zZc.js.gz +0 -0
  186. package/web/dist/assets/setup-5I4Vl9fR.js +1 -0
  187. package/web/dist/assets/setup-5I4Vl9fR.js.br +0 -0
  188. package/web/dist/assets/setup-5I4Vl9fR.js.gz +0 -0
  189. package/web/dist/assets/table-BOxXhqS4.js +1 -0
  190. package/web/dist/assets/table-BOxXhqS4.js.br +0 -0
  191. package/web/dist/assets/table-BOxXhqS4.js.gz +0 -0
  192. package/web/dist/assets/textfield-BzRFkxBF.js +1 -0
  193. package/web/dist/assets/textfield-BzRFkxBF.js.br +0 -0
  194. package/web/dist/assets/textfield-BzRFkxBF.js.gz +0 -0
  195. package/web/dist/assets/time--9kSQbbp.js +1 -0
  196. package/web/dist/assets/use-css-variable-CpnN9WtA.js +5 -0
  197. package/web/dist/assets/use-css-variable-CpnN9WtA.js.br +0 -0
  198. package/web/dist/assets/use-css-variable-CpnN9WtA.js.gz +0 -0
  199. package/web/dist/assets/useMutation-CVPKlR34.js +1 -0
  200. package/web/dist/assets/useMutation-CVPKlR34.js.br +0 -0
  201. package/web/dist/assets/useMutation-CVPKlR34.js.gz +0 -0
  202. package/web/dist/index.html +15 -0
  203. package/catalog/ATTRIBUTION.md +0 -44
  204. package/catalog/scores.json +0 -3202
  205. package/catalog/sources.json +0 -97
  206. package/src/adapters/antigravity/events.ts +0 -131
  207. package/src/adapters/antigravity/home.ts +0 -46
  208. package/src/adapters/antigravity/index.ts +0 -335
  209. package/src/adapters/antigravity/models.ts +0 -37
  210. package/src/adapters/grok/events.ts +0 -112
  211. package/src/adapters/grok/home.ts +0 -187
  212. package/src/adapters/grok/index.ts +0 -358
  213. package/src/adapters/grok/models.ts +0 -48
  214. package/src/domain/calibration.ts +0 -132
  215. package/src/domain/sources.ts +0 -144
  216. package/src/entry/mcp/dispatch-tools.ts +0 -52
  217. package/src/entry/mcp/lane-tools.ts +0 -131
  218. package/src/entry/mcp/protocol-tools.ts +0 -63
  219. package/src/entry/mcp/push-tools.ts +0 -16
  220. package/src/entry/mcp/run-tools.ts +0 -164
  221. package/src/entry/mcp/setup-tools.ts +0 -109
  222. package/src/entry/mcp/workspace-tools.ts +0 -142
  223. package/src/entry/tui/commands.ts +0 -563
  224. package/src/entry/tui/effects.ts +0 -385
  225. package/src/entry/tui/fixtures.ts +0 -637
  226. package/src/entry/tui/profile-edits.ts +0 -146
  227. package/src/entry/tui/profile-tree.ts +0 -429
  228. package/src/entry/tui/providers/app.tsx +0 -162
  229. package/src/entry/tui/providers/boundary.tsx +0 -57
  230. package/src/entry/tui/providers/data.tsx +0 -160
  231. package/src/entry/tui/providers/keymap.tsx +0 -166
  232. package/src/entry/tui/providers/root.tsx +0 -45
  233. package/src/entry/tui/providers/theme.tsx +0 -17
  234. package/src/entry/tui/providers/toast.tsx +0 -77
  235. package/src/entry/tui/run.tsx +0 -114
  236. package/src/entry/tui/state.ts +0 -400
  237. package/src/entry/tui/stories.ts +0 -114
  238. package/src/entry/tui/text.ts +0 -130
  239. package/src/entry/tui/theme.ts +0 -140
  240. package/src/entry/tui/views/app.tsx +0 -208
  241. package/src/entry/tui/views/dialogs.tsx +0 -41
  242. package/src/entry/tui/views/profile-actions.ts +0 -331
  243. package/src/entry/tui/views/profiles.tsx +0 -520
  244. package/src/entry/tui/views/runs.tsx +0 -619
  245. package/src/entry/tui/views/save-dialog.tsx +0 -344
  246. package/src/entry/tui/views/status.tsx +0 -213
  247. package/src/entry/tui/widgets/chrome.tsx +0 -114
  248. package/src/entry/tui/widgets/dialog-confirm.tsx +0 -58
  249. package/src/entry/tui/widgets/dialog-prompt.tsx +0 -52
  250. package/src/entry/tui/widgets/dialog-select.tsx +0 -212
  251. package/src/entry/tui/widgets/dialog.tsx +0 -68
  252. package/src/entry/tui/widgets/line.tsx +0 -92
  253. package/src/entry/tui/widgets/list.tsx +0 -197
  254. package/src/entry/tui/widgets/spinner.tsx +0 -49
  255. package/src/entry/tui/widgets/toast.tsx +0 -62
  256. package/src/infra/sources/arena.ts +0 -95
  257. package/src/infra/sources/artificial-analysis.ts +0 -119
  258. package/src/infra/sources/cache.ts +0 -126
  259. package/src/infra/sources/csv.ts +0 -38
  260. package/src/infra/sources/epoch.ts +0 -82
  261. package/src/infra/sources/http.ts +0 -75
  262. package/src/infra/sources/litellm.ts +0 -58
  263. package/src/infra/sources/models-dev.ts +0 -59
  264. package/src/infra/sources/openrouter-endpoints.ts +0 -52
  265. package/src/infra/sources/openrouter-models.ts +0 -60
  266. package/src/infra/sources/rows.ts +0 -38
  267. package/src/infra/sources/vectara.ts +0 -69
  268. package/src/infra/sources/zip.ts +0 -48
  269. package/src/services/catalog-refresh.ts +0 -201
  270. package/src/services/doctor-sources.ts +0 -76
  271. package/src/services/runs-page.ts +0 -290
  272. package/src/services/source-derive.ts +0 -397
  273. package/src/services/source-sync.ts +0 -312
  274. package/src/services/standins.ts +0 -238
  275. package/src/services/treat-likes.ts +0 -113
@@ -0,0 +1,26 @@
1
+ const ANSI = new RegExp(`${String.fromCharCode(27)}\\[[0-9;]*m`, "g");
2
+
3
+ /**
4
+ * The lines of a check's output that name a failure, stripped of colour, timings and counters so two runs compare
5
+ * equal: at most 60 lines of at most 300 characters (t3herd `failLines`).
6
+ */
7
+ export function checkFailLines(out: string): string[] {
8
+ const seen = new Set<string>();
9
+ for (const raw of out.split("\n")) {
10
+ const line = raw
11
+ .replace(ANSI, "")
12
+ .replace(/\s*[[(]\s*\d+(\.\d+)?\s*(ms|s|m)\s*[\])]/g, "")
13
+ .replace(/\b\d+(\.\d+)?(ms|s)\b/g, "")
14
+ .trim();
15
+ const vuln = line.match(/^Vulnerability #\d+: (GO-\d{4}-\d+)/)?.[1];
16
+ if (vuln) seen.add(`vulnerability ${vuln}`);
17
+ else if (/(^|\s)(FAIL|FAILED|✗|✕|×)(\s|:|$)|^\(fail\)|^--- FAIL|^not ok\b|\bpanic:|^error:/.test(line))
18
+ seen.add(line.slice(0, 300));
19
+ if (seen.size >= 60) break;
20
+ }
21
+ return [...seen];
22
+ }
23
+
24
+ /** The lines of `fails` the base does not have; all of them when there is no base record. */
25
+ export const newFails = (fails: string[], base: string[] | null): string[] =>
26
+ fails.filter((f) => !base?.includes(f));
@@ -1,8 +1,6 @@
1
- import { billingKeyOf, type Catalog, DIMS, type Dim, rungInfo, scoresOf } from "./catalog.ts";
1
+ import { billingKeyOf, type Catalog, pairOf, rungInfo } from "./catalog.ts";
2
2
  import { type BillingMode, compareCost, type Cost, costOf, DEFAULT_BILLING } from "./cost.ts";
3
3
  import { type Rung, tryParseRung } from "./ids.ts";
4
- import { DIFFICULTIES, KINDS } from "./lane.ts";
5
- import { EFFORT_ORDER } from "./sources.ts";
6
4
 
7
5
  /**
8
6
  * Spec §7.1 and §4.5: a stand-in on the same quota would be out of quota too. The billing key names the
@@ -16,74 +14,35 @@ export function claudeBilled(rung: string): boolean {
16
14
  return r !== null && quotaOf(r) === "claude-code";
17
15
  }
18
16
 
19
- /** A rung's scores by dim, its own or borrowed through a treat-like; null when unscored or not a rung. */
20
- export function valuesOf(c: Catalog, rung: string): Partial<Record<Dim, number>> | null {
21
- try {
22
- return scoresOf(c, rungInfo(c, rung).canonical)?.values ?? null;
23
- } catch {
24
- return null;
25
- }
26
- }
17
+ /** A rung's intelligence index; null when it is not a rung or not indexed. */
18
+ export const intelligenceOf = (c: Catalog, rung: string): number | null =>
19
+ pairOf(c, rung)?.intelligence ?? null;
27
20
 
28
- /** The dims used by the routing bars (kind × difficulty) the rung clears: what makes it fit its lanes. */
29
- export function barDims(c: Catalog, rung: string): Dim[] {
30
- const v = valuesOf(c, rung);
31
- if (!v) return [];
32
- const used = new Set<Dim>();
33
- for (const kind of KINDS)
34
- for (const d of DIFFICULTIES) {
35
- const bar = Object.entries(c.bars[kind][d]).filter(([, min]) => min !== undefined) as [Dim, number][];
36
- if (bar.every(([dim, min]) => (v[dim] ?? Number.NEGATIVE_INFINITY) >= min))
37
- for (const [dim] of bar) used.add(dim);
38
- }
39
- return DIMS.filter((d) => used.has(d));
21
+ /** Spec 1.1 §11: `standIn` is a downgrade when the rung is indexed and the stand-in is not, or is less intelligent. */
22
+ export function downgrade(c: Catalog, rung: string, standIn: string): boolean {
23
+ const a = intelligenceOf(c, rung);
24
+ const b = intelligenceOf(c, standIn);
25
+ return a !== null && (b === null || b < a);
40
26
  }
41
27
 
42
- /**
43
- * Spec 1.1 §11: the dims, among those the rung's bars use, on which `standIn` scores below `rung` (a
44
- * missing score counts as below). Empty means the stand-in clears every bar the rung clears, at least as
45
- * well: no downgrade.
46
- */
47
- export function downgradeDims(c: Catalog, rung: string, standIn: string): Dim[] {
48
- return downgradeAgainst(valuesOf(c, rung), barDims(c, rung), valuesOf(c, standIn));
49
- }
50
-
51
- /** downgradeDims with the rung's values and bar dims computed once, for a pool of stand-ins. */
52
- function downgradeAgainst(
53
- a: Partial<Record<Dim, number>> | null,
54
- dims: Dim[],
55
- standIn: Partial<Record<Dim, number>> | null,
56
- ): Dim[] {
57
- if (!a) return [];
58
- const b = standIn ?? {};
59
- return dims.filter((d) => (b[d] ?? Number.NEGATIVE_INFINITY) < (a[d] as number));
60
- }
61
-
62
- /**
63
- * Spec 1.1 §11 "the ladder goes down": the dims both rungs are scored on where `upper` (the later rung)
64
- * scores below `lower`, when it scores above it on none of them. Empty when it does not go down.
65
- */
66
- export function ladderDropDims(c: Catalog, lower: string, upper: string): Dim[] {
67
- const a = valuesOf(c, lower);
68
- const b = valuesOf(c, upper);
69
- if (!a || !b) return [];
70
- const shared = DIMS.filter((d) => a[d] !== undefined && b[d] !== undefined);
71
- if (shared.some((d) => (b[d] as number) > (a[d] as number))) return [];
72
- return shared.filter((d) => (b[d] as number) < (a[d] as number));
28
+ /** Spec 1.1 §11 "the ladder goes down": the later rung (`upper`) is less intelligent than `lower`. */
29
+ export function ladderDrop(c: Catalog, lower: string, upper: string): boolean {
30
+ const a = intelligenceOf(c, lower);
31
+ const b = intelligenceOf(c, upper);
32
+ return a !== null && b !== null && b < a;
73
33
  }
74
34
 
75
35
  const costFor = (c: Catalog, billing: Partial<Record<string, BillingMode>>, rung: string): Cost => {
76
36
  const info = rungInfo(c, rung);
77
- return costOf(info.family, info.parsed.effort, billing[info.key] ?? DEFAULT_BILLING[info.key]);
37
+ return costOf(info.family, info.pair, info.parsed.effort, billing[info.key] ?? DEFAULT_BILLING[info.key]);
78
38
  };
79
39
 
80
40
  /**
81
41
  * Spec 1.1 §11: the rungs of `pool` that can stand in for `rung`, best first. A stand-in is on another
82
- * quota, scored, paid from a plan or subscription under `billing` (a metered one spends money nobody chose
83
- * to), startable by `dispatch` (not a native `claude:` subagent), and no downgrade on the rung's bar dims.
84
- * Claude-billed stand-ins rank last, then the effort nearest the rung's own (a model's efforts carry one
85
- * another's values as `adjacent`, spec 1.2 §4.3, so the cheapest would otherwise be its lowest effort; plan 14
86
- * Ruling 4), then the cheapest.
42
+ * quota, indexed, paid from a plan or subscription under `billing` (a metered one spends money nobody chose
43
+ * to), startable by `dispatch` (not a native `claude:` subagent), and never less intelligent than the rung
44
+ * (a rung with no index of its own keeps every stand-in). Claude-billed stand-ins rank last, then the
45
+ * nearest intelligence above the rung's own, then the cheapest.
87
46
  */
88
47
  export function rankStandIns(
89
48
  c: Catalog,
@@ -93,17 +52,15 @@ export function rankStandIns(
93
52
  ): string[] {
94
53
  const from = tryParseRung(rung);
95
54
  if (!from) return [];
96
- // the rung's own values and bar dims, once for the whole pool
97
- const own = valuesOf(c, rung);
98
- const dims = barDims(c, rung);
55
+ const own = intelligenceOf(c, rung);
99
56
  const fits = [...new Set(pool)].filter((x) => {
100
57
  const r = tryParseRung(x);
101
- const values = r ? valuesOf(c, x) : null;
102
- if (!r || r.backend === "claude" || quotaOf(r) === quotaOf(from) || !values) return false;
58
+ const i = r ? intelligenceOf(c, x) : null;
59
+ if (!r || r.backend === "claude" || quotaOf(r) === quotaOf(from) || i === null) return false;
103
60
  if (costFor(c, billing, x).tier === 1) return false;
104
- return downgradeAgainst(own, dims, values).length === 0;
61
+ return own === null || i >= own;
105
62
  });
106
- const gap = (x: string) => effortGap(from.effort, tryParseRung(x)?.effort ?? "");
63
+ const gap = (x: string) => (own === null ? 0 : (intelligenceOf(c, x) as number) - own);
107
64
  return fits.sort(
108
65
  (a, b) =>
109
66
  Number(claudeBilled(a)) - Number(claudeBilled(b)) ||
@@ -113,35 +70,28 @@ export function rankStandIns(
113
70
  );
114
71
  }
115
72
 
116
- /** How many effort steps apart two efforts are; an effort that is no effort word (`default`) is far from all. */
117
- function effortGap(a: string, b: string): number {
118
- const i = (EFFORT_ORDER as readonly string[]).indexOf(a);
119
- const j = (EFFORT_ORDER as readonly string[]).indexOf(b);
120
- if (a === b) return 0;
121
- return i < 0 || j < 0 ? EFFORT_ORDER.length : Math.abs(i - j);
122
- }
123
-
124
- /** How a family's `on` key or a model id's prefix maps to the backend that runs it. */
73
+ /** How a family's `on` key maps to the backend that runs it. */
125
74
  const BACKEND_OF_KEY: Record<string, string> = { "opencode-go": "opencode" };
126
75
 
127
76
  /**
128
- * Every rung the catalog knows how to name: each shipped family's model on each backend at each effort (at
129
- * `#default` when it has none),
130
- * each listed model at each effort, and each treat-like whose model is an opencode id (`opencode-go/…`,
131
- * `opencode/…`), such as the shipped Kimi K3 stand-in. Sorted, without duplicates.
77
+ * Every indexed rung catherd can name: each shipped family's model on each backend at each effort the index
78
+ * (or the backend's listing) gives it, and each listed model at each listed effort. Sorted, without duplicates.
132
79
  */
133
- export function catalogRungs(c: Catalog): string[] {
80
+ export function catalogPairs(c: Catalog): string[] {
134
81
  const out = new Set<string>();
82
+ const add = (backend: string, model: string, efforts: readonly string[]) => {
83
+ for (const e of [...efforts, "default"]) {
84
+ const rung = `${backend}:${model}#${e}`;
85
+ if (pairOf(c, rung)) out.add(rung);
86
+ }
87
+ };
135
88
  for (const f of c.families)
136
89
  for (const [key, m] of Object.entries(f.on)) {
137
90
  if (!m) continue;
138
- // a model with no effort has one rung, #default (Claude Code's Haiku, Cursor's Composer; spec 1.3 §7.1)
139
- for (const e of m.efforts.length ? m.efforts : ["default"])
140
- out.add(`${BACKEND_OF_KEY[key] ?? key}:${m.id}#${e}`);
91
+ const backend = BACKEND_OF_KEY[key] ?? key;
92
+ add(backend, m.id, rungInfo(c, `${backend}:${m.id}#default`).efforts);
141
93
  }
142
94
  for (const [backend, l] of Object.entries(c.listed))
143
- for (const m of l.models) for (const e of m.efforts) out.add(`${backend}:${m.id}#${e}`);
144
- for (const canonical of Object.keys(c.treatLike))
145
- if (/^opencode(-go)?\//.test(canonical)) out.add(`opencode:${canonical}`);
95
+ for (const m of l.models) add(backend, m.id, m.efforts);
146
96
  return [...out].sort();
147
97
  }
@@ -0,0 +1,47 @@
1
+ import type { Finding } from "./run-events.ts";
2
+
3
+ const LINE = /^\s*(?:[-*]\s+|\d+[.)]\s+)?\**(BLOCKER|BUG|NIT)\b\**\s*:?\s*(.*)$/;
4
+ /** What a reviewer writes where it has nothing to report: `BUG: none`, `NIT n/a`, a lone dash. */
5
+ const NOTHING = /^(?:none|n\/a|nil|no findings?|clean|[-—–]+)\.?\**$/i;
6
+ const PATH = /^[`'"(]*((?:\.{0,2}\/)?[\w@~.+-]+(?:\/[\w@~.+-]+)*)(?::\d+(?:[-:]\d+)?)?[`'"),;:]*$/;
7
+
8
+ /**
9
+ * The first `path:line` or `path` token of a finding's first words (a reviewer writes
10
+ * `BLOCKER file:line — problem — fix`), or null: a path has a `/` or an extension.
11
+ */
12
+ export function fileOf(text: string): string | null {
13
+ for (const tok of text.trim().split(/\s+/).slice(0, 4)) {
14
+ if (/^[—–-]+$/.test(tok)) return null;
15
+ const p = PATH.exec(tok)?.[1];
16
+ if (p && (p.includes("/") || /\.[A-Za-z]\w*$/.test(p))) return p;
17
+ }
18
+ return null;
19
+ }
20
+
21
+ /** A reviewer's reply as findings, in order: its BLOCKER, BUG and NIT lines, as written; "none" lines left out. */
22
+ export function parseFindings(reply: string): Finding[] {
23
+ const out: Finding[] = [];
24
+ for (const line of reply.split("\n")) {
25
+ const m = LINE.exec(line);
26
+ if (!m) continue;
27
+ const rest = (m[2] ?? "").trim();
28
+ if (!rest || NOTHING.test(rest)) continue;
29
+ out.push({ label: m[1] as Finding["label"], text: line.trim(), file: fileOf(rest) });
30
+ }
31
+ return out;
32
+ }
33
+
34
+ /** The lines a failing verifier lists below its VERDICT line, as BLOCKERs: what the next fix round starts from. */
35
+ export function parseFailLines(reply: string, max = 12): Finding[] {
36
+ const lines = reply.split("\n").map((l) => l.trim());
37
+ const at = lines.findIndex((l) => l !== "");
38
+ return lines
39
+ .slice(at + 1)
40
+ .filter((l) => l !== "" && !/^STATUS:/i.test(l))
41
+ .slice(0, max)
42
+ .map((l) => ({
43
+ label: "BLOCKER" as const,
44
+ text: l.replace(/^(?:[-*]\s+|\d+[.)]\s+)/, ""),
45
+ file: fileOf(l.replace(/^(?:[-*]\s+|\d+[.)]\s+)/, "").replace(/^\**FAIL\**:?\s*/i, "")),
46
+ }));
47
+ }
@@ -8,6 +8,7 @@ export const RECHECK_COMMAND_MIN = 10;
8
8
  /** The rules of the gate, one paragraph each. */
9
9
  export const VERIFIER_GATE_RULES = [
10
10
  "Run the root gate (the milestone's full check) as one gate item and each per-service acceptance suite as its own item, never as one command: a failure then names its item, and a re-check reruns only that item. Acceptance that builds from HEAD (git archive, a commit's image) is yours: workers do not commit, so they never run it.",
11
+ "Run the milestone's full check with gate_run (or catherd gate run), on the committed HEAD, never by hand: catherd runs it detached under the verifier lock, reruns a failure with new lines once, scans the run's commits for secrets and records the result, and land reads that record. If it answers running, call it again. When it fails and no baseline exists, it says how to record the base's own failures from a checkout of the base.",
11
12
  "Stay in the foreground until your verdict: no background watcher, and never end your turn while a command runs.",
12
13
  `When the machine, not the work, stops an item (a VPN filter, a Docker client proxy, a dead registry, no DNS), probe it directly, twice, 5 s apart, and if it still fails, reply VERDICT: BLOCKED: environment — <the probe and its output>. That is a blocker for the owner, not a FAIL.`,
13
14
  "Last, when you built Docker images, run docker image prune -f, so the gates of later milestones do not fill the disk.",
package/src/domain/ids.ts CHANGED
@@ -11,8 +11,8 @@ export function assertId(kind: string, value: string): string {
11
11
  return value;
12
12
  }
13
13
 
14
- /** The backends that run as a process through a `BackendAdapter` (spec 1.3: cursor, grok and antigravity). */
15
- export const ADAPTER_IDS = ["codex", "claude-code", "opencode", "cursor", "grok", "antigravity"] as const;
14
+ /** The backends that run as a process through a `BackendAdapter`. */
15
+ export const ADAPTER_IDS = ["codex", "claude-code", "opencode", "cursor"] as const;
16
16
 
17
17
  /** `claude` is the native Claude Code subagent path (no process); the rest are adapters. */
18
18
  const RUNG_BACKENDS = [...ADAPTER_IDS, "claude"] as const;
@@ -56,8 +56,19 @@ export function tryParseRung(s: string): Rung | null {
56
56
  }
57
57
  }
58
58
 
59
+ /** Reasoning efforts from least to most; a rung's effort that is none of these (`default`) cannot be ranked. */
60
+ export const EFFORT_ORDER = ["none", "minimal", "low", "medium", "high", "xhigh", "max", "ultra"] as const;
61
+ export type EffortWord = (typeof EFFORT_ORDER)[number];
62
+
63
+ /** Index in `EFFORT_ORDER`; -1 when `e` is not an effort word. */
64
+ export const effortRank = (e: string): number => (EFFORT_ORDER as readonly string[]).indexOf(e);
65
+
59
66
  export const formatRung = (r: Rung): string => `${r.backend}:${r.model}#${r.effort}`;
60
67
 
68
+ /** Spec 2.0 decision 12: the daemon runs every role headless, so a native `claude:` rung runs as `claude-code:`. */
69
+ export const headlessRung = (rung: string): string =>
70
+ rung.startsWith("claude:") ? `claude-code:${rung.slice("claude:".length)}` : rung;
71
+
61
72
  const B32 = "0123456789ABCDEFGHJKMNPQRSTVWXYZ";
62
73
 
63
74
  let lastTime = -1;
package/src/domain/jev.ts CHANGED
@@ -1,4 +1,5 @@
1
1
  import { z } from "zod";
2
+ import { BLOCKED_REASONS } from "./blocked.ts";
2
3
  import { type Difficulty, KINDS, type Kind, parseLaneHeader } from "./lane.ts";
3
4
  import { scrubKeyShapes, scrubSecretAssignments } from "./secrets.ts";
4
5
 
@@ -29,6 +30,12 @@ const RouteRuleSchema = z.object({
29
30
  /** exactly two levels each, in order: copy then build, logic then hard */
30
31
  trackA: z.array(z.string()).length(2),
31
32
  trackB: z.array(z.string()).length(2),
33
+ /** a noul answer at or above this is a yes */
34
+ noulMin: z.number().min(0).max(1),
35
+ /** a yes to any of these lifts the lane to at least logic */
36
+ floorLogic: z.array(z.string()),
37
+ /** a yes to any of these holds the lane at build; the floor wins */
38
+ capBuild: z.array(z.string()),
32
39
  });
33
40
  export type RouteRule = z.infer<typeof RouteRuleSchema>;
34
41
  const VerdictRuleSchema = z.object({ min: z.number().min(0).max(1), fallback: z.string() });
@@ -38,9 +45,16 @@ export const JevFileSchema = z.looseObject({
38
45
  schema: z.literal(1),
39
46
  model: z.string(),
40
47
  sets: z.object({
41
- "route-v2": z.object({ questions: z.record(z.string(), QuestionSchema), rule: RouteRuleSchema }),
48
+ "route-v2": z
49
+ .object({ questions: z.record(z.string(), QuestionSchema), rule: RouteRuleSchema })
50
+ .superRefine((set, ctx) => {
51
+ for (const id of [...set.rule.floorLogic, ...set.rule.capBuild])
52
+ if (set.questions[id]?.type !== "noul")
53
+ ctx.addIssue({ code: "custom", message: `${id} is no noul question of route-v2` });
54
+ }),
42
55
  finding: z.object({ questions: z.record(z.string(), QuestionSchema), rule: VerdictRuleSchema }),
43
56
  "same-defect": z.object({ questions: z.record(z.string(), QuestionSchema), rule: VerdictRuleSchema }),
57
+ stop: z.object({ questions: z.record(z.string(), QuestionSchema), rule: VerdictRuleSchema }),
44
58
  }),
45
59
  });
46
60
  export type JevFile = z.infer<typeof JevFileSchema>;
@@ -50,6 +64,7 @@ export type SetName = keyof JevFile["sets"];
50
64
  export const VERDICT_OPTIONS = {
51
65
  finding: ["design", "code", "unclear"],
52
66
  "same-defect": ["yes", "no"],
67
+ stop: [...BLOCKED_REASONS, "other"],
53
68
  } as const;
54
69
 
55
70
  /** JSON with object keys sorted at every depth, so equal data always hashes the same. */
@@ -199,7 +214,7 @@ export interface RouteJudgement {
199
214
  * different option counts; the rule reads probabilities instead: the kind at p_max ≥ kindMin, the track
200
215
  * when the summed probability of its levels reaches trackMin, and neither in between (the dead band).
201
216
  */
202
- export function judgeRoute(rule: RouteRule, answers: JevAnswers): RouteJudgement {
217
+ function decideRoute(rule: RouteRule, answers: JevAnswers): RouteJudgement {
203
218
  const k = answers.kind;
204
219
  const d = answers.difficulty;
205
220
  const nouls: Record<string, number> = {};
@@ -252,6 +267,36 @@ export function judgeRoute(rule: RouteRule, answers: JevAnswers): RouteJudgement
252
267
  };
253
268
  }
254
269
 
270
+ /**
271
+ * `decideRoute`, then Jev's yes/no answers (decision 11): a yes to a `floorLogic` question lifts the lane
272
+ * to logic (hard stays hard), else a yes to a `capBuild` question holds it at build (copy stays copy). The
273
+ * floor wins, and both apply in the dead band. The reason names the answer only when the difficulty moved.
274
+ */
275
+ export function judgeRoute(rule: RouteRule, answers: JevAnswers): RouteJudgement {
276
+ const j = decideRoute(rule, answers);
277
+ const yes = (ids: string[]) => ids.find((id) => (j.nouls[id] ?? 0) >= rule.noulMin);
278
+ const lift = yes(rule.floorLogic);
279
+ const hold = lift === undefined ? yes(rule.capBuild) : undefined;
280
+ const now: Difficulty | null =
281
+ lift !== undefined
282
+ ? j.difficulty === "hard"
283
+ ? "hard"
284
+ : "logic"
285
+ : hold !== undefined
286
+ ? j.difficulty === "copy"
287
+ ? "copy"
288
+ : "build"
289
+ : j.difficulty;
290
+ if (now === j.difficulty) return j;
291
+ const [what, id] = lift !== undefined ? ["floor logic", lift] : ["cap build", hold ?? ""];
292
+ return {
293
+ ...j,
294
+ track: now === "logic" || now === "hard" ? "B" : "A",
295
+ difficulty: now,
296
+ rule: `${j.rule}; ${what}: ${id} ${j.nouls[id]}`,
297
+ };
298
+ }
299
+
255
300
  export interface Verdict<T extends string> {
256
301
  value: T;
257
302
  /** the chosen answer's probability, when Jev answered */
@@ -0,0 +1,124 @@
1
+ import { z } from "zod";
2
+ import { BLOCKED_LINE } from "./blocked.ts";
3
+
4
+ export const JUDGE_KINDS = ["triage", "question", "unexpected", "rules"] as const;
5
+ export type JudgeKind = (typeof JUDGE_KINDS)[number];
6
+
7
+ /** What a judge call is given: its kind, the unit it is about, a context text and the numbered findings. */
8
+ export interface JudgeInput {
9
+ id: string;
10
+ kind: JudgeKind;
11
+ unit: string;
12
+ context: string;
13
+ findings: string[];
14
+ lanes: { id: string; owns: string[] }[];
15
+ }
16
+
17
+ const why = z.string();
18
+
19
+ /** A finished run's rules review proposes at most this many changes to the standing rules (plan 34). */
20
+ export const MAX_RULE_CHANGES = 5;
21
+ export const RuleChangeSchema = z.object({ op: z.enum(["add", "remove"]), text: z.string().min(1), why });
22
+ export type RuleChange = z.infer<typeof RuleChangeSchema>;
23
+
24
+ export const JudgeDecisionSchema = z.discriminatedUnion("verb", [
25
+ z.object({
26
+ verb: z.literal("route"),
27
+ routes: z.array(z.object({ finding: z.number().int().min(1), to: z.string(), why })),
28
+ }),
29
+ z.object({ verb: z.literal("answer"), answer: z.string() }),
30
+ z.object({ verb: z.literal("ask_owner"), question: z.string() }),
31
+ z.object({ verb: z.literal("park"), question: z.string() }),
32
+ z.object({ verb: z.literal("retry"), why }),
33
+ z.object({ verb: z.literal("climb"), why }),
34
+ z.object({ verb: z.literal("skip"), why }),
35
+ z.object({ verb: z.literal("rules"), changes: z.array(RuleChangeSchema) }),
36
+ ]);
37
+ export type JudgeDecision = z.infer<typeof JudgeDecisionSchema>;
38
+
39
+ export const judgeName = (id: string): string => `judge-${id}`;
40
+
41
+ /** The judge's reply contract, appended to every judge brief (spec 2.0 decision 3). */
42
+ export const JUDGE_CONTRACT = [
43
+ "Your first line is DECISION: <verb>, one of the verbs your brief lists. Then, by verb:",
44
+ "- route: one line per finding, ROUTE <n> -> <lane id>|architect|dismiss — <why>",
45
+ "- answer: ANSWER: <the answer the worker needs, from the plan and the code>",
46
+ "- ask_owner or park: QUESTION: <one question only the owner can answer>",
47
+ "- retry, climb or skip: WHY: <one line>",
48
+ "- rules: up to five lines, ADD <a one-line rule, with a [role, …] tag when it is not for every role> — <the event it comes from>, or REMOVE <the text of one line of knowledge.md> — <why>; no line when nothing should change",
49
+ `Decide only from what the brief and the run's files show. The last line of your reply is: STATUS: complete — <one line why>. ${BLOCKED_LINE}`,
50
+ ].join("\n");
51
+
52
+ /** The verbs each kind of call may answer with. */
53
+ export const JUDGE_VERBS: Record<JudgeKind, readonly JudgeDecision["verb"][]> = {
54
+ triage: ["route"],
55
+ question: ["answer", "ask_owner"],
56
+ unexpected: ["retry", "climb", "skip", "park"],
57
+ rules: ["rules"],
58
+ };
59
+
60
+ /** A line without the emphasis a model puts around it (`**DECISION: retry**`, backticks). */
61
+ const plain = (l: string): string => l.trim().replace(/^[*_`\s]+|[*_`\s]+$/g, "");
62
+ const STATUS = /^STATUS:\s*(complete|partial|blocked|refused|flaky)\b/i;
63
+ const RULE_LINE = /^\s*(?:[-*]|\d+[.)])?\s*\**(ADD|REMOVE)\**:?\s+(.+)$/;
64
+ const ROUTE = /^[-*\s]*ROUTE\s+(\d+)\s*(?:->|→)\s*(\S+)\s*(?:—|–|-|:)?\s*(.*)$/;
65
+
66
+ /** The text after `label:` on its line and the lines under it, up to the STATUS line. */
67
+ function section(lines: string[], label: string): string | null {
68
+ const at = lines.findIndex((l) => new RegExp(`^${label}:`, "i").test(plain(l)));
69
+ if (at < 0) return null;
70
+ const rest = [plain(lines[at] as string).slice(label.length + 1)];
71
+ for (const l of lines.slice(at + 1)) {
72
+ if (STATUS.test(plain(l))) break;
73
+ rest.push(l.trim());
74
+ }
75
+ return rest.join("\n").trim();
76
+ }
77
+
78
+ /** The judge's decision, or null when the reply is not one its kind allows (the daemon asks once more). */
79
+ export function parseJudgeReply(reply: string, kind: JudgeKind): JudgeDecision | null {
80
+ const lines = reply.split("\n");
81
+ const first = lines.map(plain).find(Boolean) ?? "";
82
+ const verb = /^DECISION:\s*(\w+)\s*$/i.exec(first)?.[1]?.toLowerCase();
83
+ if (!verb || !(JUDGE_VERBS[kind] as readonly string[]).includes(verb)) return null;
84
+ const decision = ((): unknown => {
85
+ switch (verb) {
86
+ case "route": {
87
+ const routes = lines.flatMap((l) => {
88
+ const m = ROUTE.exec(plain(l));
89
+ return m ? [{ finding: Number(m[1]), to: m[2] as string, why: (m[3] ?? "").trim() }] : [];
90
+ });
91
+ return routes.length ? { verb, routes } : null;
92
+ }
93
+ case "answer": {
94
+ const answer = section(lines, "ANSWER");
95
+ return answer ? { verb, answer } : null;
96
+ }
97
+ case "ask_owner":
98
+ case "park": {
99
+ const question = section(lines, "QUESTION");
100
+ return question ? { verb, question } : null;
101
+ }
102
+ case "rules": {
103
+ const changes = lines.flatMap((l) => {
104
+ const m = RULE_LINE.exec(l);
105
+ if (!m) return [];
106
+ const rest = (m[2] as string).trim();
107
+ const at = rest.lastIndexOf(" — ");
108
+ return [
109
+ {
110
+ op: (m[1] as string).toLowerCase(),
111
+ text: (at < 0 ? rest : rest.slice(0, at)).trim(),
112
+ why: at < 0 ? "" : rest.slice(at + 3).trim(),
113
+ },
114
+ ];
115
+ });
116
+ return { verb, changes: changes.slice(0, MAX_RULE_CHANGES) };
117
+ }
118
+ default:
119
+ return { verb, why: section(lines, "WHY") ?? "" };
120
+ }
121
+ })();
122
+ const parsed = JudgeDecisionSchema.safeParse(decision);
123
+ return parsed.success ? parsed.data : null;
124
+ }
@@ -1,4 +1,5 @@
1
1
  import { CatherdError } from "./errors.ts";
2
+ import { TASK_REF } from "./plan-doc.ts";
2
3
 
3
4
  export const KINDS = ["repo_code", "terminal", "ui", "prose", "research"] as const;
4
5
  export type Kind = (typeof KINDS)[number];
@@ -15,10 +16,12 @@ export interface LaneHeader {
15
16
  after: string[];
16
17
  /** `Allow: path[:line], …` under the check: hits of the lane's absence check that are allowed exceptions */
17
18
  allow: string[];
19
+ /** `Tasks: 3, plan.md#4`: the plan tasks the lane implements (catherd inlines them into its worker's brief) */
20
+ tasks: string[];
18
21
  }
19
22
 
20
- /** A lane id in an After: line: Mx.Ly, the shape lanes/<id>.md takes. */
21
- const LANE_ID = /^[A-Za-z0-9][A-Za-z0-9_-]*\.[A-Za-z0-9][A-Za-z0-9._-]*$/;
23
+ /** An After: entry: a lane id (Mx.Ly, the shape lanes/<id>.md takes) or a whole milestone (Mx). */
24
+ const LANE_ID = /^[A-Za-z0-9][A-Za-z0-9_-]*(\.[A-Za-z0-9][A-Za-z0-9._-]*)?$/;
22
25
 
23
26
  /** An Allow: entry: a repo-relative path, optionally `:<line>`. */
24
27
  const ALLOW_ENTRY = /^([^\s:]+)(?::(\d+))?$/;
@@ -89,13 +92,29 @@ export function parseLaneHeader(text: string): LaneHeader {
89
92
  difficulty: oneOf(field(text, "difficulty"), DIFFICULTIES),
90
93
  after: list("after"),
91
94
  allow: list("allow"),
95
+ tasks: list("tasks"),
92
96
  };
93
97
  }
94
98
 
99
+ /** The milestones, other than `m`, that `m`'s lanes wait on (After: entries, lane or milestone), in numeric order. */
100
+ export function milestoneAfter(lanes: { id: string; after: string[] }[], m: string): string[] {
101
+ const out = new Set<string>();
102
+ for (const l of lanes)
103
+ if (l.id.split(".")[0] === m)
104
+ for (const a of l.after) {
105
+ const dep = a.split(".")[0] as string;
106
+ if (dep !== m) out.add(dep);
107
+ }
108
+ return [...out].sort((a, b) => a.localeCompare(b, "en", { numeric: true }));
109
+ }
110
+
95
111
  /** What is wrong with the After: and Allow: entries, one line each; none when they are well formed. */
96
112
  function orderProblems(h: LaneHeader): string[] {
97
113
  const out: string[] = [];
98
- for (const a of h.after) if (!LANE_ID.test(a)) out.push(`After "${a}" is not a lane id like M1.L1`);
114
+ for (const a of h.after)
115
+ if (!LANE_ID.test(a)) out.push(`After "${a}" is not a lane or milestone id like M1.L1 or M1`);
116
+ for (const t of h.tasks)
117
+ if (!TASK_REF.test(t)) out.push(`Tasks "${t}" is not a task number like 3 or plan.md#3`);
99
118
  for (const a of h.allow) {
100
119
  const m = ALLOW_ENTRY.exec(a);
101
120
  try {
@@ -0,0 +1,53 @@
1
+ /**
2
+ * Plan 35 D6: how many milestones of a base run may work side by side. A new one starts when none is working,
3
+ * else when the memory the machine has free, less a reserve and less what the ramping milestones are still to take,
4
+ * stays above a floor. The three megabyte numbers are the run profile's `limits.memory`.
5
+ */
6
+ export const MEMORY_LIMITS = {
7
+ reserveMb: 2048,
8
+ perMilestoneMb: 3072,
9
+ floorMb: 3072,
10
+ /** a milestone counts at `perMilestoneMb` for this long after its tree is ready: its lanes have not reached their memory yet */
11
+ rampMs: 300_000,
12
+ recheckMs: 120_000,
13
+ } as const;
14
+
15
+ export type MemoryLimits = {
16
+ reserveMb: number;
17
+ perMilestoneMb: number;
18
+ floorMb: number;
19
+ rampMs: number;
20
+ recheckMs: number;
21
+ };
22
+
23
+ /** The profile's own memory limits over the defaults. */
24
+ export function memoryLimitsOf(p: {
25
+ limits?: { memory?: Partial<Pick<MemoryLimits, "reserveMb" | "perMilestoneMb" | "floorMb">> };
26
+ }): MemoryLimits {
27
+ return { ...MEMORY_LIMITS, ...p.limits?.memory };
28
+ }
29
+
30
+ /** Milestones working (a tree, not landed, merged or parked) and those of them still ramping up. */
31
+ export function memoryLoad(
32
+ ms: { tree: string | null; status: string; treeAt: string | null }[],
33
+ nowMs: number,
34
+ l: MemoryLimits = MEMORY_LIMITS,
35
+ ): { working: number; ramping: number } {
36
+ const working = ms.filter((m) => m.tree !== null && !["landed", "merged", "parked"].includes(m.status));
37
+ return {
38
+ working: working.length,
39
+ ramping: working.filter((m) => m.treeAt !== null && Date.parse(m.treeAt) > nowMs - l.rampMs).length,
40
+ };
41
+ }
42
+
43
+ /** The memory a deferral waits for: the reserve, what the ramping milestones will take, and the floor. */
44
+ export const neededMb = (ramping: number, l: MemoryLimits = MEMORY_LIMITS): number =>
45
+ l.reserveMb + ramping * l.perMilestoneMb + l.floorMb;
46
+
47
+ /** Whether one more milestone may start: always when none is working, so a run always moves. */
48
+ export function mayStartMilestone(
49
+ i: { availableMb: number; working: number; ramping: number },
50
+ l: MemoryLimits = MEMORY_LIMITS,
51
+ ): boolean {
52
+ return i.working === 0 || i.availableMb - l.reserveMb - i.ramping * l.perMilestoneMb >= l.floorMb;
53
+ }