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
@@ -2,15 +2,20 @@ import { existsSync, readdirSync, readFileSync } from "node:fs";
2
2
  import { join } from "node:path";
3
3
  import { CatherdError, errorMessage } from "../domain/errors.ts";
4
4
  import { assertLaneHeader, LANE_HEADER_FIX, onlyAllowedHits, parseLaneHeader } from "../domain/lane.ts";
5
+ import { coverageProblems } from "../domain/plan-doc.ts";
6
+ import { lintLanes } from "../domain/plan-lint.ts";
5
7
  import { checkEnv } from "../infra/env.ts";
6
8
  import { heavySlots, withHeavySlotWithin } from "../infra/heavy-lock.ts";
7
9
  import { loginEnv } from "../infra/login-env.ts";
8
10
  import { killGroup } from "../infra/proc.ts";
9
11
  import { readGateEnv, resolveGateEnv } from "./gate-env.ts";
10
12
  import { landedMilestones } from "./milestones.ts";
13
+ import { planDocs } from "./plan-input.ts";
11
14
  import type { Deps } from "./ports.ts";
15
+ import { isDaemonRun, replayRun } from "./run-events-store.ts";
12
16
  import { TAIL_LINES } from "./run-debug.ts";
13
17
  import { findRun, type Run, runPaths } from "./run-store.ts";
18
+ import { treeOf } from "./tree-path.ts";
14
19
 
15
20
  /** Spec §4.7. Only `cannot-start` blocks the run; `lock-busy` (plan 23) means: run preflight again. */
16
21
  export type PreflightOutcome = "pass" | "fails-as-expected" | "skipped" | "cannot-start" | "lock-busy";
@@ -25,7 +30,12 @@ interface PreflightResult {
25
30
  }
26
31
 
27
32
  export type PreflightReport =
28
- | { needsConfirmation: true; commands: { lane: string; check: string | null }[] }
33
+ | {
34
+ needsConfirmation: true;
35
+ commands: { lane: string; check: string | null }[];
36
+ /** plan 34: the plan lint's warnings */
37
+ lint: string[];
38
+ }
29
39
  | {
30
40
  needsConfirmation: false;
31
41
  commands: { lane: string; check: string | null }[];
@@ -33,6 +43,8 @@ export type PreflightReport =
33
43
  blocked: boolean;
34
44
  /** plan 23: lanes whose fast check runs no linter while the repo has one */
35
45
  warnings: string[];
46
+ /** plan 34: the plan lint's warnings */
47
+ lint: string[];
36
48
  };
37
49
 
38
50
  const CHECK_TIMEOUT_MS = 120_000;
@@ -57,6 +69,10 @@ interface LaneCheck {
57
69
  problem: string | null;
58
70
  /** spec 1.1 §6: why its Kind: or Difficulty: line is refused */
59
71
  invalid: string | null;
72
+ /** plan 32: the plan tasks it names on its `Tasks:` line */
73
+ tasks: string[];
74
+ /** plan 34: the lane file's text; null when it cannot be read */
75
+ text: string | null;
60
76
  }
61
77
 
62
78
  function laneChecks(run: Run): LaneCheck[] {
@@ -83,9 +99,20 @@ function laneChecks(run: Run): LaneCheck[] {
83
99
  allow: h.allow,
84
100
  problem: h.fastCheck ? null : `lanes/${f} has no Fast check: line`,
85
101
  invalid,
102
+ tasks: h.tasks,
103
+ text,
86
104
  };
87
105
  } catch (e) {
88
- return { lane, check: null, owns: [], allow: [], problem: (e as Error).message, invalid };
106
+ return {
107
+ lane,
108
+ check: null,
109
+ owns: [],
110
+ allow: [],
111
+ problem: (e as Error).message,
112
+ invalid,
113
+ tasks: [],
114
+ text: null,
115
+ };
89
116
  }
90
117
  });
91
118
  }
