catherd-cli 0.2.1 → 1.1.0

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 (207) hide show
  1. package/README.md +141 -33
  2. package/THIRD_PARTY_NOTICES.md +37 -0
  3. package/catalog/jev.json +89 -0
  4. package/catalog/models.json +236 -0
  5. package/catalog/scores.json +366 -0
  6. package/package.json +24 -8
  7. package/plugin/.claude-plugin/plugin.json +2 -2
  8. package/plugin/.mcp.json +2 -2
  9. package/plugin/bin/catherd-mcp +30 -0
  10. package/plugin/commands/catherd.md +2 -2
  11. package/plugin/skills/catherd/SKILL.md +160 -97
  12. package/plugin/skills/catherd-setup/SKILL.md +34 -24
  13. package/src/adapters/access.ts +63 -0
  14. package/src/adapters/all.ts +8 -0
  15. package/src/adapters/backend.ts +159 -0
  16. package/src/adapters/claude-code/events.ts +86 -0
  17. package/src/adapters/claude-code/index.ts +317 -0
  18. package/src/adapters/claude-code/models-api.ts +65 -0
  19. package/src/adapters/claude-code/models.ts +26 -0
  20. package/src/adapters/cli.ts +74 -0
  21. package/src/adapters/codex/events.ts +65 -0
  22. package/src/adapters/codex/home.ts +47 -0
  23. package/src/adapters/codex/index.ts +282 -0
  24. package/src/adapters/discovery.ts +87 -0
  25. package/src/adapters/opencode/agents.ts +84 -0
  26. package/src/adapters/opencode/events.ts +109 -0
  27. package/src/adapters/opencode/index.ts +333 -0
  28. package/src/adapters/registry.ts +14 -0
  29. package/src/cli.ts +159 -19
  30. package/src/domain/agents.ts +60 -0
  31. package/src/domain/budget.ts +45 -0
  32. package/src/domain/catalog.ts +262 -0
  33. package/src/domain/changes.ts +52 -0
  34. package/src/domain/cost.ts +89 -0
  35. package/src/domain/errors.ts +60 -0
  36. package/src/domain/failover.ts +119 -0
  37. package/src/domain/hints.ts +22 -0
  38. package/src/domain/ids.ts +97 -0
  39. package/src/domain/jev.ts +283 -0
  40. package/src/domain/lane.ts +92 -0
  41. package/src/domain/notice.ts +95 -0
  42. package/src/domain/profile-rules.ts +239 -0
  43. package/src/domain/profile.ts +389 -0
  44. package/src/domain/record.ts +86 -0
  45. package/src/{profile → domain}/role-prompts.ts +75 -27
  46. package/src/domain/roles.ts +25 -0
  47. package/src/domain/route.ts +126 -0
  48. package/src/domain/runtime.ts +21 -0
  49. package/src/domain/sanitize.ts +29 -0
  50. package/src/domain/secrets.ts +57 -0
  51. package/src/domain/select.ts +136 -0
  52. package/src/domain/state.ts +49 -0
  53. package/src/domain/util.ts +18 -0
  54. package/src/entry/capture-fixtures-command.ts +57 -0
  55. package/src/entry/catalog-command.ts +106 -0
  56. package/src/entry/cli-kit.ts +36 -0
  57. package/src/entry/deps.ts +17 -0
  58. package/src/entry/doctor-command.ts +51 -0
  59. package/src/entry/glyphs.ts +34 -0
  60. package/src/entry/init-command.ts +182 -0
  61. package/src/entry/lock-command.ts +123 -0
  62. package/src/{mcp/commands.ts → entry/mcp/command.ts} +5 -1
  63. package/src/entry/mcp/dispatch-tools.ts +52 -0
  64. package/src/entry/mcp/handshake.ts +58 -0
  65. package/src/entry/mcp/lane-tools.ts +84 -0
  66. package/src/entry/mcp/protocol-tools.ts +57 -0
  67. package/src/entry/mcp/result.ts +51 -0
  68. package/src/entry/mcp/run-tools.ts +145 -0
  69. package/src/entry/mcp/server.ts +88 -0
  70. package/src/entry/mcp/setup-tools.ts +81 -0
  71. package/src/entry/profile-command.ts +318 -0
  72. package/src/entry/prompt.ts +103 -0
  73. package/src/entry/runs-command.ts +260 -0
  74. package/src/entry/supervise-bin.ts +18 -0
  75. package/src/entry/supervise-command.ts +37 -0
  76. package/src/entry/tui/commands.ts +545 -0
  77. package/src/entry/tui/effects.ts +312 -0
  78. package/src/entry/tui/fixtures.ts +585 -0
  79. package/src/entry/tui/profile-edits.ts +138 -0
  80. package/src/entry/tui/profile-tree.ts +420 -0
  81. package/src/entry/tui/providers/app.tsx +162 -0
  82. package/src/entry/tui/providers/boundary.tsx +57 -0
  83. package/src/entry/tui/providers/data.tsx +160 -0
  84. package/src/entry/tui/providers/keymap.tsx +166 -0
  85. package/src/entry/tui/providers/root.tsx +45 -0
  86. package/src/entry/tui/providers/theme.tsx +17 -0
  87. package/src/entry/tui/providers/toast.tsx +77 -0
  88. package/src/entry/tui/run.tsx +98 -0
  89. package/src/entry/tui/state.ts +356 -0
  90. package/src/entry/tui/stories.ts +107 -0
  91. package/src/entry/tui/text.ts +130 -0
  92. package/src/entry/tui/theme.ts +140 -0
  93. package/src/entry/tui/views/app.tsx +208 -0
  94. package/src/entry/tui/views/dialogs.tsx +41 -0
  95. package/src/entry/tui/views/profile-actions.ts +321 -0
  96. package/src/entry/tui/views/profiles.tsx +440 -0
  97. package/src/entry/tui/views/runs.tsx +573 -0
  98. package/src/entry/tui/views/save-dialog.tsx +332 -0
  99. package/src/entry/tui/views/status.tsx +209 -0
  100. package/src/entry/tui/widgets/chrome.tsx +114 -0
  101. package/src/entry/tui/widgets/dialog-confirm.tsx +58 -0
  102. package/src/entry/tui/widgets/dialog-prompt.tsx +52 -0
  103. package/src/entry/tui/widgets/dialog-select.tsx +212 -0
  104. package/src/entry/tui/widgets/dialog.tsx +68 -0
  105. package/src/entry/tui/widgets/line.tsx +92 -0
  106. package/src/entry/tui/widgets/list.tsx +197 -0
  107. package/src/entry/tui/widgets/spinner.tsx +49 -0
  108. package/src/entry/tui/widgets/toast.tsx +62 -0
  109. package/src/infra/assets.ts +4 -0
  110. package/src/infra/claude-session.ts +98 -0
  111. package/src/infra/dispatch-dir.ts +286 -0
  112. package/src/infra/env.ts +67 -0
  113. package/src/infra/filelock.ts +159 -0
  114. package/src/infra/git.ts +91 -0
  115. package/src/infra/heavy-lock.ts +36 -0
  116. package/src/infra/jev-client.ts +162 -0
  117. package/src/infra/launch.ts +25 -0
  118. package/src/infra/log.ts +84 -0
  119. package/src/infra/paths.ts +38 -0
  120. package/src/infra/peer-inbox.ts +92 -0
  121. package/src/infra/proc.ts +110 -0
  122. package/src/infra/store.ts +229 -0
  123. package/src/infra/supervisor.ts +287 -0
  124. package/src/infra/version.ts +6 -0
  125. package/src/services/admission.ts +303 -0
  126. package/src/services/agent-links.ts +139 -0
  127. package/src/services/backends.ts +44 -0
  128. package/src/services/budget.ts +47 -0
  129. package/src/services/capture.ts +233 -0
  130. package/src/services/catalog-service.ts +382 -0
  131. package/src/services/dispatch-service.ts +620 -0
  132. package/src/services/dispatches.ts +174 -0
  133. package/src/services/doctor-access.ts +231 -0
  134. package/src/services/doctor-backends.ts +170 -0
  135. package/src/services/doctor-checks.ts +148 -0
  136. package/src/services/doctor-push.ts +109 -0
  137. package/src/services/doctor.ts +338 -0
  138. package/src/services/finalize.ts +377 -0
  139. package/src/services/gate-service.ts +288 -0
  140. package/src/services/global-install.ts +99 -0
  141. package/src/services/jev-service.ts +183 -0
  142. package/src/services/lane-service.ts +390 -0
  143. package/src/services/milestones.ts +208 -0
  144. package/src/services/notifier.ts +296 -0
  145. package/src/services/peek.ts +103 -0
  146. package/src/services/ports.ts +135 -0
  147. package/src/services/preflight.ts +221 -0
  148. package/src/services/profile-service.ts +292 -0
  149. package/src/services/profile-store.ts +141 -0
  150. package/src/services/protocol.ts +212 -0
  151. package/src/services/questions.ts +102 -0
  152. package/src/services/reconcile.ts +130 -0
  153. package/src/services/reentry.ts +22 -0
  154. package/src/services/routing-service.ts +190 -0
  155. package/src/services/run-debug.ts +79 -0
  156. package/src/services/run-service.ts +194 -0
  157. package/src/services/run-store.ts +334 -0
  158. package/src/services/runs-page.ts +254 -0
  159. package/src/services/session-view.ts +152 -0
  160. package/src/services/sessions.ts +92 -0
  161. package/src/services/setup.ts +87 -0
  162. package/src/services/state.ts +111 -0
  163. package/src/services/summary.ts +241 -0
  164. package/catalog/catalog.json +0 -112
  165. package/catalog/models-dev.json +0 -56453
  166. package/src/core/codex-events.ts +0 -49
  167. package/src/core/codex.ts +0 -166
  168. package/src/core/harness.ts +0 -89
  169. package/src/core/lanes.ts +0 -67
  170. package/src/core/lock.ts +0 -111
  171. package/src/core/opencode.ts +0 -207
  172. package/src/core/reconcile.ts +0 -26
  173. package/src/core/reply.ts +0 -54
  174. package/src/core/runstore.ts +0 -163
  175. package/src/core/spawn.ts +0 -89
  176. package/src/core/status.ts +0 -193
  177. package/src/files.ts +0 -19
  178. package/src/mcp/dispatch.ts +0 -154
  179. package/src/mcp/lane-tools.ts +0 -200
  180. package/src/mcp/out.ts +0 -6
  181. package/src/mcp/preflight.ts +0 -84
  182. package/src/mcp/run-tools.ts +0 -121
  183. package/src/mcp/runs.ts +0 -118
  184. package/src/mcp/server.ts +0 -26
  185. package/src/mcp/setup-tools.ts +0 -175
  186. package/src/paths.ts +0 -20
  187. package/src/profile/agents.ts +0 -130
  188. package/src/profile/profile.ts +0 -228
  189. package/src/routing/catalog.ts +0 -227
  190. package/src/routing/commands.ts +0 -22
  191. package/src/routing/jev.ts +0 -185
  192. package/src/routing/route.ts +0 -155
  193. package/src/routing/select.ts +0 -90
  194. package/src/tui/backends.ts +0 -57
  195. package/src/tui/commands.ts +0 -53
  196. package/src/tui/dashboard.tsx +0 -245
  197. package/src/tui/editor.tsx +0 -214
  198. package/src/tui/init.tsx +0 -208
  199. package/src/tui/matrix-model.ts +0 -279
  200. package/src/tui/matrix.tsx +0 -512
  201. package/src/tui/profiles.ts +0 -38
  202. package/src/tui/theme.ts +0 -177
  203. package/src/tui/ui.tsx +0 -267
  204. package/src/tui/watch-model.ts +0 -83
  205. package/src/tui/watch.tsx +0 -236
  206. package/src/types.ts +0 -184
  207. package/src/version.ts +0 -7
