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
@@ -6,9 +6,12 @@
6
6
  /** Spec §3.6: `later` waits for the receiver's turn to end, `next` drains at its next tool round. Never `now`. */
7
7
  export type NoticePriority = "later" | "next";
8
8
 
9
- /** One role's news: it finished (`record`), or an event while it runs (`stall`). */
9
+ /**
10
+ * One role's news: it finished (`record`), or an event while it runs (`stall`); of a 2.0 run, one of its
11
+ * reports (`report`: `name` is the unit, `status` the report's kind, `reply` its text).
12
+ */
10
13
  export interface Notice {
11
- kind: "finished" | "stalled";
14
+ kind: "finished" | "stalled" | "report";
12
15
  eventId: string;
13
16
  runId: string;
14
17
  runTitle: string;
@@ -41,6 +44,7 @@ const threadWord = (n: Notice): string => `thread: ${n.thread ?? "none"}`;
41
44
 
42
45
  /** The first line of one role's block: it must stand alone, since the Desktop preview shows only it. */
43
46
  export function noticeHeader(n: Notice): string {
47
+ if (n.kind === "report") return `catherd · ${n.runTitle} · ${n.status} · ${n.name}`;
44
48
  const head = `catherd · ${n.runTitle} · ${n.name} ${n.role} · ${n.rung} · ${n.status}`;
45
49
  if (n.kind === "stalled") return `${head} · running ${n.secs}s · ${threadWord(n)}`;
46
50
  return [
@@ -68,6 +72,15 @@ export function capNoticeReply(reply: string, cap = REPLY_CAP): string {
68
72
  const neutral = (s: string) => s.replace(/(cross)-(session)-(message)/gi, "$1 $2 $3");
69
73
 
70
74
  function block(n: Notice): string {
75
+ if (n.kind === "report")
76
+ return [
77
+ noticeHeader(n),
78
+ ...(n.reply ? [n.reply] : []),
79
+ n.status === "parked"
80
+ ? `Answer: answer(run: "${n.runId}", unit: "${n.name}", answer: "<your answer>")`
81
+ : `Status: status(run: "${n.runId}")`,
82
+ `Event: ${n.eventId}`,
83
+ ].join("\n");
71
84
  const call = `run: "${n.runId}", name: "${n.name}"`;
72
85
  if (n.kind === "stalled") return `${noticeHeader(n)}\nPeek: peek(${call})\nEvent: ${n.eventId}`;
73
86
  const reply = capNoticeReply(n.reply);
@@ -80,12 +93,16 @@ function block(n: Notice): string {
80
93
  function previewOf(ns: Notice[]): string {
81
94
  const titles = [...new Set(ns.map((n) => n.runTitle))];
82
95
  const where = titles.length === 1 ? titles[0] : plural(titles.length, "run", "runs");
83
- const what = ns.every((n) => n.kind === "finished")
84
- ? `${ns.length} roles finished`
85
- : `${plural(ns.length, "role", "roles")} to look at`;
86
- const each = ns.map(
87
- (n) =>
88
- `${n.lane ?? n.name} ${n.kind === "stalled" ? "stalled" : n.status.split(";")[0]} (${threadWord(n)})`,
96
+ const reports = ns.every((n) => n.kind === "report");
97
+ const what = reports
98
+ ? plural(ns.length, "report", "reports")
99
+ : ns.every((n) => n.kind === "finished")
100
+ ? `${ns.length} roles finished`
101
+ : `${plural(ns.length, "role", "roles")} to look at`;
102
+ const each = ns.map((n) =>
103
+ n.kind === "report"
104
+ ? `${n.name} ${n.status}`
105
+ : `${n.lane ?? n.name} ${n.kind === "stalled" ? "stalled" : n.status.split(";")[0]} (${threadWord(n)})`,
89
106
  );
90
107
  return `catherd · ${where} · ${what}: ${each.join(", ")}`;
91
108
  }
@@ -0,0 +1,32 @@
1
+ import type { NotifyMoment } from "./profile.ts";
2
+
3
+ export interface NotifyMessage {
4
+ title: string;
5
+ message: string;
6
+ tags: string[];
7
+ }
8
+
9
+ /**
10
+ * The phone message of a moment (spec 2.0): the run, the unit and the command that answers it, nothing else.
11
+ * Never the question's text, never code or secrets: the owner opens catherd to read it.
12
+ */
13
+ export function notifyMessage(i: {
14
+ runId: string;
15
+ runTitle: string;
16
+ unit: string;
17
+ moment: NotifyMoment;
18
+ }): NotifyMessage {
19
+ const title = `catherd · ${i.runTitle}`;
20
+ switch (i.moment) {
21
+ case "blocked":
22
+ return {
23
+ title,
24
+ message: `${i.unit} needs your answer. Answer: catherd answer ${i.runId} ${i.unit} "<your answer>"`,
25
+ tags: ["question"],
26
+ };
27
+ case "milestone":
28
+ return { title, message: `${i.unit} landed.`, tags: ["white_check_mark"] };
29
+ case "finish":
30
+ return { title, message: `The run finished: catherd status ${i.runId}`, tags: ["checkered_flag"] };
31
+ }
32
+ }
@@ -0,0 +1,198 @@
1
+ /** Plan files as a preamble and tasks (plan 32): what a lane's `Tasks:` line points at, and what an approval reads. */
2
+
3
+ export interface PlanTask {
4
+ doc: string;
5
+ id: string;
6
+ title: string;
7
+ text: string;
8
+ signature: boolean;
9
+ test: boolean;
10
+ }
11
+ export interface PlanDoc {
12
+ name: string;
13
+ preamble: string;
14
+ tasks: PlanTask[];
15
+ /** the whole file, for the section checks (a section may follow the tasks) */
16
+ text: string;
17
+ }
18
+
19
+ export const TASK_HEADING = /^#{2,4}[ \t]+Task[ \t]+(\d+[a-z]?)\b[ \t]*[:.—–-]?[ \t]*(.*)$/gim;
20
+ export const TASK_REF = /^(?:([^\s#,]+)#)?(\d+[a-z]?)$/;
21
+
22
+ const SIGNATURE = [
23
+ /^[ \t]*(?:[-*][ \t]*)?\**(?:Interfaces|Produces|Signatures?)\**[ \t]*:/im,
24
+ /^[ \t]*```/m,
25
+ /`[^`\n]*\w\([^`\n]*\)[^`\n]*`/,
26
+ ];
27
+ const TEST = [
28
+ /^[ \t]*(?:[-*][ \t]*)?\**Tests?\**[ \t]*:/im,
29
+ /[\w./-]*(?:\.test\.|_test\.|\.spec\.|\btest_)[\w.]*/,
30
+ /\b(?:it|test|describe)\(\s*["'`]|\bfunc Test\w+|\bdef test_\w+|#\[test\]/,
31
+ /^[ \t]*(?:[-*][ \t]*)?\**Expected\**[ \t]*:/im,
32
+ ];
33
+
34
+ export function parsePlanDoc(name: string, text: string): PlanDoc {
35
+ const heads = [...text.matchAll(TASK_HEADING)];
36
+ const tasks: PlanTask[] = heads.map((m, i) => {
37
+ const next = heads[i + 1]?.index ?? text.length;
38
+ const body = text.slice(m.index, next);
39
+ const nl = body.indexOf("\n");
40
+ // a task ends at the next task heading, or at the next `## ` heading after its own line
41
+ const rest = nl < 0 ? -1 : body.slice(nl).search(/^##[ \t]+(?!#)/m);
42
+ const taskText = rest < 0 ? body : body.slice(0, nl + rest);
43
+ return {
44
+ doc: name,
45
+ id: m[1] as string,
46
+ title: (m[2] ?? "").trim(),
47
+ text: taskText,
48
+ signature: SIGNATURE.some((r) => r.test(taskText)),
49
+ test: TEST.some((r) => r.test(taskText)),
50
+ };
51
+ });
52
+ const milestone = /^##[ \t]+M\d+\b/m.exec(text)?.index ?? text.length;
53
+ return { name, preamble: text.slice(0, Math.min(milestone, heads[0]?.index ?? text.length)), tasks, text };
54
+ }
55
+
56
+ const and = (names: string[]) =>
57
+ names.length === 2 ? names.join(" and ") : `${names.slice(0, -1).join(", ")} and ${names.at(-1)}`;
58
+
59
+ /** The task a lane's `Tasks:` ref names, or why it names none. */
60
+ export function resolveTask(docs: PlanDoc[], ref: string): PlanTask | string {
61
+ const m = TASK_REF.exec(ref);
62
+ if (!m) return `${ref} is not a task number like 3 or plan.md#3`;
63
+ const file = m[1];
64
+ const id = m[2] as string;
65
+ if (file !== undefined) {
66
+ const doc = docs.find((d) => d.name === file);
67
+ if (!doc) return `no plan file ${file}`;
68
+ return pick(doc, id) ?? `no task ${id} in ${file}`;
69
+ }
70
+ const owners = docs.filter((d) => d.tasks.some((t) => t.id === id));
71
+ if (owners.length === 0) return `no task ${id} in ${docs.map((d) => d.name).join(", ")}`;
72
+ if (owners.length > 1) {
73
+ const names = owners.map((d) => d.name);
74
+ return `task ${id} is in ${and(names)}: write ${names[0]}#${id}`;
75
+ }
76
+ return pick(owners[0] as PlanDoc, id) as PlanTask | string;
77
+ }
78
+
79
+ /** The one task `id` names in `doc`, or why it names none: a file that holds a number twice cannot say which. */
80
+ function pick(doc: PlanDoc, id: string): PlanTask | string | undefined {
81
+ const held = doc.tasks.filter((t) => t.id === id);
82
+ return held.length > 1 ? `${doc.name} holds Task ${id} twice: renumber one of them` : held[0];
83
+ }
84
+
85
+ /** What is wrong between a milestone's lanes and the plan: a lane with no task, a bad ref, a task with nothing to build or test. */
86
+ export function coverageProblems(
87
+ docs: PlanDoc[],
88
+ lanes: { id: string; tasks: string[] }[],
89
+ ): { lane: string; note: string }[] {
90
+ const out: { lane: string; note: string }[] = [];
91
+ const named = new Set<PlanTask>();
92
+ for (const l of lanes) {
93
+ if (l.tasks.length === 0) {
94
+ out.push({
95
+ lane: l.id,
96
+ note: `lanes/${l.id}.md has no Tasks: line: name the plan tasks it implements`,
97
+ });
98
+ continue;
99
+ }
100
+ for (const ref of l.tasks) {
101
+ const r = resolveTask(docs, ref);
102
+ if (typeof r === "string") out.push({ lane: l.id, note: `lanes/${l.id}.md: Tasks: ${ref} — ${r}` });
103
+ else named.add(r);
104
+ }
105
+ }
106
+ // only a task a lane builds needs something to build or test: the rest of the plan is the owner's
107
+ for (const t of named)
108
+ if (!t.signature && !t.test)
109
+ out.push({
110
+ lane: "",
111
+ note: `${t.doc} Task ${t.id} has neither a signature nor a test: add its signatures (a code block or an Interfaces: line) or a Test: line`,
112
+ });
113
+ return out;
114
+ }
115
+
116
+ export const DEPTH_SECTIONS = ["What already exists", "Contracts", "Decisions"] as const;
117
+
118
+ /** The text under a `## <name>` heading (case-insensitive prefix), up to the next `## ` heading; null when absent. */
119
+ function section(text: string, name: string): string | null {
120
+ const lines = text.split("\n");
121
+ const at = lines.findIndex((l) => new RegExp(`^##[ \\t]+${name}`, "i").test(l));
122
+ if (at < 0) return null;
123
+ const end = lines.findIndex((l, i) => i > at && /^##[ \t]+(?!#)/.test(l));
124
+ return lines.slice(at + 1, end < 0 ? undefined : end).join("\n");
125
+ }
126
+
127
+ const DECISION = /^[ \t]*(?:[-*][ \t]*)?\**(D\d+)\b/;
128
+
129
+ /** What a plan the architect wrote lacks, to the depth the owner reviews: one line per gap. */
130
+ export function depthProblems(doc: PlanDoc): string[] {
131
+ const out: string[] = [];
132
+ const full = doc.text;
133
+ for (const s of DEPTH_SECTIONS) if (section(full, s) === null) out.push(`plan.md has no "## ${s}" section`);
134
+ if (doc.tasks.length === 0) out.push('plan.md has no "### Task <n>:" heading');
135
+ for (const t of doc.tasks)
136
+ if (!t.test) out.push(`plan.md Task ${t.id} has no test: add a Test: line naming its test file and case`);
137
+ for (const line of (section(full, "Decisions") ?? "").split("\n")) {
138
+ const id = DECISION.exec(line)?.[1];
139
+ if (id && !/rejected/i.test(line))
140
+ out.push(
141
+ `plan.md ${id} names no rejected alternative: write ${id} — <choice> — rejected: <alternative> — <why>`,
142
+ );
143
+ }
144
+ const exists = section(full, "What already exists");
145
+ if (exists !== null && !/`[^`\n]*(?:\/|\.\w+)[^`\n]*`/.test(exists))
146
+ out.push("plan.md's What already exists lists no path");
147
+ return out;
148
+ }
149
+
150
+ export const APPROVE_WORDS = ["approve", "approved", "yes", "ok", "lgtm"] as const;
151
+
152
+ /** A bare approving word, case and a trailing `.`/`!` ignored; "ok but rename X" is a change request. */
153
+ export function isApproval(answer: string): boolean {
154
+ const w = answer
155
+ .trim()
156
+ .toLowerCase()
157
+ .replace(/[.!]+$/, "");
158
+ return (APPROVE_WORDS as readonly string[]).includes(w);
159
+ }
160
+
161
+ export const approvalQuestion = (runId: string) =>
162
+ `The architect's plan is ready for your review: catherd run-file read ${runId} plan.md. Answer approve, or say what to change.`;
163
+
164
+ export const reapprovalQuestion = (runId: string, ids: string[]) =>
165
+ `The architect changed approved decisions ${ids.join(", ")} of plan.md: catherd run-file read ${runId} plan.md. Answer approve, or say what to change.`;
166
+
167
+ /** Each `D<n>` line of the Decisions section, keyed by its id, whitespace collapsed. */
168
+ export function decisionLines(text: string): Map<string, string> {
169
+ const out = new Map<string, string>();
170
+ for (const line of (section(text, "Decisions") ?? "").split("\n")) {
171
+ const id = DECISION.exec(line)?.[1];
172
+ if (id) out.set(id, line.trim().replace(/\s+/g, " "));
173
+ }
174
+ return out;
175
+ }
176
+
177
+ /** The `D<n>` ids added, removed or edited between two plan texts, in number order. */
178
+ export function changedDecisions(approved: string, now: string): string[] {
179
+ const a = decisionLines(approved);
180
+ const b = decisionLines(now);
181
+ return [...new Set([...a.keys(), ...b.keys()])]
182
+ .filter((id) => a.get(id) !== b.get(id))
183
+ .sort((x, y) => Number(x.slice(1)) - Number(y.slice(1)));
184
+ }
185
+
186
+ /** The copies' file names: basenames, a repeat numbered `-2` before its extension. */
187
+ export function planInputNames(paths: string[]): string[] {
188
+ const used = new Set<string>();
189
+ return paths.map((p) => {
190
+ const base = p.split("/").at(-1) as string;
191
+ const dot = base.lastIndexOf(".");
192
+ const [stem, ext] = dot > 0 ? [base.slice(0, dot), base.slice(dot)] : [base, ""];
193
+ let name = base;
194
+ for (let n = 2; used.has(name); n++) name = `${stem}-${n}${ext}`;
195
+ used.add(name);
196
+ return name;
197
+ });
198
+ }
@@ -0,0 +1,141 @@
1
+ import { type LaneHeader, parseLaneHeader } from "./lane.ts";
2
+
3
+ export interface PlanLint {
4
+ /** advice only: preflight reports it and goes on */
5
+ warnings: string[];
6
+ /** an `After:` that can never finish: the lane (or the cycle's first lane) never starts */
7
+ stuck: { lane: string; note: string }[];
8
+ }
9
+
10
+ const milestoneOf = (id: string): string => id.split(".")[0] as string;
11
+ const isFolder = (p: string): boolean => p.endsWith("/");
12
+
13
+ /**
14
+ * Lint of the lanes in scope. `known` is every lane id of the run (a bare milestone `Mx` is known when one of its lanes is), the landed milestones' too, so an `After:` on a
15
+ * lane that already landed is no error. `finished` is the milestones that landed (merged, in a base run): an `After:`
16
+ * on a lane of any other milestone can never finish, as that milestone does not start before this one is done. Never throws: a lane whose header cannot be read is left out.
17
+ */
18
+ export function lintLanes(
19
+ lanes: { id: string; text: string }[],
20
+ known: string[],
21
+ finished: string[] = [],
22
+ ): PlanLint {
23
+ const warnings: string[] = [];
24
+ const stuck: PlanLint["stuck"] = [];
25
+ const headers = new Map<string, LaneHeader>();
26
+ for (const l of lanes) {
27
+ try {
28
+ headers.set(l.id, parseLaneHeader(l.text));
29
+ } catch {
30
+ // a header that cannot be read is preflight's refusal to make, not lint's
31
+ }
32
+ }
33
+ const inScope = lanes.filter((l) => headers.has(l.id));
34
+ const milestones = new Set(known.map(milestoneOf));
35
+
36
+ for (const { id } of inScope) {
37
+ const h = headers.get(id) as LaneHeader;
38
+ const folders = h.owns.filter(isFolder);
39
+ if (folders.length)
40
+ warnings.push(
41
+ `${id}: Owns names whole folders (${folders.join(", ")}): name the files, unless the lane creates the folder`,
42
+ );
43
+ for (const a of h.after) {
44
+ if (a === id) stuck.push({ lane: id, note: `${id}: After: names the lane itself` });
45
+ else if (!a.includes(".") && a === milestoneOf(id))
46
+ stuck.push({ lane: id, note: `${id}: After: ${a} is its own milestone: this lane never starts` });
47
+ else if (a.includes(".") ? !known.includes(a) : !milestones.has(a))
48
+ stuck.push({ lane: id, note: `${id}: After: ${a} has no lane file` });
49
+ else if (milestoneOf(a) !== milestoneOf(id) && !finished.includes(milestoneOf(a)))
50
+ stuck.push({
51
+ lane: id,
52
+ note: `${id}: After: ${a} is in milestone ${milestoneOf(a)}, which has not finished: this lane never starts`,
53
+ });
54
+ else {
55
+ const other = headers.get(a);
56
+ if (
57
+ other &&
58
+ h.kind !== "research" &&
59
+ other.kind !== "research" &&
60
+ h.owns.length &&
61
+ other.owns.length &&
62
+ !h.owns.some(isFolder) &&
63
+ !other.owns.some(isFolder) &&
64
+ !h.owns.some((p) => other.owns.includes(p))
65
+ )
66
+ warnings.push(
67
+ `${id}: After: ${a} shares no file with it: drop it unless ${id} needs ${a}'s output`,
68
+ );
69
+ }
70
+ }
71
+ }
72
+
73
+ const edges = (id: string): string[] =>
74
+ (headers.get(id)?.after ?? []).filter((a) => a !== id && headers.has(a));
75
+
76
+ const state = new Map<string, "visiting" | "done">();
77
+ const path: string[] = [];
78
+ const visit = (id: string): void => {
79
+ state.set(id, "visiting");
80
+ path.push(id);
81
+ for (const a of edges(id)) {
82
+ if (state.get(a) === "visiting") {
83
+ const loop = [...path.slice(path.indexOf(a)), a];
84
+ stuck.push({ lane: a, note: `After: cycle ${loop.join(" → ")}: these lanes never start` });
85
+ } else if (!state.has(a)) visit(a);
86
+ }
87
+ path.pop();
88
+ state.set(id, "done");
89
+ };
90
+ for (const { id } of inScope) if (!state.has(id)) visit(id);
91
+
92
+ const depth = new Map<string, number>();
93
+ const depthOf = (id: string): number => {
94
+ const seen = depth.get(id);
95
+ if (seen !== undefined) return seen;
96
+ depth.set(id, 1);
97
+ const d =
98
+ 1 +
99
+ Math.max(
100
+ 0,
101
+ ...edges(id)
102
+ .filter((a) => milestoneOf(a) === milestoneOf(id))
103
+ .map(depthOf),
104
+ );
105
+ depth.set(id, d);
106
+ return d;
107
+ };
108
+ const byMilestone = new Map<string, string[]>();
109
+ for (const { id } of inScope)
110
+ byMilestone.set(milestoneOf(id), [...(byMilestone.get(milestoneOf(id)) ?? []), id]);
111
+ for (const [m, ids] of byMilestone) {
112
+ if (ids.length < 2 || !ids.some((id) => edges(id).some((a) => milestoneOf(a) === m))) continue;
113
+ const k = Math.max(...ids.map(depthOf));
114
+ warnings.push(
115
+ `info: ${m} critical path ${k} of ${ids.length} lanes${k === ids.length ? ": fully serial" : ""}`,
116
+ );
117
+ }
118
+ return { warnings, stuck };
119
+ }
120
+
121
+ /** What keeps the milestones from ever starting: an `after` naming no milestone of the plan, or a wait cycle. */
122
+ export function milestoneProblems(milestones: { id: string; after: string[] }[]): string[] {
123
+ const after = new Map(milestones.map((m) => [m.id, m.after]));
124
+ const out: string[] = [];
125
+ for (const m of milestones)
126
+ for (const a of m.after) if (!after.has(a)) out.push(`${m.id}: After: ${a} is no milestone of the plan`);
127
+ const done = new Set<string>();
128
+ const path: string[] = [];
129
+ const visit = (id: string): void => {
130
+ path.push(id);
131
+ for (const a of after.get(id) ?? []) {
132
+ if (path.includes(a))
133
+ out.push(`milestones wait on each other (cycle ${[...path.slice(path.indexOf(a)), a].join(" → ")})`);
134
+ else if (!done.has(a) && after.has(a)) visit(a);
135
+ }
136
+ path.pop();
137
+ done.add(id);
138
+ };
139
+ for (const m of milestones) if (!done.has(m.id)) visit(m.id);
140
+ return out;
141
+ }