@@ -280,8 +307,43 @@ export async function preflight(
280
307
  const invalid = lanes.flatMap((l) => (l.invalid ? [l.invalid] : []));
281
308
  if (invalid.length) throw new CatherdError("E_LANE_INVALID", invalid.join("; "), { fix: LANE_HEADER_FIX });
282
309
  const commands = lanes.map((l) => ({ lane: l.lane, check: l.check }));
283
- if (profile.preflight.confirm && !i.confirmed) return { needsConfirmation: true, commands };
310
+ const { warnings: lint, stuck } = lintLanes(
311
+ lanes.flatMap((l) => (l.text === null ? [] : [{ id: l.lane, text: l.text }])),
312
+ laneChecks(run).map((l) => l.lane),
313
+ // a base run's milestone starts after the ones it names are merged, not only landed (plan 35)
314
+ run.meta.base
315
+ ? replayRun(run)
316
+ .milestones.filter((m) => m.status === "merged")
317
+ .map((m) => m.id)
318
+ : [...landed],
319
+ );
320
+ // plan 32: a daemon run starts no lane the plan does not back, before the confirm list and before any check
321
+ if (isDaemonRun(run)) {
322
+ const problems = coverageProblems(
323
+ planDocs(run),
324
+ lanes.map((l) => ({ id: l.lane, tasks: l.tasks })),
325
+ );
326
+ if (problems.length)
327
+ return {
328
+ needsConfirmation: false,
329
+ commands,
330
+ results: problems.map((p) => ({
331
+ lane: p.lane || (i.milestone ?? "plan"),
332
+ check: null,
333
+ outcome: "cannot-start" as const,
334
+ exitCode: null,
335
+ tail: [],
336
+ note: p.note,
337
+ })),
338
+ blocked: true,
339
+ warnings: [],
340
+ lint,
341
+ };
342
+ }
343
+ if (profile.preflight.confirm && !i.confirmed) return { needsConfirmation: true, commands, lint };
284
344
  const timeoutMs = i.timeoutMs ?? CHECK_TIMEOUT_MS;
345
+ // plan 35: a base run's milestone is checked in its own worktree
346
+ const cwd = treeOf(run, i.milestone ?? null);
285
347
  const results: PreflightResult[] = [];
286
348
  // the login env is captured once per server; the gate env is read once per preflight
287
349
  let env: Record<string, string> | undefined;
@@ -302,9 +364,7 @@ export async function preflight(
302
364
  results.push({ lane: l.lane, check, outcome: "skipped", exitCode: null, tail: [], note: AS_ROOT });
303
365
  continue;
304
366
  }
305
- const unborn = l.owns.filter(
306
- (p) => check.includes(p.replace(/\/$/, "")) && !existsSync(join(run.meta.repo, p)),
307
- );
367
+ const unborn = l.owns.filter((p) => check.includes(p.replace(/\/$/, "")) && !existsSync(join(cwd, p)));
308
368
  if (unborn.length) {
309
369
  const note = `${unborn.join(", ")} does not exist yet; the lane creates it`;
310
370
  results.push({ lane: l.lane, check, outcome: "skipped", exitCode: null, tail: [], note });
@@ -313,8 +373,11 @@ export async function preflight(
313
373
  env ??= await checkEnvFor(run.meta.repo);
314
374
  const checkEnvironment = env;
315
375
  // plan 23: each check waits for its slot within its own budget, and says lock-busy past it
316
- const slot = await withHeavySlotWithin(heavySlots(profile.heavy), preflightLimits.lockWaitMs, () =>
317
- runCheck(run.meta.repo, check, timeoutMs, checkEnvironment),
376
+ const slot = await withHeavySlotWithin(
377
+ heavySlots(profile.heavy),
378
+ preflightLimits.lockWaitMs,
379
+ (_slot, tmp) => runCheck(cwd, check, timeoutMs, { ...checkEnvironment, TMPDIR: tmp, GOTMPDIR: tmp }),
380
+ { tmpBase: process.env.TMPDIR },
318
381
  );
319
382
  if (slot.busy) {
320
383
  const note = `every heavy slot stayed busy for ${preflightLimits.lockWaitMs / 1000} s (other lanes' checks); run preflight again when they finish`;
@@ -335,6 +398,16 @@ export async function preflight(
335
398
  note: allowed ? `every hit is an Allow: exception (${r.lines.length})` : noteOf(r, outcome, timeoutMs),
336
399
  });
337
400
  }
401
+ // plan 34: an After: that can never finish stops the milestone, so the daemon parks it instead of waiting forever
402
+ for (const s of stuck)
403
+ results.push({
404
+ lane: s.lane,
405
+ check: null,
406
+ outcome: "cannot-start",
407
+ exitCode: null,
408
+ tail: [],
409
+ note: s.note,
410
+ });
338
411
  // plan 23: lint reaches the worker only through its fast check
339
412
  const linter = repoLinter(run.meta.repo);
340
413
  const warnings = linter
@@ -351,5 +424,6 @@ export async function preflight(
351
424
  results,
352
425
  blocked: results.some((r) => r.outcome === "cannot-start"),
353
426
  warnings,
427
+ lint,
354
428
  };
355
429
  }
@@ -1,5 +1,6 @@
1
1
  import type { HostContext, OrchestrationHost } from "../domain/host.ts";
2
2
  import { agentFiles } from "../domain/agents.ts";
3
+ import { type MemoryLimits, memoryLimitsOf } from "../domain/milestone-memory.ts";
3
4
  import { existsSync, readFileSync, rmSync } from "node:fs";
4
5
  import { join } from "node:path";
5
6
  import { isDeepStrictEqual } from "node:util";
@@ -78,7 +79,7 @@ function saveAndLink(name: string, doc: ProfileDoc, host: OrchestrationHost, rep
78
79
 
79
80
  const validate = (doc: ProfileDoc, name: string, host: OrchestrationHost): Validation => {
80
81
  try {
81
- return validateHere(resolveProfile(doc, name, host), loadCatalog({ timings: false }), doc, host);
82
+ return validateHere(resolveProfile(doc, name, host), loadCatalog(), doc, host);
82
83
  } catch (e) {
83
84
  if (!(e instanceof CatherdError)) throw e;
84
85
  return { errors: [{ path: "roles", message: e.message, fix: e.fix }], warnings: [] };
@@ -105,7 +106,7 @@ function syncFor(
105
106
  return allowPrune ? pruneProfileAgents(name) : { linked: [], pruned: [], newSessionNeededFor: [] };
106
107
  }
107
108
 
108
- /** What a save returns: the port's ProfileSaved (one type for the CLI, the TUI and the MCP tools). */
109
+ /** What a save returns: the port's ProfileSaved (one type for the CLI, the panel and the MCP tools). */
109
110
  export type Saved = ProfileSaved;
110
111
 
111
112
  const unsaved = (v: Validation): Saved => ({
@@ -121,7 +122,7 @@ const unsaved = (v: Validation): Saved => ({
121
122
  export const CHANGED_ON_DISK = "profile";
122
123
 
123
124
  /**
124
- * Spec §7.3 `patch` (profile_set, `catherd profile set`, the TUI's save): validates first and writes
125
+ * Spec §7.3 `patch` (profile_set, `catherd profile set`, the panel's save): validates first and writes
125
126
  * nothing when invalid, unless the save repairs the profile (spec 1.2 §6.2): it removes at least one error
126
127
  * the stored profile had and adds none. Then it saves, and the result's `errors` are those still open. A
127
128
  * profile that does not exist yet starts from the default profile. With `expect` (the profile as a preview
@@ -301,14 +302,23 @@ function viewOf(p: Profile): ProfileView {
301
302
  jev: p.jev,
302
303
  isolated: Object.fromEntries(Object.entries(p.harness).map(([k, h]) => [k, h.isolated])),
303
304
  failover: p.failover,
305
+ maxEffort: p.maxEffort,
306
+ bars: p.bars,
304
307
  budget: p.budget,
305
308
  timeouts: p.timeouts,
306
309
  preflight: p.preflight,
307
310
  heavy: p.lock.heavy,
308
311
  notify: p.notify,
312
+ limits: { memory: pickMemory(memoryLimitsOf(p)) },
309
313
  };
310
314
  }
311
315
 
316
+ const pickMemory = ({ reserveMb, perMilestoneMb, floorMb }: MemoryLimits) => ({
317
+ reserveMb,
318
+ perMilestoneMb,
319
+ floorMb,
320
+ });
321
+
312
322
  /** The ProfilePort the run engine and the MCP tools use (spec §7.3: the single writer). */
313
323
  export function profileService(host: () => HostContext): ProfilePort {
314
324
  // without a name, each tool acts on the profile `repo` runs on (the active one outside a repo)
@@ -341,6 +351,23 @@ export function profileService(host: () => HostContext): ProfilePort {
341
351
  set: (name, patch, repo = null) => patchProfile(name ?? activeName(repo), patch, { host: host().host }),
342
352
  agentFor: (repo, role, rung) =>
343
353
  parseRung(rung).backend === "claude" ? agentName(activeName(repo), role, rung) : null,
354
+ notifyUrl: () =>
355
+ locked(() => {
356
+ const config = readConfig();
357
+ if (config.notify?.url) return config.notify.url;
358
+ const url = `https://ntfy.sh/catherd-${Buffer.from(crypto.getRandomValues(new Uint8Array(10))).toString("hex")}`;
359
+ writeJsonAtomic(configFile(), { ...config, schema: 1, notify: { ...config.notify, url } });
360
+ return url;
361
+ }),
362
+ setNotifyUrl: (url) =>
363
+ locked(() => {
364
+ if (!/^https?:\/\//.test(url) || !URL.canParse(url))
365
+ throw new CatherdError("E_INPUT_INVALID", `"${url}" is not an http(s) url`, {
366
+ fix: "give the ntfy topic's url, like https://ntfy.sh/<topic>",
367
+ });
368
+ const config = readConfig();
369
+ writeJsonAtomic(configFile(), { ...config, schema: 1, notify: { ...config.notify, url } });
370
+ }),
344
371
  };
345
372
  }
346
373
 
@@ -39,7 +39,12 @@ export const projectsFile = (): string => join(configDir(), "projects.json");
39
39
  export const agentsRoot = (): string => join(configDir(), "agents");
40
40
  export const profileFile = (name: string): string => join(profilesDir(), `${name}.json`);
41
41
 
42
- const ConfigSchema = z.looseObject({ schema: z.literal(1), activeProfile: z.string().optional() });
42
+ const ConfigSchema = z.looseObject({
43
+ schema: z.literal(1),
44
+ activeProfile: z.string().optional(),
45
+ /** where the phone messages go (an ntfy topic), made at the first run */
46
+ notify: z.looseObject({ url: z.string().optional() }).optional(),
47
+ });
43
48
  const ProjectsSchema = z.looseObject({
44
49
  schema: z.literal(1),
45
50
  bindings: z.record(z.string(), z.string()).default({}),
@@ -149,7 +154,7 @@ export function isolationKeyErrors(
149
154
 
150
155
  /**
151
156
  * Spec 1.3 §9 Q2: each enabled role whose rung, or a failover stand-in of one, runs natively on a backend that
152
- * holds the role's access only when isolated (agy has no read-only flag).
157
+ * holds the role's access only when isolated (one with no read-only flag).
153
158
  */
154
159
  export function isolatedOnlyErrors(p: Profile): Issue[] {
155
160
  const out: Issue[] = [];
@@ -243,7 +248,7 @@ export function validateNamed(
243
248
  ): Validation {
244
249
  const n = name ?? activeName(repo);
245
250
  const doc = readProfileDoc(n);
246
- const catalog = loadCatalog({ timings: false, ...(repo === null ? {} : { repo }) });
251
+ const catalog = loadCatalog(repo === null ? {} : { repo });
247
252
  try {
248
253
  return validateHere(resolveProfile(doc, n, host), catalog, doc, host, o);
249
254
  } catch (e) {
@@ -50,6 +50,10 @@ function laneIds(run: Run): string[] {
50
50
  .sort(byNumber);
51
51
  }
52
52
 
53
+ /** Plan 28: the milestones the run's lane files name, in number order. */
54
+ export const runMilestones = (run: Run): string[] =>
55
+ [...new Set(laneIds(run).map(milestoneOf))].sort(byNumber);
56
+
53
57
  /** What laneDone reads of the run, read once for every lane of the milestone. */
54
58
  interface LaneEvidence {
55
59
  routes: RouteRow[];
@@ -1,81 +1,28 @@
1
- import { type Catalog, type Confidence, DIMS, type Dim, rungInfo, scoresOf } from "../domain/catalog.ts";
1
+ import { barOf, BAR_PERCENTILE } from "../domain/bars.ts";
2
+ import { type Catalog, type Pair, rungInfo } from "../domain/catalog.ts";
2
3
  import { type BillingMode, type Cost, costOf, DEFAULT_BILLING } from "../domain/cost.ts";
3
4
  import type { Difficulty, Kind } from "../domain/lane.ts";
4
5
  import { type EvidenceTable, evidenceLine, evidenceOf } from "./run-evidence.ts";
5
6
 
6
- /** Spec 1.2 §5.3: one value routing uses for a rung, with where it came from. */
7
- export interface ValueUsed {
8
- dim: Dim;
9
- value: number;
10
- confidence: Confidence;
11
- /** the source a synced value came from; `shipped` for a hand-typed one, `override` for the user's */
12
- source: string;
13
- benchmark: string;
14
- date: string;
15
- url: string;
16
- /** catherd's guess for this rung: lent by a treat-like or an inferred stand-in (spec 1.2 §6.1) */
17
- inferred: boolean;
18
- /** the rung the value belongs to, when it is not this one */
19
- from: string | null;
20
- /** what lent it: a treat-like (a mapping, `like X`) or an inferred stand-in (a guess); null for its own */
21
- lent: "treat-like" | "stand-in" | null;
22
- }
23
-
24
- /** Spec 1.2 §5.3: one threshold of the lane's bar, the value used against it, and whether it clears. */
25
- export interface Threshold {
26
- dim: Dim;
27
- min: number;
28
- used: ValueUsed | null;
29
- clears: boolean;
30
- /** where the default threshold came from (`barsWhy`) */
31
- why: string | null;
32
- }
33
-
34
- /** Spec 1.2 §5.3, §8: what `route` reports about the rung it chose. */
7
+ /** What `route` reports about the rung it chose: the one number it ranked on, the bar, and what it costs. */
35
8
  export interface Provenance {
36
9
  rung: string;
37
10
  canonical: string;
38
- /** the lane's bar, threshold by threshold; empty when the route read no bar (the default rung) */
39
- thresholds: Threshold[];
40
- /** every value the rung has */
41
- values: ValueUsed[];
42
- /** facts that never carry a bar (spec 1.2 §4.1): `<source>.<field>` → value */
43
- speed: Record<string, number>;
11
+ /** the rung's Slopalytics intelligence index; null when it is not indexed */
12
+ intelligence: number | null;
13
+ /** the difficulty's bar, with the percentile it sits at; null when the route read no bar (the default rung) */
14
+ bar: { difficulty: Difficulty; percentile: number; min: number; clears: boolean } | null;
15
+ price: Pair["price"];
16
+ /** tokens per second; null when Slopalytics has none */
17
+ speed: number | null;
44
18
  cost: Cost;
45
- /**
46
- * spec 1.2 §8: its runs for the lane's kind (or every kind) and over every kind; never used for routing;
47
- * null when the runs could not be read
48
- */
19
+ /** when the proxy fetched the index the route read; null with no index */
20
+ indexedAt: string | null;
21
+ /** spec 1.2 §8: its runs for the lane's kind (or every kind) and over every kind; null when they could not be read */
49
22
  evidence: { kind: string | null; all: string | null } | null;
50
23
  }
51
24
 
52
- /** Spec 1.2 §5.3: each value a canonical rung has, with its confidence and source, borrowed ones marked. */
53
- export function valuesUsed(c: Catalog, canonical: string): ValueUsed[] {
54
- const s = scoresOf(c, canonical);
55
- if (!s) return [];
56
- return DIMS.flatMap((dim) => {
57
- const r = s.records[dim];
58
- if (!r) return [];
59
- const borrowed = s.borrowed.includes(dim);
60
- const from = borrowed ? s.via : (s.standIns[dim] ?? null);
61
- return [
62
- {
63
- dim,
64
- value: r.value,
65
- confidence: r.confidence,
66
- source: r.source ?? "shipped",
67
- benchmark: `${r.benchmark} ${r.version}`,
68
- date: r.date,
69
- url: r.url,
70
- inferred: from !== null,
71
- from,
72
- lent: from === null ? null : borrowed ? "treat-like" : "stand-in",
73
- },
74
- ];
75
- });
76
- }
77
-
78
- /** Spec 1.2 §5.3: the chosen rung's thresholds for the lane's kind and difficulty, its values and facts. */
25
+ /** The chosen rung's intelligence, the difficulty's bar and whether it clears it, its price, speed and cost. */
79
26
  export function provenanceOf(
80
27
  c: Catalog,
81
28
  rung: string,
@@ -83,34 +30,28 @@ export function provenanceOf(
83
30
  difficulty: Difficulty | null,
84
31
  billing: Partial<Record<string, BillingMode>>,
85
32
  evidence: EvidenceTable | null,
33
+ at: Record<Difficulty, number> = BAR_PERCENTILE,
86
34
  ): Provenance {
87
35
  const info = rungInfo(c, rung);
88
- const values = valuesUsed(c, info.canonical);
89
- const bar = kind && difficulty ? c.bars[kind][difficulty] : {};
90
- const thresholds: Threshold[] = DIMS.flatMap((dim) => {
91
- const min = bar[dim];
92
- if (min === undefined || !kind || !difficulty) return [];
93
- const used = values.find((v) => v.dim === dim) ?? null;
94
- return [
95
- {
96
- dim,
97
- min,
98
- used,
99
- clears: used !== null && used.value >= min,
100
- // a threshold the user's override set is theirs, whatever the default's why says (1.2 minor)
101
- why: c.userBars?.includes(`${kind}/${difficulty}/${dim}`)
102
- ? "your override"
103
- : (c.barsWhy[dim]?.[difficulty] ?? null),
104
- },
105
- ];
106
- });
36
+ const intelligence = info.pair?.intelligence ?? null;
37
+ const min = difficulty ? barOf(c, difficulty, at) : null;
107
38
  return {
108
39
  rung,
109
40
  canonical: info.canonical,
110
- thresholds,
111
- values,
112
- speed: info.family?.speed ?? {},
113
- cost: costOf(info.family, info.parsed.effort, billing[info.key] ?? DEFAULT_BILLING[info.key]),
41
+ intelligence,
42
+ bar:
43
+ difficulty && min !== null
44
+ ? {
45
+ difficulty,
46
+ percentile: at[difficulty],
47
+ min,
48
+ clears: intelligence !== null && intelligence >= min,
49
+ }
50
+ : null,
51
+ price: info.pair?.price ?? null,
52
+ speed: info.pair?.speed ?? null,
53
+ cost: costOf(info.family, info.pair, info.parsed.effort, billing[info.key] ?? DEFAULT_BILLING[info.key]),
54
+ indexedAt: c.indexedAt,
114
55
  evidence: evidence && {
115
56
  kind: evidenceLine(evidenceOf(evidence, info.canonical, kind)),
116
57
  all: evidenceLine(evidenceOf(evidence, info.canonical)),
@@ -29,6 +29,15 @@ const rows = (run: Run): QuestionRow[] =>
29
29
  (r) => typeof r?.milestone === "string" && typeof r.text === "string",
30
30
  );
31
31
 
32
+ /** Plan 28: how many questions the run parked on and how many were answered. */
33
+ export const questionCounts = (run: Run): { questions: number; answers: number } => {
34
+ const all = rows(run);
35
+ return {
36
+ questions: all.filter((r) => r.kind === "question").length,
37
+ answers: all.filter((r) => r.kind === "answer").length,
38
+ };
39
+ };
40
+
32
41
  /** The questions not answered yet, oldest first; a milestone answered after its question is no longer open. */
33
42
  export function openQuestions(run: Run): OpenQuestion[] {
34
43
  const open = new Map<string, OpenQuestion>();
@@ -3,10 +3,12 @@ import { errorMessage } from "../domain/errors.ts";
3
3
  import { dispatchPaths } from "../infra/dispatch-dir.ts";
4
4
  import { log } from "../infra/log.ts";
5
5
  import { writeJsonAtomic } from "../infra/store.ts";
6
+ import { ensureDaemon } from "./daemon.ts";
6
7
  import { recoverOwned, settle, stallPoll, unsettledLimits, watching } from "./dispatch-service.ts";
7
8
  import { type Dispatch, listDispatches, pendingDispatches } from "./dispatches.ts";
8
9
  import { finalizeDispatch, waitForFinish } from "./finalize.ts";
9
10
  import type { Deps } from "./ports.ts";
11
+ import { isDaemonRun } from "./run-events-store.ts";
10
12
  import { listRuns, type Run } from "./run-store.ts";
11
13
  import { ownsRun } from "./sessions.ts";
12
14
 
@@ -100,6 +102,15 @@ export async function reconcileAll(deps: Deps): Promise<ReconcileReport> {
100
102
  if (!report.warnings.includes(w)) report.warnings.push(w);
101
103
  };
102
104
  for (const run of runs) {
105
+ // a 2.0 run is its daemon's: an unfinished one gets its daemon back, and nothing here settles its roles
106
+ if (isDaemonRun(run)) {
107
+ try {
108
+ ensureDaemon(run);
109
+ } catch (e) {
110
+ warn(run, e);
111
+ }
112
+ continue;
113
+ }
103
114
  let pending;
104
115
  try {
105
116
  const scrubbed = scrubOldSpecs(run);
@@ -1,4 +1,5 @@
1
1
  import { assertNativeHost } from "./backends.ts";
2
+ import { percentilesOf } from "../domain/bars.ts";
2
3
  import { BUDGET_CHEAP_AT } from "../domain/budget.ts";
3
4
  import { errorMessage } from "../domain/errors.ts";
4
5
  import {
@@ -21,10 +22,11 @@ import {
21
22
  type Pick,
22
23
  quotaUsage,
23
24
  type RoutingProfile,
25
+ rolesWithoutRung,
24
26
  select,
25
27
  } from "../domain/select.ts";
26
28
  import { log } from "../infra/log.ts";
27
- import { catalogQuery, freshenDiscovery, loadCatalog } from "./catalog-service.ts";
29
+ import { catalogQuery, freshenDiscovery, loadCatalog, startIndex } from "./catalog-service.ts";
28
30
  import { type Asked, askJev, type JevOpts, jevQuestions, logJev } from "./jev-service.ts";
29
31
  import type { ProfileView, RouteAnswer, RouteRequest, RoutingPort, Verdict } from "./ports.ts";
30
32
  import { provenanceOf } from "./provenance.ts";
@@ -42,6 +44,8 @@ function routingProfile(
42
44
  // spec §4.6: from 80 % of the budget on, start at the cheapest rung that clears the bar
43
45
  objective: spentFraction >= BUDGET_CHEAP_AT ? "cost" : v.objective,
44
46
  billing: v.billing,
47
+ maxEffort: v.maxEffort ?? {},
48
+ bars: { ...v.bars, ...rc?.bars },
45
49
  role: {
46
50
  enabled: rc?.enabled ?? false,
47
51
  rungs: rc?.rungs ?? [],
@@ -142,7 +146,7 @@ interface Prepared {
142
146
  async function prepare(req: RouteRequest, o: RoutingOpts): Promise<Prepared> {
143
147
  const p = routingProfile(req.profile, req.role, req.spentFraction, req.usage);
144
148
  await freshenWithin(p.role.rungs, req.repo, o.discoveryBudgetMs ?? DISCOVERY_BUDGET_MS);
145
- return { p, c: loadCatalog({ repo: req.repo }) };
149
+ return { p, c: loadCatalog({ repo: req.repo, runDir: req.runDir }) };
146
150
  }
147
151
 
148
152
  /** Jev's answer about a lane; null when the route reads no lane, empty when it asks nothing (Jev off, one usable rung). */
@@ -172,7 +176,15 @@ function decide(req: RouteRequest, { p, c }: Prepared, j: Judged | null): RouteA
172
176
  const fallback = () => defaultLadder(c, p, req.role);
173
177
  const finish = (out: RouteAnswer): RouteAnswer => {
174
178
  out.why = whyOf(out, req.laneText !== null);
175
- out.provenance = provenanceOf(c, out.rung, out.kind, out.difficulty, p.billing, evidenceOrNone(c));
179
+ out.provenance = provenanceOf(
180
+ c,
181
+ out.rung,
182
+ out.kind,
183
+ out.difficulty,
184
+ p.billing,
185
+ evidenceOrNone(c),
186
+ percentilesOf(p),
187
+ );
176
188
  return out;
177
189
  };
178
190
  if (req.laneText === null || j === null)
@@ -184,21 +196,14 @@ function decide(req: RouteRequest, { p, c }: Prepared, j: Judged | null): RouteA
184
196
  : null;
185
197
  let out: RouteAnswer;
186
198
  if (lane.kind && lane.difficulty) {
187
- out = answer(
188
- select(c, p, req.role, lane.kind, lane.difficulty),
189
- "lane",
190
- lane.kind,
191
- lane.difficulty,
192
- asked,
193
- jev,
194
- );
199
+ out = answer(select(c, p, req.role, lane.difficulty), "lane", lane.kind, lane.difficulty, asked, jev);
195
200
  } else if (judged?.track && judged.difficulty) {
196
201
  const kind = lane.kind ?? judged.kind ?? "repo_code";
197
- out = answer(select(c, p, req.role, kind, judged.difficulty), "jev", kind, judged.difficulty, asked, jev);
202
+ out = answer(select(c, p, req.role, judged.difficulty), "jev", kind, judged.difficulty, asked, jev);
198
203
  } else if (judged?.kind || (judged && lane.kind)) {
199
204
  const kind = (lane.kind ?? judged.kind) as Kind;
200
- const difficulty = lane.difficulty ?? defaultDifficulty(c, p, req.role, kind);
201
- out = answer(select(c, p, req.role, kind, difficulty), "jev-kind", kind, difficulty, asked, jev);
205
+ const difficulty = lane.difficulty ?? defaultDifficulty(c, p, req.role);
206
+ out = answer(select(c, p, req.role, difficulty), "jev-kind", kind, difficulty, asked, jev);
202
207
  } else {
203
208
  out = answer(fallback(), "default", null, null, asked, jev);
204
209
  }
@@ -248,7 +253,7 @@ async function routeMany(reqs: RouteRequest[], o: RoutingOpts): Promise<RouteAns
248
253
 
249
254
  async function verdict<T extends string>(
250
255
  runDir: string,
251
- set: Extract<SetName, "finding" | "same-defect">,
256
+ set: Extract<SetName, "finding" | "same-defect" | "stop">,
252
257
  state: Record<string, unknown>,
253
258
  options: readonly T[],
254
259
  use: "auto" | "off",
@@ -303,6 +308,12 @@ export function routingService(o: RoutingOpts = {}): RoutingPort {
303
308
  use,
304
309
  o,
305
310
  ),
311
+ stopCause: (runDir, status, reply, use) =>
312
+ verdict(runDir, "stop", { status, reply: scrubFree(reply.slice(-4000)) }, VERDICT_OPTIONS.stop, use, o),
313
+ checkStart: async (view, repo) => {
314
+ const { catalog, index } = await startIndex(repo);
315
+ return { unusable: rolesWithoutRung(catalog, view), index };
316
+ },
306
317
  catalog: (filter, billing) => catalogQuery(filter, billing),
307
318
  };
308
319
  }