@@ -0,0 +1,138 @@
1
+ import { type Catalog, rungInfo, scoresOf } from "../../domain/catalog.ts";
2
+ import { parseRung, tryParseRung } from "../../domain/ids.ts";
3
+ import { NOTIFY, type Profile, type ProfilePatch } from "../../domain/profile.ts";
4
+ import { inferredScores, quotaOf } from "../../domain/profile-rules.ts";
5
+ import { ACCESS } from "../../domain/record.ts";
6
+ import type { Role } from "../../domain/roles.ts";
7
+ import type { CatalogModel } from "../../services/catalog-service.ts";
8
+ import type { RowAction } from "./profile-tree.ts";
9
+ import type { NumberPath, SelectOption } from "./state.ts";
10
+ import { shortRung } from "./text.ts";
11
+
12
+ // What the Profiles tab's rows change: the patch a toggle or cycle makes, number fields, and the options
13
+ // each picker dialog offers. profile-tree.ts builds the rows themselves.
14
+
15
+ const next = <T>(list: readonly T[], cur: T): T => list[(list.indexOf(cur) + 1) % list.length] as T;
16
+
17
+ /**
18
+ * The patch a toggle (space) or a cycle (enter) makes on the draft; null for rows that open a dialog
19
+ * or expand instead. Space on a model row never changes a rung (research K5).
20
+ */
21
+ export function patchFor(p: Profile, a: RowAction): ProfilePatch | null {
22
+ switch (a.type) {
23
+ case "role":
24
+ return { roles: { [a.role]: { enabled: !p.roles[a.role].enabled } } };
25
+ case "access":
26
+ return { roles: { [a.role]: { access: next(ACCESS, p.roles[a.role].access) } } };
27
+ case "rung": {
28
+ const rc = p.roles[a.role];
29
+ if (!rc.rungs.includes(a.rung)) return { roles: { [a.role]: { rungs: [...rc.rungs, a.rung] } } };
30
+ return {
31
+ roles: {
32
+ [a.role]: {
33
+ rungs: rc.rungs.filter((r) => r !== a.rung),
34
+ ...(rc.defaultRung === a.rung ? { defaultRung: null } : {}),
35
+ },
36
+ },
37
+ };
38
+ }
39
+ case "objective":
40
+ return { objective: p.objective === "cost" ? "speed" : "cost" };
41
+ case "jev":
42
+ return { jev: { use: p.jev.use === "auto" ? "off" : "auto" } };
43
+ case "isolated":
44
+ return { harness: { [a.harness]: { isolated: !(p.harness[a.harness]?.isolated ?? false) } } };
45
+ case "notify":
46
+ return {
47
+ notify: p.notify.includes(a.moment)
48
+ ? p.notify.filter((m) => m !== a.moment)
49
+ : NOTIFY.filter((m) => m === a.moment || p.notify.includes(m)),
50
+ };
51
+ default:
52
+ return null;
53
+ }
54
+ }
55
+
56
+ /** A number field set, or cleared with null (a budget cap); timeouts cannot be cleared. */
57
+ export function numberPatch(path: NumberPath, value: number | null): ProfilePatch {
58
+ const [head, leaf] = path.split(".") as ["budget" | "timeouts", string];
59
+ return { [head]: { [leaf]: value } } as ProfilePatch;
60
+ }
61
+
62
+ /** Parses a value editor's text: a positive number, or empty for no cap where the field allows one. */
63
+ export function parseNumber(path: NumberPath, text: string): { value: number | null } | { error: string } {
64
+ const t = text.trim();
65
+ if (t === "")
66
+ return path.startsWith("budget.") ? { value: null } : { error: "enter a number of minutes above 0" };
67
+ const n = Number(t);
68
+ if (!Number.isFinite(n) || n <= 0) return { error: `"${t}" is not a number above 0` };
69
+ if (path === "budget.tokens" && !Number.isInteger(n)) return { error: "tokens are a whole number" };
70
+ return { value: n };
71
+ }
72
+
73
+ export function numberValue(p: Profile, path: NumberPath): number | undefined {
74
+ const [head, leaf] = path.split(".") as ["budget" | "timeouts", string];
75
+ return (p[head] as Record<string, number | undefined>)[leaf];
76
+ }
77
+
78
+ /** The default-rung picker: the role's rungs, and none. */
79
+ export function startOptions(p: Profile, role: Role): SelectOption[] {
80
+ const rc = p.roles[role];
81
+ return [
82
+ {
83
+ value: "",
84
+ title: "none: the cheapest rung that clears the bar",
85
+ current: rc.defaultRung === undefined,
86
+ },
87
+ ...rc.rungs.map((r) => ({ value: r, title: shortRung(r), detail: r, current: r === rc.defaultRung })),
88
+ ];
89
+ }
90
+
91
+ /** The failover picker: every usable rung on another quota (Ruling 3 of plan 5), and none. */
92
+ export function failoverOptions(
93
+ p: Profile,
94
+ models: CatalogModel[],
95
+ c: Catalog,
96
+ rung: string,
97
+ ): SelectOption[] {
98
+ const parsed = tryParseRung(rung);
99
+ if (!parsed) return [];
100
+ const quota = quotaOf(parsed);
101
+ const cur = p.failover[rung];
102
+ const out: SelectOption[] = [{ value: "", title: "none", current: cur === undefined }];
103
+ // usable: scored in catalog_query, or through a treat-like staged in `c` (`withStaged`)
104
+ const usable = (x: string) => {
105
+ try {
106
+ return scoresOf(c, rungInfo(c, x).canonical) !== null;
107
+ } catch {
108
+ return false;
109
+ }
110
+ };
111
+ for (const m of models)
112
+ for (const r of m.rungs) {
113
+ if (!(r.enabled || usable(r.rung)) || r.rung.startsWith("claude:")) continue;
114
+ const q = quotaOf(parseRung(r.rung));
115
+ if (q === quota) continue;
116
+ const via = inferredScores(c, rungInfo(c, r.rung)).via;
117
+ out.push({
118
+ value: r.rung,
119
+ title: shortRung(r.rung),
120
+ group: m.billing,
121
+ detail: via ? `scores borrowed from ${via}` : "",
122
+ current: r.rung === cur,
123
+ });
124
+ }
125
+ return out;
126
+ }
127
+
128
+ /** The treat-like picker: every rung with scores of its own, by model. */
129
+ export function treatLikeOptions(c: Catalog): SelectOption[] {
130
+ return Object.keys(c.scores)
131
+ .filter((canonical) => scoresOf(c, canonical)?.via === null)
132
+ .sort()
133
+ .map((canonical) => ({
134
+ value: canonical,
135
+ title: canonical,
136
+ group: canonical.slice(0, canonical.lastIndexOf("#")),
137
+ }));
138
+ }
@@ -0,0 +1,420 @@
1
+ import { billingKeyOf, type Catalog, rungInfo } from "../../domain/catalog.ts";
2
+ import { tryParseRung } from "../../domain/ids.ts";
3
+ import { NOTIFY, type NotifyMoment, type Profile } from "../../domain/profile.ts";
4
+ import { type Issue, inferredScores, routingProfileOf, type Validation } from "../../domain/profile-rules.ts";
5
+ import type { Access } from "../../domain/record.ts";
6
+ import { ROLES, type Role } from "../../domain/roles.ts";
7
+ import { candidates } from "../../domain/select.ts";
8
+ import type { CatalogModel } from "../../services/catalog-service.ts";
9
+ import type { NumberPath } from "./state.ts";
10
+ import { shortRung } from "./text.ts";
11
+
12
+ export type RowAction =
13
+ | { type: "section" }
14
+ | { type: "role"; role: Role }
15
+ | { type: "access"; role: Role }
16
+ | { type: "start"; role: Role }
17
+ | { type: "group" }
18
+ | { type: "model" }
19
+ | { type: "rung"; role: Role; rung: string; scored: boolean }
20
+ | { type: "objective" }
21
+ | { type: "jev" }
22
+ | { type: "isolated"; harness: string }
23
+ | { type: "number"; path: NumberPath }
24
+ | { type: "failover"; rung: string }
25
+ | { type: "notify"; moment: NotifyMoment };
26
+
27
+ /** One line of the Profiles tree (spec §9.1: roles → access + backend → model → efforts, then settings). */
28
+ export interface Row {
29
+ key: string;
30
+ parent: string | null;
31
+ depth: number;
32
+ label: string;
33
+ value: string;
34
+ /** a checkbox's state; null for a row that is not one */
35
+ check: boolean | null;
36
+ expandable: boolean;
37
+ expanded: boolean;
38
+ /** headings are skipped by the cursor */
39
+ selectable: boolean;
40
+ /** drawn muted: an unusable rung, the children of a role that is off */
41
+ dim: boolean;
42
+ issue: (Issue & { level: "error" | "warning" }) | null;
43
+ action: RowAction;
44
+ /** what the filter matches: the labels of the row's parents, its own label and its value */
45
+ search: string;
46
+ }
47
+
48
+ export interface TreeInput {
49
+ /** the draft, resolved */
50
+ profile: Profile;
51
+ models: CatalogModel[];
52
+ /** the catalog with the draft's staged treat-likes merged in (`withStaged`) */
53
+ catalog: Catalog;
54
+ staged: Record<string, string>;
55
+ expanded: ReadonlySet<string>;
56
+ /** the harnesses catherd can isolate: the registered adapters */
57
+ harnesses: readonly string[];
58
+ enforcement: (rung: string, access: Access) => "enforced" | "advisory";
59
+ validation: Validation;
60
+ /** every expandable row open, for filtering */
61
+ expandAll?: boolean;
62
+ }
63
+
64
+ /** The catalog as it will be once the staged treat-likes are saved. */
65
+ export function withStaged(c: Catalog, staged: Record<string, string>): Catalog {
66
+ if (Object.keys(staged).length === 0) return c;
67
+ const treatLike = { ...c.treatLike };
68
+ for (const [rung, like] of Object.entries(staged)) {
69
+ try {
70
+ treatLike[rungInfo(c, rung).canonical] = { like, source: "user" };
71
+ } catch {
72
+ // a staged rung that does not parse is never offered
73
+ }
74
+ }
75
+ return { ...c, treatLike };
76
+ }
77
+
78
+ const GROUP_ORDER = ["claude", "claude-code", "codex", "opencode-go", "opencode", "cursor", "grok"];
79
+ const GROUP_NOTE: Record<string, string> = { claude: "native subagent", "claude-code": "headless" };
80
+
81
+ interface ModelEntry {
82
+ backend: string;
83
+ model: string;
84
+ key: string;
85
+ efforts: string[];
86
+ listed: boolean | null;
87
+ /** efforts catalog_query scores (their own or through a saved treat-like) */
88
+ scored: Set<string>;
89
+ inferred: Set<string>;
90
+ }
91
+
92
+ function issueFor(v: Validation, match: (path: string) => boolean): Row["issue"] {
93
+ const e = v.errors.find((i) => match(i.path));
94
+ if (e) return { ...e, level: "error" };
95
+ const w = v.warnings.find((i) => match(i.path));
96
+ return w ? { ...w, level: "warning" } : null;
97
+ }
98
+
99
+ /** A role's models: those catalog_query says can fill it, plus any rung the profile holds that it lacks. */
100
+ function modelsFor(models: CatalogModel[], role: Role, rungs: string[]): ModelEntry[] {
101
+ const out: ModelEntry[] = [];
102
+ const find = (backend: string, model: string) =>
103
+ out.find((m) => m.backend === backend && m.model === model);
104
+ for (const m of models) {
105
+ if (!m.roles.includes(role)) continue;
106
+ const scored = new Set<string>();
107
+ const inferred = new Set<string>();
108
+ for (const r of m.rungs) {
109
+ const effort = r.rung.slice(r.rung.lastIndexOf("#") + 1);
110
+ if (r.enabled) scored.add(effort);
111
+ if (r.treatLike || Object.values(r.scores).some((s) => s.confidence === "inferred"))
112
+ inferred.add(effort);
113
+ }
114
+ out.push({
115
+ backend: m.backend,
116
+ model: m.model,
117
+ key: m.billing,
118
+ efforts: m.efforts.length ? [...m.efforts] : ["default"],
119
+ listed: m.listed,
120
+ scored,
121
+ inferred,
122
+ });
123
+ }
124
+ for (const rung of rungs) {
125
+ const r = tryParseRung(rung);
126
+ if (!r) continue;
127
+ const m = find(r.backend, r.model);
128
+ if (m) {
129
+ if (!m.efforts.includes(r.effort)) m.efforts.push(r.effort);
130
+ continue;
131
+ }
132
+ out.push({
133
+ backend: r.backend,
134
+ model: r.model,
135
+ key: billingKeyOf(r),
136
+ efforts: [r.effort],
137
+ listed: null,
138
+ scored: new Set(),
139
+ inferred: new Set(),
140
+ });
141
+ }
142
+ return out;
143
+ }
144
+
145
+ const rungOf = (m: ModelEntry, effort: string) => `${m.backend}:${m.model}#${effort}`;
146
+
147
+ /** The ladder routing would climb, cheapest first, as short rungs. */
148
+ function ladderOf(i: TreeInput, role: Role): string {
149
+ const rc = i.profile.roles[role];
150
+ if (!rc.enabled) return "off";
151
+ const ladder = candidates(i.catalog, routingProfileOf(i.profile, role), role).map((c) => shortRung(c.rung));
152
+ return ladder.length ? ladder.join(" → ") : "no usable rung";
153
+ }
154
+
155
+ /** Spec §9.1's Profiles tree for the draft: one row per line, in order. */
156
+ export function buildRows(i: TreeInput): Row[] {
157
+ const rows: Row[] = [];
158
+ const labels = new Map<string, string>();
159
+ const open = (key: string) => i.expandAll === true || i.expanded.has(key);
160
+ const add = (
161
+ r: Omit<Row, "search" | "dim" | "issue" | "expandable" | "expanded" | "selectable" | "check"> &
162
+ Partial<Pick<Row, "dim" | "issue" | "expandable" | "selectable" | "check">>,
163
+ ) => {
164
+ const path = `${r.parent ? (labels.get(r.parent) ?? "") : ""} ${r.label}`;
165
+ labels.set(r.key, path);
166
+ const row: Row = {
167
+ check: null,
168
+ dim: false,
169
+ issue: null,
170
+ expandable: false,
171
+ selectable: true,
172
+ ...r,
173
+ expanded: r.expandable === true && open(r.key),
174
+ search: `${path} ${r.value}`.toLowerCase(),
175
+ };
176
+ rows.push(row);
177
+ return row;
178
+ };
179
+ const section = (key: string, label: string) =>
180
+ add({ key, parent: null, depth: 0, label, value: "", selectable: false, action: { type: "section" } });
181
+
182
+ section("section:roles", "ROLES");
183
+ for (const role of ROLES) {
184
+ const rc = i.profile.roles[role];
185
+ const roleKey = `role:${role}`;
186
+ const roleRow = add({
187
+ key: roleKey,
188
+ parent: "section:roles",
189
+ depth: 1,
190
+ label: role,
191
+ value: ladderOf(i, role),
192
+ check: rc.enabled,
193
+ expandable: true,
194
+ issue: issueFor(
195
+ i.validation,
196
+ (p) =>
197
+ p === `roles.${role}.enabled` || p === `roles.${role}.rungs` || p === `roles.${role}.defaultRung`,
198
+ ),
199
+ action: { type: "role", role },
200
+ });
201
+ if (!roleRow.expanded) continue;
202
+ const dim = !rc.enabled;
203
+ const weakest = rc.rungs.some((r) => i.enforcement(r, rc.access) === "advisory")
204
+ ? "advisory"
205
+ : "enforced";
206
+ add({
207
+ key: `access:${role}`,
208
+ parent: roleKey,
209
+ depth: 2,
210
+ label: "access",
211
+ value: `${rc.access} · ${rc.rungs.length ? weakest : "no rung"}`,
212
+ dim,
213
+ issue: issueFor(i.validation, (p) => p === `roles.${role}.access`),
214
+ action: { type: "access", role },
215
+ });
216
+ add({
217
+ key: `start:${role}`,
218
+ parent: roleKey,
219
+ depth: 2,
220
+ label: "default rung",
221
+ value: rc.defaultRung ? shortRung(rc.defaultRung) : "the cheapest that clears the bar",
222
+ dim,
223
+ action: { type: "start", role },
224
+ });
225
+ const entries = modelsFor(i.models, role, rc.rungs);
226
+ const keys = [...new Set(entries.map((m) => m.key))].sort(
227
+ (a, b) => GROUP_ORDER.indexOf(a) - GROUP_ORDER.indexOf(b) || a.localeCompare(b),
228
+ );
229
+ for (const key of keys) {
230
+ const groupKey = `group:${role}:${key}`;
231
+ const note = GROUP_NOTE[key];
232
+ add({
233
+ key: groupKey,
234
+ parent: roleKey,
235
+ depth: 2,
236
+ label: note ? `${key} (${note})` : key,
237
+ value: i.profile.billing[key] ?? "",
238
+ selectable: false,
239
+ dim: true,
240
+ action: { type: "group" },
241
+ });
242
+ for (const m of entries.filter((e) => e.key === key)) {
243
+ const modelKey = `model:${role}:${m.backend}:${m.model}`;
244
+ const ticked = m.efforts.filter((e) => rc.rungs.includes(rungOf(m, e))).length;
245
+ const usable = m.efforts.some((e) => m.scored.has(e) || i.staged[rungOf(m, e)]);
246
+ const flags = [m.listed === false ? "not listed" : "", usable ? "" : "unscored"].filter(Boolean);
247
+ const modelRow = add({
248
+ key: modelKey,
249
+ parent: groupKey,
250
+ depth: 3,
251
+ label: m.model,
252
+ value: [`${ticked} of ${m.efforts.length}`, ...flags].join(" · "),
253
+ expandable: true,
254
+ dim: dim || !usable,
255
+ action: { type: "model" },
256
+ });
257
+ if (!modelRow.expanded) continue;
258
+ for (const effort of m.efforts) {
259
+ const rung = rungOf(m, effort);
260
+ const staged = i.staged[rung];
261
+ const scored = m.scored.has(effort) || staged !== undefined;
262
+ const value = staged
263
+ ? `treated like ${staged} · unsaved`
264
+ : !scored
265
+ ? "unscored · enter: treat like"
266
+ : m.inferred.has(effort)
267
+ ? "inferred"
268
+ : "";
269
+ add({
270
+ key: `rung:${role}:${rung}`,
271
+ parent: modelKey,
272
+ depth: 4,
273
+ label: effort,
274
+ value,
275
+ check: rc.rungs.includes(rung),
276
+ dim: dim || !scored,
277
+ action: { type: "rung", role, rung, scored },
278
+ });
279
+ }
280
+ }
281
+ }
282
+ }
283
+
284
+ section("section:routing", "ROUTING");
285
+ add({
286
+ key: "objective",
287
+ parent: "section:routing",
288
+ depth: 1,
289
+ label: "objective",
290
+ value: i.profile.objective,
291
+ action: { type: "objective" },
292
+ });
293
+ add({
294
+ key: "jev",
295
+ parent: "section:routing",
296
+ depth: 1,
297
+ label: "Jev",
298
+ value: i.profile.jev.use,
299
+ action: { type: "jev" },
300
+ });
301
+
302
+ section("section:harness", "HARNESS");
303
+ for (const h of i.harnesses) {
304
+ const isolated = i.profile.harness[h]?.isolated ?? false;
305
+ add({
306
+ key: `harness:${h}`,
307
+ parent: "section:harness",
308
+ depth: 1,
309
+ label: h,
310
+ value: isolated ? "isolated" : "your own config",
311
+ check: isolated,
312
+ action: { type: "isolated", harness: h },
313
+ });
314
+ }
315
+
316
+ section("section:budget", "BUDGET");
317
+ const b = i.profile.budget;
318
+ for (const [path, label, v] of [
319
+ ["budget.minutes", "minutes", b.minutes],
320
+ ["budget.tokens", "tokens", b.tokens],
321
+ ["budget.usd", "usd", b.usd],
322
+ ] as const)
323
+ add({
324
+ key: path,
325
+ parent: "section:budget",
326
+ depth: 1,
327
+ label,
328
+ value: v === undefined ? "no cap" : String(v),
329
+ action: { type: "number", path },
330
+ });
331
+
332
+ section("section:failover", "FAILOVER");
333
+ const onLadders = ROLES.filter((r) => i.profile.roles[r].enabled).flatMap((r) => i.profile.roles[r].rungs);
334
+ const fromRungs = [
335
+ ...new Set([...onLadders.filter((r) => !r.startsWith("claude:")), ...Object.keys(i.profile.failover)]),
336
+ ].sort();
337
+ if (fromRungs.length === 0)
338
+ add({
339
+ key: "failover:none",
340
+ parent: "section:failover",
341
+ depth: 1,
342
+ label: "no rung to stand in for",
343
+ value: "",
344
+ selectable: false,
345
+ dim: true,
346
+ action: { type: "section" },
347
+ });
348
+ for (const rung of fromRungs) {
349
+ const to = i.profile.failover[rung];
350
+ let mark = "";
351
+ if (to) {
352
+ try {
353
+ const via = inferredScores(i.catalog, rungInfo(i.catalog, to)).via;
354
+ mark = via ? ` (scores borrowed from ${via})` : "";
355
+ } catch {
356
+ mark = "";
357
+ }
358
+ }
359
+ add({
360
+ key: `failover:${rung}`,
361
+ parent: "section:failover",
362
+ depth: 1,
363
+ label: shortRung(rung),
364
+ value: to ? `→ ${shortRung(to)}${mark}` : "none",
365
+ issue: issueFor(i.validation, (p) => p === `failover.${rung}`),
366
+ action: { type: "failover", rung },
367
+ });
368
+ }
369
+
370
+ section("section:timeouts", "TIMEOUTS");
371
+ add({
372
+ key: "timeouts.idleMin",
373
+ parent: "section:timeouts",
374
+ depth: 1,
375
+ label: "idle minutes",
376
+ value: String(i.profile.timeouts.idleMin),
377
+ action: { type: "number", path: "timeouts.idleMin" },
378
+ });
379
+ add({
380
+ key: "timeouts.wallMin",
381
+ parent: "section:timeouts",
382
+ depth: 1,
383
+ label: "wall minutes",
384
+ value: String(i.profile.timeouts.wallMin),
385
+ action: { type: "number", path: "timeouts.wallMin" },
386
+ });
387
+
388
+ section("section:notify", "NOTIFY");
389
+ for (const moment of NOTIFY)
390
+ add({
391
+ key: `notify:${moment}`,
392
+ parent: "section:notify",
393
+ depth: 1,
394
+ label: moment,
395
+ value: "",
396
+ check: i.profile.notify.includes(moment),
397
+ action: { type: "notify", moment },
398
+ });
399
+ return rows;
400
+ }
401
+
402
+ /** Rows matching every word of `text`, with their parents so each keeps its place in the tree. */
403
+ export function filterRows(rows: Row[], text: string): Row[] {
404
+ const words = text.toLowerCase().split(/\s+/).filter(Boolean);
405
+ if (words.length === 0) return rows;
406
+ const keep = new Set<string>();
407
+ const byKey = new Map(rows.map((r) => [r.key, r]));
408
+ for (const r of rows)
409
+ if (r.selectable && words.every((w) => r.search.includes(w)))
410
+ for (let k: string | null = r.key; k && !keep.has(k); k = byKey.get(k)?.parent ?? null) keep.add(k);
411
+ return rows.filter((r) => keep.has(r.key));
412
+ }
413
+
414
+ /** The first row the filter matches itself (not only as a parent of a match): where the cursor goes. */
415
+ export function firstMatch(rows: Row[], text: string): Row | null {
416
+ const words = text.toLowerCase().split(/\s+/).filter(Boolean);
417
+ return (
418
+ rows.find((r) => r.selectable && words.length > 0 && words.every((w) => r.search.includes(w))) ?? null
419
+ );
420
+ }