catherd-cli 1.4.0 → 1.5.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 (139) hide show
  1. package/README.md +93 -30
  2. package/catalog/ATTRIBUTION.md +12 -4
  3. package/catalog/models.json +8 -0
  4. package/package.json +1 -1
  5. package/plugin/.claude-plugin/plugin.json +1 -1
  6. package/plugin/.codex-plugin/plugin.json +1 -1
  7. package/plugin/bin/catherd-mcp +1 -1
  8. package/plugin/skills/catherd/SKILL.md +110 -45
  9. package/plugin/skills/catherd-setup/SKILL.md +2 -2
  10. package/src/adapters/antigravity/index.ts +18 -3
  11. package/src/adapters/backend.ts +18 -2
  12. package/src/adapters/claude-code/index.ts +126 -14
  13. package/src/adapters/codex/events.ts +10 -0
  14. package/src/adapters/codex/index.ts +17 -5
  15. package/src/adapters/cursor/index.ts +7 -1
  16. package/src/adapters/grok/home.ts +75 -20
  17. package/src/adapters/grok/index.ts +27 -8
  18. package/src/adapters/opencode/index.ts +38 -1
  19. package/src/cli.ts +5 -0
  20. package/src/domain/agents.ts +3 -1
  21. package/src/domain/brief.ts +91 -0
  22. package/src/domain/calibration.ts +7 -0
  23. package/src/domain/catalog.ts +12 -1
  24. package/src/domain/changes.ts +23 -1
  25. package/src/domain/cost.ts +8 -3
  26. package/src/domain/errors.ts +4 -0
  27. package/src/domain/failover.ts +17 -5
  28. package/src/domain/gate-brief.ts +52 -0
  29. package/src/domain/hints.ts +13 -2
  30. package/src/domain/lane.ts +99 -3
  31. package/src/domain/notice.ts +9 -2
  32. package/src/domain/profile-rules.ts +112 -16
  33. package/src/domain/profile.ts +24 -0
  34. package/src/domain/record.ts +16 -2
  35. package/src/domain/reply.ts +29 -0
  36. package/src/domain/role-prompts.ts +56 -16
  37. package/src/domain/role-scope.ts +106 -0
  38. package/src/domain/role-tools.ts +34 -0
  39. package/src/domain/route.ts +73 -0
  40. package/src/domain/select.ts +177 -25
  41. package/src/domain/state.ts +5 -0
  42. package/src/domain/util.ts +22 -0
  43. package/src/domain/workspace.ts +130 -0
  44. package/src/entry/catalog-command.ts +14 -6
  45. package/src/entry/cli-kit.ts +17 -1
  46. package/src/entry/deps.ts +2 -0
  47. package/src/entry/doctor-command.ts +20 -2
  48. package/src/entry/host-arg.ts +28 -0
  49. package/src/entry/init-command.ts +2 -1
  50. package/src/entry/knowledge-command.ts +78 -3
  51. package/src/entry/lock-command.ts +104 -8
  52. package/src/entry/mcp/dispatch-tools.ts +3 -3
  53. package/src/entry/mcp/handshake.ts +6 -2
  54. package/src/entry/mcp/lane-tools.ts +55 -8
  55. package/src/entry/mcp/protocol-tools.ts +10 -4
  56. package/src/entry/mcp/push-tools.ts +16 -0
  57. package/src/entry/mcp/role-bin.ts +20 -0
  58. package/src/entry/mcp/role-server.ts +116 -0
  59. package/src/entry/mcp/run-tools.ts +20 -4
  60. package/src/entry/mcp/server.ts +98 -7
  61. package/src/entry/mcp/setup-tools.ts +4 -3
  62. package/src/entry/mcp/workspace-tools.ts +142 -0
  63. package/src/entry/pause-command.ts +67 -0
  64. package/src/entry/profile-command.ts +23 -4
  65. package/src/entry/role-cli-command.ts +115 -0
  66. package/src/entry/runs-command.ts +78 -8
  67. package/src/entry/supervise-command.ts +30 -3
  68. package/src/entry/tui/effects.ts +4 -0
  69. package/src/entry/tui/state.ts +37 -10
  70. package/src/entry/tui/views/profiles.tsx +1 -1
  71. package/src/entry/tui/views/runs.tsx +66 -20
  72. package/src/entry/tui/views/save-dialog.tsx +6 -2
  73. package/src/entry/tui/views/status.tsx +6 -2
  74. package/src/entry/workspace-command.ts +44 -0
  75. package/src/infra/boot-lock.ts +25 -0
  76. package/src/infra/delivery.ts +3 -0
  77. package/src/infra/dispatch-dir.ts +2 -0
  78. package/src/infra/env.ts +11 -1
  79. package/src/infra/git.ts +6 -0
  80. package/src/infra/heavy-lock.ts +93 -4
  81. package/src/infra/host-probe.ts +16 -0
  82. package/src/infra/lock-activity.ts +88 -0
  83. package/src/infra/login-env.ts +48 -0
  84. package/src/infra/paths.ts +35 -0
  85. package/src/infra/role-mcp.ts +69 -0
  86. package/src/infra/sources/arena.ts +11 -3
  87. package/src/infra/sources/artificial-analysis.ts +8 -1
  88. package/src/infra/sources/cache.ts +2 -0
  89. package/src/infra/supervisor.ts +107 -16
  90. package/src/services/admission.ts +110 -23
  91. package/src/services/budget.ts +26 -6
  92. package/src/services/catalog-service.ts +20 -12
  93. package/src/services/credentials.ts +11 -6
  94. package/src/services/dispatch-service.ts +204 -16
  95. package/src/services/dispatches.ts +30 -2
  96. package/src/services/doctor-access.ts +8 -3
  97. package/src/services/doctor-docker.ts +221 -0
  98. package/src/services/doctor-role-mcp.ts +65 -0
  99. package/src/services/doctor.ts +68 -6
  100. package/src/services/end-push.ts +157 -0
  101. package/src/services/finalize.ts +12 -2
  102. package/src/services/gate-env.ts +218 -0
  103. package/src/services/gate-service.ts +216 -45
  104. package/src/services/global-install.ts +46 -13
  105. package/src/services/lane-edit.ts +154 -0
  106. package/src/services/lane-service.ts +257 -52
  107. package/src/services/milestones.ts +137 -26
  108. package/src/services/notifier.ts +56 -3
  109. package/src/services/orchestrator-wait.ts +82 -0
  110. package/src/services/pause.ts +161 -0
  111. package/src/services/peek.ts +79 -17
  112. package/src/services/ports.ts +31 -2
  113. package/src/services/preflight.ts +153 -19
  114. package/src/services/profile-service.ts +1 -0
  115. package/src/services/profile-store.ts +10 -3
  116. package/src/services/protocol.ts +119 -25
  117. package/src/services/provenance.ts +16 -12
  118. package/src/services/questions.ts +15 -0
  119. package/src/services/reconcile.ts +22 -1
  120. package/src/services/reentry.ts +7 -6
  121. package/src/services/role-access.ts +27 -0
  122. package/src/services/routing-service.ts +144 -36
  123. package/src/services/run-pin.ts +125 -0
  124. package/src/services/run-service.ts +151 -19
  125. package/src/services/run-store.ts +109 -15
  126. package/src/services/runs-page.ts +42 -7
  127. package/src/services/scratch.ts +60 -0
  128. package/src/services/sessions.ts +3 -1
  129. package/src/services/source-derive.ts +28 -5
  130. package/src/services/source-sync.ts +9 -2
  131. package/src/services/standins.ts +40 -6
  132. package/src/services/state.ts +21 -0
  133. package/src/services/summary.ts +49 -9
  134. package/src/services/treat-likes.ts +19 -3
  135. package/src/services/verifier-brief.ts +23 -0
  136. package/src/services/verifier-step.ts +48 -0
  137. package/src/services/workspace-admission.ts +267 -0
  138. package/src/services/workspace-service.ts +254 -0
  139. package/src/services/workspace-store.ts +95 -0
@@ -43,6 +43,35 @@ export interface RouteRow {
43
43
  jev?: RouteJev | null;
44
44
  /** climb rows: the climb was caused by the environment, not the rung's capability (spec §5.6) */
45
45
  env?: boolean;
46
+ /** route rows (spec 1.5 plan 24): the decision in one line, as `route` returned it */
47
+ why?: string;
48
+ /** route rows: where Jev disagreed with the lane's declared Kind/Difficulty */
49
+ jevSaid?: string;
50
+ /** route rows: no rung clears the lane's bar, and the closest one */
51
+ noClear?: string;
52
+ /** route rows: rungs of equal scores on other quotas competed for the start, and what decided */
53
+ tie?: string;
54
+ /** route rows: the chosen rung's thresholds, values and sources (spec 1.2 §5.3), kept out of `route`'s answer */
55
+ provenance?: unknown;
56
+ }
57
+
58
+ /**
59
+ * Spec 1.5 plan 24: one routes.jsonl row for a role's decision outside a lane: `route` without a lane file, or
60
+ * a lane-less `dispatch` (its `name`). `readRoutes` skips these rows: they have no lane to climb.
61
+ */
62
+ export interface RoleRouteRow {
63
+ at: string;
64
+ lane: null;
65
+ role: Role;
66
+ /** the dispatch's name; null for a `route` */
67
+ name: string | null;
68
+ rung: string;
69
+ ladder: string[];
70
+ source: "route" | "dispatch";
71
+ /** who chose the rung: the router (its source), or the coordinator passing `rung` to `dispatch` */
72
+ decidedBy: RouteSource | "orchestrator";
73
+ why: string;
74
+ provenance?: unknown;
46
75
  }
47
76
 
48
77
  export function nextRung(ladder: string[], current: string): string | null {
@@ -118,6 +147,50 @@ export function laneOutcome(rows: RouteRow[], lane: string, landed: boolean, at:
118
147
  };
119
148
  }
120
149
 
150
+ /**
151
+ * Spec 1.5 plan 24: a lane's final outcome in routes.jsonl, beside the route row that holds Jev's answer, for
152
+ * calibrating Jev's difficulty question: what Jev said and what the route used, whether the lane climbed, and
153
+ * how it ended. `readRoutes` skips these rows; outcomes.jsonl keeps its own (spec §5.6).
154
+ */
155
+ export interface OutcomeRouteRow {
156
+ at: string;
157
+ lane: string;
158
+ source: "outcome";
159
+ decidedBy: RouteSource;
160
+ kind: Kind | null;
161
+ difficulty: Difficulty | null;
162
+ questionSet: string | null;
163
+ jev: RouteJev | null;
164
+ /** some climb moved the lane to another rung (an environment climb included) */
165
+ climbed: boolean;
166
+ startRung: string;
167
+ finalRung: string;
168
+ landed: boolean;
169
+ start_ok: boolean;
170
+ envCaused: boolean;
171
+ }
172
+
173
+ /** The routes.jsonl outcome row of `o`, with the kind and difficulty of the lane's last route. */
174
+ export function outcomeRouteRow(rows: RouteRow[], o: OutcomeRow): OutcomeRouteRow {
175
+ const start = rows.findLast((r) => r.lane === o.lane && r.source === "route");
176
+ return {
177
+ at: o.at,
178
+ lane: o.lane,
179
+ source: "outcome",
180
+ decidedBy: o.source,
181
+ kind: start?.kind ?? null,
182
+ difficulty: start?.difficulty ?? null,
183
+ questionSet: o.questionSet,
184
+ jev: o.jevProbs,
185
+ climbed: o.climbs.length > 0,
186
+ startRung: o.startRung,
187
+ finalRung: o.finalRung,
188
+ landed: o.landed,
189
+ start_ok: o.start_ok,
190
+ envCaused: o.envCaused,
191
+ };
192
+ }
193
+
121
194
  /** Spec §5.6: one row per lane, the last one written (last row per lane wins), in first-seen lane order. */
122
195
  export function latestOutcomes(rows: OutcomeRow[]): OutcomeRow[] {
123
196
  const byLane = new Map<string, OutcomeRow>();
@@ -9,6 +9,8 @@ import {
9
9
  } from "./catalog.ts";
10
10
  import { type BillingMode, type Cost, compareCost, costOf, DEFAULT_BILLING } from "./cost.ts";
11
11
  import { CatherdError } from "./errors.ts";
12
+ import { quotaOf } from "./failover.ts";
13
+ import { tryParseRung } from "./ids.ts";
12
14
  import { DIFFICULTIES, type Difficulty, type Kind } from "./lane.ts";
13
15
  import type { Role } from "./roles.ts";
14
16
 
@@ -17,6 +19,11 @@ export interface RoutingProfile {
17
19
  objective: "cost" | "speed";
18
20
  billing: Partial<Record<string, BillingMode>>;
19
21
  role: { enabled: boolean; rungs: string[]; defaultRung?: string };
22
+ /**
23
+ * spec 1.5 plan 24: the run's dispatches so far per quota (`quotaOf`), the headroom a tie between rungs of
24
+ * equal scores is broken on; absent, every quota counts 0 and the candidates' order breaks it
25
+ */
26
+ usage?: Partial<Record<string, number>>;
20
27
  }
21
28
 
22
29
  export interface Candidate {
@@ -31,6 +38,10 @@ export interface Candidate {
31
38
  export interface Pick {
32
39
  rung: string;
33
40
  ladder: string[];
41
+ /** spec 1.5 plan 24: no rung clears the lane's bar (`no rung clears repo_code/hard; best is …`) */
42
+ noClear?: string;
43
+ /** spec 1.5 plan 24: rungs of equal scores competed for the start, and how the tie was broken */
44
+ tie?: string;
34
45
  }
35
46
 
36
47
  const byNull = (a: number | null, b: number | null) =>
@@ -44,12 +55,14 @@ function secsOf(c: Catalog, canonical: string, kind: Kind | null): number | null
44
55
  * The role's enabled rungs that the catalog can place: parseable, offered by their model, listed by
45
56
  * the backend when it has a listing, capable for the role, and scored (their own or a treat-like).
46
57
  * Ordered by cost (spec §5.3), or by measured speed with cost breaking ties; with fewer than 5
47
- * samples a rung has no speed, and cost orders it, which within a model is effort order.
58
+ * samples a rung has no speed, and cost orders it, which within a model is effort order. The profile's
59
+ * ladder order breaks every remaining tie (spec 1.5 plan 24), so equal rungs start in the order the user wrote.
48
60
  */
49
61
  export function candidates(c: Catalog, p: RoutingProfile, role: Role, kind: Kind | null = null): Candidate[] {
50
62
  if (!p.role.enabled) return [];
51
63
  const out: Candidate[] = [];
52
- for (const rung of new Set(p.role.rungs)) {
64
+ const order = [...new Set(p.role.rungs)];
65
+ for (const rung of order) {
53
66
  let info: RungInfo;
54
67
  try {
55
68
  info = rungInfo(c, rung);
@@ -68,8 +81,11 @@ export function candidates(c: Catalog, p: RoutingProfile, role: Role, kind: Kind
68
81
  secs: secsOf(c, info.canonical, kind),
69
82
  });
70
83
  }
71
- const cost = (a: Candidate, b: Candidate) => compareCost(a.cost, b.cost) || byNull(a.secs, b.secs);
72
- const speed = (a: Candidate, b: Candidate) => byNull(a.secs, b.secs) || compareCost(a.cost, b.cost);
84
+ const written = (a: Candidate, b: Candidate) => order.indexOf(a.rung) - order.indexOf(b.rung);
85
+ const cost = (a: Candidate, b: Candidate) =>
86
+ compareCost(a.cost, b.cost) || byNull(a.secs, b.secs) || written(a, b);
87
+ const speed = (a: Candidate, b: Candidate) =>
88
+ byNull(a.secs, b.secs) || compareCost(a.cost, b.cost) || written(a, b);
73
89
  return out.sort(p.objective === "speed" ? speed : cost);
74
90
  }
75
91
 
@@ -85,15 +101,94 @@ function noRung(role: Role): CatherdError {
85
101
  });
86
102
  }
87
103
 
104
+ /** The dimensions a ladder is compared on: the bar's, else every dimension the start has a value on. */
105
+ function compareDims(start: Candidate, bar: Partial<Record<Dim, number>>): Dim[] {
106
+ const dims = (Object.keys(bar) as Dim[]).filter((d) => bar[d] !== undefined);
107
+ return dims.length ? dims : (Object.keys(start.scores) as Dim[]);
108
+ }
109
+
110
+ /** `x` scores at least `start` on every one of `dims` (a missing value scores below any). */
111
+ const atLeast = (x: Candidate, start: Candidate, dims: Dim[]): boolean =>
112
+ dims.every(
113
+ (d) => (x.scores[d] ?? Number.NEGATIVE_INFINITY) >= (start.scores[d] ?? Number.NEGATIVE_INFINITY),
114
+ );
115
+
116
+ /** Spec 1.5 plan 24: how many of `rungs` (a run's dispatched rungs) drew on each quota. */
117
+ export function quotaUsage(rungs: readonly string[]): Record<string, number> {
118
+ const out: Record<string, number> = {};
119
+ for (const rung of rungs) {
120
+ const r = tryParseRung(rung);
121
+ if (r) out[quotaOf(r)] = (out[quotaOf(r)] ?? 0) + 1;
122
+ }
123
+ return out;
124
+ }
125
+
126
+ /** Equal on every one of `dims`: a tie for the start. */
127
+ const same = (a: Candidate, b: Candidate, dims: Dim[]): boolean => atLeast(a, b, dims) && atLeast(b, a, dims);
128
+
129
+ const quota = (x: Candidate): string => quotaOf(x.info.parsed);
130
+
131
+ /**
132
+ * Spec 1.5 plan 24: when rungs on other quotas score exactly as `start` does on `dims`, the start goes to the
133
+ * quota with the most headroom (the fewest of the run's dispatches so far), the candidates' order (cost, then
134
+ * the profile's ladder order) breaking an equal count. `tie` says what decided. Rungs of one quota that tie
135
+ * are no tie (cost already orders them), nor is a rung of another cost tier.
136
+ */
137
+ function breakTie(
138
+ start: Candidate,
139
+ order: Candidate[],
140
+ dims: Dim[],
141
+ usage: Partial<Record<string, number>>,
142
+ ): { start: Candidate; tie?: string } {
143
+ // a metered rung never wins a tie over a plan's: headroom is free only within the start's cost tier
144
+ const rivals = order.filter(
145
+ (x) =>
146
+ x !== start && quota(x) !== quota(start) && x.cost.tier === start.cost.tier && same(x, start, dims),
147
+ );
148
+ if (rivals.length === 0) return { start };
149
+ const used = (x: Candidate) => usage[quota(x)] ?? 0;
150
+ const tied = [start, ...rivals];
151
+ const pick = [...tied].sort((a, b) => used(a) - used(b))[0] as Candidate;
152
+ const others = tied.filter((x) => x !== pick).map((x) => x.rung);
153
+ const counts = [...new Set(tied.map(quota))].map((q) => `${q} ${usage[q] ?? 0}`).join(", ");
154
+ const why = tied.every((x) => used(x) === used(pick))
155
+ ? `equal headroom (${used(pick)} dispatches each); the profile's ladder order and cost decided`
156
+ : `${quota(pick)} has the most headroom (dispatches in this run: ${counts})`;
157
+ return {
158
+ start: pick,
159
+ tie: `tie on ${dims.join(", ")} with ${others.join(", ")}: started on ${pick.rung}; ${why}`,
160
+ };
161
+ }
162
+
163
+ /**
164
+ * Spec 1.5 plan 24, "climb ladders only go up": the start (after a tie on equal scores goes to the quota with
165
+ * the most headroom), then, in `order`'s order, every other candidate that scores at least the last rung kept
166
+ * on `dims`. A stronger rung is on the ladder wherever cost puts it, and one weaker than the rung before it
167
+ * never is (checking each against the start alone let a cheaper, weaker rung follow a stronger one), so a
168
+ * climb never lands on a rung below the one it leaves.
169
+ */
170
+ function ladderFrom(
171
+ first: Candidate,
172
+ order: Candidate[],
173
+ dims: Dim[],
174
+ usage: Partial<Record<string, number>> = {},
175
+ ): Pick {
176
+ const { start, tie } = breakTie(first, order, dims, usage);
177
+ const kept = [start];
178
+ for (const x of order) if (x !== start && atLeast(x, kept.at(-1) as Candidate, dims)) kept.push(x);
179
+ return { rung: start.rung, ladder: kept.map((x) => x.rung), ...(tie ? { tie } : {}) };
180
+ }
181
+
88
182
  /**
89
- * The role's default rung and every candidate above it (the whole list when it has no default), in cost
90
- * order under either objective: speed order would put slower, not stronger, rungs above the default.
183
+ * The role's default rung and every candidate at least as strong on every dimension it has a value on (the
184
+ * whole list when it has no default), in cost order under either objective: speed order would put slower, not
185
+ * stronger, rungs above the default.
91
186
  */
92
187
  export function defaultLadder(c: Catalog, p: RoutingProfile, role: Role): Pick {
93
- const all = candidates(c, { ...p, objective: "cost" }, role).map((x) => x.rung);
188
+ const all = candidates(c, { ...p, objective: "cost" }, role);
94
189
  if (all.length === 0) throw noRung(role);
95
- const i = Math.max(0, all.indexOf(p.role.defaultRung ?? ""));
96
- return { rung: all[i] as string, ladder: all.slice(i) };
190
+ const start = all.find((x) => x.rung === p.role.defaultRung) ?? (all[0] as Candidate);
191
+ return ladderFrom(start, all, compareDims(start, {}), p.usage);
97
192
  }
98
193
 
99
194
  /**
@@ -108,29 +203,86 @@ export function defaultDifficulty(c: Catalog, p: RoutingProfile, role: Role, kin
108
203
  return cleared.at(-1) ?? "build";
109
204
  }
110
205
 
206
+ /** The kind's main dimension: what a speed ladder sorts on and what `best is` reads first. */
207
+ export const primaryDim = (kind: Kind): Dim =>
208
+ kind === "terminal" ? "terminal" : kind === "ui" ? "frontend" : "repo_code";
209
+
111
210
  /**
112
211
  * Under `objective: "speed"` the objective picks only the start (the fastest bar-clearing rung); the
113
- * ladder above it is the other bar-clearing rungs at least as strong on the kind's primary dimension,
114
- * weakest first, cost breaking ties, so a lane never climbs onto a weaker rung. Ported from 0.x.
212
+ * ladder above it is the other rungs at least as strong on the bar, weakest first on the kind's primary
213
+ * dimension, cost breaking ties, so a lane never climbs onto a weaker rung. Ported from 0.x.
115
214
  */
116
- function speedLadder(clearing: Candidate[], kind: Kind): Pick {
117
- const start = clearing[0] as Candidate;
118
- const dim: Dim = kind === "terminal" ? "terminal" : "repo_code";
215
+ function speedLadder(start: Candidate, all: Candidate[], kind: Kind, dims: Dim[], p: RoutingProfile): Pick {
216
+ const dim = primaryDim(kind);
119
217
  const strength = (x: Candidate) => x.scores[dim] ?? Number.NEGATIVE_INFINITY;
120
- const rest = clearing
121
- .filter((x) => x !== start && strength(x) >= strength(start))
122
- .sort((a, b) => strength(a) - strength(b) || compareCost(a.cost, b.cost));
123
- return { rung: start.rung, ladder: [start, ...rest].map((x) => x.rung) };
218
+ const order = [...all].sort((a, b) => strength(a) - strength(b) || compareCost(a.cost, b.cost));
219
+ return ladderFrom(start, order, dims, p.usage);
220
+ }
221
+
222
+ /** The thresholds of `bar` a rung misses, each with its value: `agentic 0.0818 < 0.1077`. */
223
+ function shortfalls(cand: Candidate, bar: Partial<Record<Dim, number>>): string[] {
224
+ return (Object.entries(bar) as [Dim, number | undefined][]).flatMap(([dim, min]) => {
225
+ if (min === undefined) return [];
226
+ const v = cand.scores[dim];
227
+ return v !== undefined && v >= min ? [] : [`${dim} ${v ?? "none"} < ${min}`];
228
+ });
229
+ }
230
+
231
+ /**
232
+ * Spec 1.5 plan 24: when no rung clears a bar, the one closest to it: the fewest thresholds missed, then the
233
+ * highest on the kind's primary dimension, then the candidates' order.
234
+ */
235
+ function bestOf(all: Candidate[], bar: Partial<Record<Dim, number>>, kind: Kind): Candidate {
236
+ const dim = primaryDim(kind);
237
+ const v = (x: Candidate) => x.scores[dim] ?? Number.NEGATIVE_INFINITY;
238
+ const misses = (x: Candidate) => shortfalls(x, bar).length;
239
+ return [...all].sort((a, b) => misses(a) - misses(b) || v(b) - v(a))[0] as Candidate;
124
240
  }
125
241
 
126
- /** Spec §5.4: the start rung and ladder for a lane of this kind and difficulty (0.x `select`). */
242
+ /** `no rung clears repo_code/hard; best is codex:gpt-6-sol#xhigh (agentic 0.0818 < 0.1077)`. */
243
+ export function noClearLine(
244
+ all: Candidate[],
245
+ bar: Partial<Record<Dim, number>>,
246
+ kind: Kind,
247
+ d: Difficulty,
248
+ ): string {
249
+ const best = bestOf(all, bar, kind);
250
+ return `no rung clears ${kind}/${d}; best is ${best.rung} (${shortfalls(best, bar).join(", ")})`;
251
+ }
252
+
253
+ /**
254
+ * Spec §5.4: the start rung and ladder for a lane of this kind and difficulty (0.x `select`). The start is the
255
+ * first rung in objective order that clears the bar. When none does, it is the default rung, raised to an
256
+ * easier difficulty's start when that one scores at least the default on this bar (spec 1.5 plan 24: `logic`
257
+ * starts no lower than `build`), and `noClear` says so. The ladder above the start only goes up.
258
+ */
127
259
  export function select(c: Catalog, p: RoutingProfile, role: Role, kind: Kind, difficulty: Difficulty): Pick {
128
260
  const all = candidates(c, p, role, kind);
129
261
  if (all.length === 0) throw noRung(role);
130
- if (all.length === 1) return { rung: all[0]?.rung as string, ladder: [all[0]?.rung as string] };
131
- const clearing = all.filter((x) => clearsBar(c, x, kind, difficulty));
132
- if (clearing.length === 0) return defaultLadder(c, p, role);
133
- return p.objective === "speed"
134
- ? speedLadder(clearing, kind)
135
- : { rung: clearing[0]?.rung as string, ladder: clearing.map((x) => x.rung) };
262
+ const bar = c.bars[kind][difficulty];
263
+ if (all.length === 1) {
264
+ const only = all[0] as Candidate;
265
+ const one = { rung: only.rung, ladder: [only.rung] };
266
+ return clearsBar(c, only, kind, difficulty)
267
+ ? one
268
+ : { ...one, noClear: noClearLine(all, bar, kind, difficulty) };
269
+ }
270
+ const first = all.find((x) => clearsBar(c, x, kind, difficulty));
271
+ if (first) {
272
+ const dims = compareDims(first, bar);
273
+ return p.objective === "speed"
274
+ ? speedLadder(first, all, kind, dims, p)
275
+ : ladderFrom(first, all, dims, p.usage);
276
+ }
277
+ const byCost = candidates(c, { ...p, objective: "cost" }, role, kind);
278
+ const fallback = defaultLadder(c, p, role).rung;
279
+ let start = byCost.find((x) => x.rung === fallback) ?? (byCost[0] as Candidate);
280
+ const dims = compareDims(start, bar);
281
+ for (const easier of DIFFICULTIES.slice(0, DIFFICULTIES.indexOf(difficulty)).reverse()) {
282
+ const s = byCost.find((x) => clearsBar(c, x, kind, easier));
283
+ if (!s) continue;
284
+ if (atLeast(s, start, dims)) start = s;
285
+ break;
286
+ }
287
+ return { ...ladderFrom(start, byCost, dims, p.usage), noClear: noClearLine(all, bar, kind, difficulty) };
136
288
  }
@@ -16,6 +16,8 @@ export interface StateView {
16
16
  dirty: { path: string; owner: string | null }[];
17
17
  running: { name: string; rung: string; thread: string | null; since: string; brief: string }[];
18
18
  lastCheck: string | null;
19
+ /** spec 1.5 "Pinned per run": what changed since the pin; dispatch keeps the pinned values */
20
+ pinChanges?: string[];
19
21
  next: string;
20
22
  /** spec 1.1 §10: the protocol's next step, derived from the run's files; always the last line */
21
23
  protocol: string;
@@ -40,6 +42,9 @@ export function renderState(s: StateView): string {
40
42
  : ["- none"]),
41
43
  "",
42
44
  `Last check: ${s.lastCheck ?? "none"}`,
45
+ ...(s.pinChanges?.length
46
+ ? [`Pinned: ${s.pinChanges.join("; ")} (dispatch keeps the pinned values; run_pin re-pins)`]
47
+ : []),
43
48
  "",
44
49
  // 1.1: no tool waits; each running role's record arrives as a catherd message
45
50
  `Next: ${waiting.length ? `running ${waiting.join(", ")} (results arrive as catherd messages; peek to check); then ${s.next}` : s.next}`,
@@ -16,6 +16,28 @@ export function median(xs: readonly number[]): number | null {
16
16
  return s.length % 2 ? (s[m] as number) : ((s[m - 1] as number) + (s[m] as number)) / 2;
17
17
  }
18
18
 
19
+ /** A stretch of time in epoch ms; `to` null while it is still open. */
20
+ export interface Span {
21
+ from: number;
22
+ to: number | null;
23
+ }
24
+
25
+ /** How many ms of [from, to] any of `spans` covers, overlaps counted once; an open span runs to `to`. */
26
+ export function coveredMs(from: number, to: number, spans: readonly Span[]): number {
27
+ const clipped = spans
28
+ .map((s) => [Math.max(from, s.from), Math.min(to, s.to ?? to)] as const)
29
+ .filter(([a, b]) => b > a)
30
+ .sort((x, y) => x[0] - y[0]);
31
+ let total = 0;
32
+ let end = -Infinity;
33
+ for (const [a, b] of clipped) {
34
+ if (b <= end) continue;
35
+ total += b - Math.max(a, end);
36
+ end = b;
37
+ }
38
+ return total;
39
+ }
40
+
19
41
  /** A plain object: not null and not an array. */
20
42
  export const isPlain = (v: unknown): v is Record<string, unknown> =>
21
43
  typeof v === "object" && v !== null && !Array.isArray(v);
@@ -0,0 +1,130 @@
1
+ import { z } from "zod";
2
+ import { ID_PATTERN } from "./ids.ts";
3
+
4
+ export const WORKSPACE_LIMIT = 100;
5
+
6
+ export const WorkspaceIdSchema = z
7
+ .string()
8
+ .regex(ID_PATTERN)
9
+ .refine((s) => !s.includes(".."));
10
+ export const WorkspaceBudgetSchema = z.strictObject({
11
+ minutes: z.number().finite().nonnegative().optional(),
12
+ tokens: z.number().finite().nonnegative().optional(),
13
+ usd: z.number().finite().nonnegative().optional(),
14
+ });
15
+ /** A git ref a release waits on (`origin/main`): no option, no range, no whitespace. */
16
+ export const BaseRefSchema = z
17
+ .string()
18
+ .regex(/^[A-Za-z0-9_][A-Za-z0-9._/-]*$/)
19
+ .refine((s) => !s.includes("..") && !s.endsWith("/") && !s.endsWith(".lock"));
20
+ export const RELEASES = ["land", "merge"] as const;
21
+ export const WorkspaceStepSchema = z
22
+ .strictObject({
23
+ id: WorkspaceIdSchema,
24
+ repo: WorkspaceIdSchema,
25
+ title: z.string().min(1),
26
+ aLines: z.array(z.string()),
27
+ dependsOn: z.array(WorkspaceIdSchema).max(WORKSPACE_LIMIT).default([]),
28
+ milestone: WorkspaceIdSchema.default("M1"),
29
+ /**
30
+ * When the step releases its dependents: once its milestone lands (the default), or once the landed
31
+ * commit is an ancestor of `base` in its repository (`git merge-base --is-ancestor`), checked when a
32
+ * dependent asks, never polled.
33
+ */
34
+ release: z.enum(RELEASES).default("land"),
35
+ base: BaseRefSchema.optional(),
36
+ })
37
+ .refine((s) => (s.release === "merge") === (s.base !== undefined), {
38
+ message: 'release "merge" needs a base ref (e.g. origin/main), and only it takes one',
39
+ });
40
+ export type WorkspaceStep = z.infer<typeof WorkspaceStepSchema>;
41
+ export const WorkspaceSchema = z
42
+ .strictObject({
43
+ schema: z.literal(1),
44
+ id: WorkspaceIdSchema,
45
+ root: z.string().min(1),
46
+ title: z.string().min(1),
47
+ aLines: z.array(z.string()),
48
+ createdAt: z.iso.datetime(),
49
+ budget: WorkspaceBudgetSchema,
50
+ repos: z
51
+ .record(WorkspaceIdSchema, z.string().min(1))
52
+ .refine((repos) => Object.keys(repos).length <= WORKSPACE_LIMIT),
53
+ steps: z.array(WorkspaceStepSchema).min(1).max(WORKSPACE_LIMIT),
54
+ })
55
+ .superRefine((workspace, ctx) => {
56
+ // Zod may still refine an oversized array; do not traverse invalid graphs.
57
+ if (
58
+ workspace.steps.length > WORKSPACE_LIMIT ||
59
+ Object.keys(workspace.repos).length > WORKSPACE_LIMIT ||
60
+ workspace.steps.some((step) => step.dependsOn.length > WORKSPACE_LIMIT)
61
+ )
62
+ return;
63
+ const ids = new Set<string>();
64
+ let invalidIdentity = false;
65
+ for (const step of workspace.steps) {
66
+ if (ids.has(step.id) || !Object.hasOwn(workspace.repos, step.repo)) {
67
+ invalidIdentity = true;
68
+ ctx.addIssue({
69
+ code: "custom",
70
+ message: "steps require unique ids and repositories present in the snapshot",
71
+ });
72
+ }
73
+ ids.add(step.id);
74
+ }
75
+ if (invalidIdentity) return;
76
+ const byId = new Map(workspace.steps.map((step) => [step.id, step]));
77
+ const remaining = new Map<string, number>();
78
+ const dependents = new Map<string, string[]>();
79
+ const ready: string[] = [];
80
+ for (const step of workspace.steps) {
81
+ if (
82
+ new Set(step.dependsOn).size !== step.dependsOn.length ||
83
+ step.dependsOn.some((id) => !byId.has(id))
84
+ ) {
85
+ ctx.addIssue({
86
+ code: "custom",
87
+ message: "dependencies must form a graph without cycles or unknown steps",
88
+ });
89
+ return;
90
+ }
91
+ remaining.set(step.id, step.dependsOn.length);
92
+ if (!step.dependsOn.length) ready.push(step.id);
93
+ for (const id of step.dependsOn) {
94
+ const next = dependents.get(id) ?? [];
95
+ next.push(step.id);
96
+ dependents.set(id, next);
97
+ }
98
+ }
99
+ const ancestors = new Map<string, Set<string>>();
100
+ const latestByRepo = new Map<string, string>();
101
+ // Topological order makes adjacent owners sufficient: if each owner follows
102
+ // the previous owner transitively, every pair of owners is ordered.
103
+ for (let i = 0; i < ready.length; i++) {
104
+ const step = byId.get(ready[i]!)!;
105
+ const before = new Set<string>();
106
+ for (const id of step.dependsOn) {
107
+ before.add(id);
108
+ for (const ancestor of ancestors.get(id)!) before.add(ancestor);
109
+ }
110
+ ancestors.set(step.id, before);
111
+ const previous = latestByRepo.get(step.repo);
112
+ if (previous && !before.has(previous))
113
+ ctx.addIssue({
114
+ code: "custom",
115
+ message: "steps sharing a repository must be ordered by dependencies",
116
+ });
117
+ latestByRepo.set(step.repo, step.id);
118
+ for (const id of dependents.get(step.id) ?? []) {
119
+ const count = remaining.get(id)! - 1;
120
+ remaining.set(id, count);
121
+ if (!count) ready.push(id);
122
+ }
123
+ }
124
+ if (ready.length !== workspace.steps.length)
125
+ ctx.addIssue({
126
+ code: "custom",
127
+ message: "dependencies must form a graph without cycles or unknown steps",
128
+ });
129
+ });
130
+ export type Workspace = z.infer<typeof WorkspaceSchema>;
@@ -13,7 +13,7 @@ import {
13
13
  import { readDerived } from "../infra/sources/cache.ts";
14
14
  import { type SyncReport, syncSources } from "../services/source-sync.ts";
15
15
  import { clearTreatLike, type LeftOnStandIn, resetTreatLikes, suggestFor } from "../services/treat-likes.ts";
16
- import { exitCodeOf, JSON_ARG, mark, printError } from "./cli-kit.ts";
16
+ import { exitCodeOf, JSON_ARG, mark, printError, refuseInRole } from "./cli-kit.ts";
17
17
 
18
18
  /**
19
19
  * Spec 1.2 §9: a sync, one line per source (fetched, fresh, skipped, or failed with the answer it kept),
@@ -99,7 +99,7 @@ export function formatModel(m: CatalogModel): string {
99
99
  // spec 1.2 §5.3: each value with its confidence and source, as `route` reports it; a guess names its rung
100
100
  const values = Object.entries(r.scores).map(
101
101
  ([dim, v]) =>
102
- `${dim} ${Number(v.value.toPrecision(4))} (${v.from ? `inferred from ${v.from}` : v.confidence}, ${v.source})`,
102
+ `${dim} ${Number(v.value.toPrecision(4))} (${v.from ? (v.lent === "treat-like" ? `like ${v.from}` : `inferred from ${v.from}`) : v.confidence}, ${v.source})`,
103
103
  );
104
104
  if (values.length === 0 && !r.evidence) continue;
105
105
  lines.push(` #${r.rung.slice(r.rung.lastIndexOf("#") + 1)} ${values.join(" · ") || "unscored"}`);
@@ -163,10 +163,17 @@ const list = defineCommand({
163
163
 
164
164
  /** Spec 1.2 §6.4: the profile rungs a removal leaves on an inferred stand-in or unscored, one line each. */
165
165
  export function leftLines(left: LeftOnStandIn[], plain = false): string[] {
166
- return left.map(
167
- (l) =>
168
- `${mark("warn", plain)} ${l.profile}: ${l.rung} is left ${l.unscored ? "unscored: routing skips it" : `on an inferred stand-in for ${l.dims.join(", ")}`}`,
169
- );
166
+ return left.map((l) => {
167
+ const what = l.unscored
168
+ ? ["unscored: routing skips it"]
169
+ : [
170
+ ...(l.dims.length ? [`on an inferred stand-in for ${l.dims.join(", ")}`] : []),
171
+ ...(l.missing?.length
172
+ ? [`with no ${l.missing.join(", ")} value: it clears no bar that needs one`]
173
+ : []),
174
+ ];
175
+ return `${mark("warn", plain)} ${l.profile}: ${l.rung} is left ${what.join(", and ")}`;
176
+ });
170
177
  }
171
178
 
172
179
  /** Spec 1.2 §6.4 `--suggest`: the three nearest stand-ins, each with its distance and the features it rests on. */
@@ -229,6 +236,7 @@ const treatLike = defineCommand({
229
236
  else for (const l of suggestLines(r, args.suggest)) console.log(l);
230
237
  return;
231
238
  }
239
+ refuseInRole(process.env, "catherd catalog treat-like");
232
240
  if (args.clear !== undefined) {
233
241
  const r = await clearTreatLike(args.clear, terminalHost(args.host).host);
234
242
  if (args.json) return console.log(JSON.stringify(r, null, 2));
@@ -1,4 +1,5 @@
1
- import { type CatherdError, isCatherdError } from "../domain/errors.ts";
1
+ import { CatherdError, isCatherdError } from "../domain/errors.ts";
2
+ import { roleScopeFromEnv } from "../domain/role-scope.ts";
2
3
  import { RUN_NOT_FOUND_FIX } from "../services/run-store.ts";
3
4
  import { glyph, type State } from "./glyphs.ts";
4
5
 
@@ -24,6 +25,21 @@ export function printError(e: Pick<CatherdError, "code" | "message" | "fix">): v
24
25
  export const exitCodeOf = (e: unknown): number =>
25
26
  isCatherdError(e) && e.code === "E_INPUT_INVALID" ? EXIT.usage : EXIT.error;
26
27
 
28
+ /**
29
+ * Spec 1.5 plan 21: a role's shell (CATHERD_ROLE, or its scratch TMPDIR) never runs a command that writes the
30
+ * orchestrator's state (pause, runs cancel, profile set, …): E_ROLE_SCOPE, as `catherd run-file` and `catherd gate`
31
+ * refuse an operation the role has not. `command` is the command as typed, like `catherd pause`.
32
+ */
33
+ export function refuseInRole(env: Record<string, string | undefined>, command: string): void {
34
+ const scope = roleScopeFromEnv(env);
35
+ if (!scope) return;
36
+ throw new CatherdError(
37
+ "E_ROLE_SCOPE",
38
+ `${command} is the orchestrator's: this process runs ${scope.name} of run ${scope.run}`,
39
+ { fix: "report it in your reply; the orchestrator does it" },
40
+ );
41
+ }
42
+
27
43
  export const printJson = (v: unknown): void => console.log(JSON.stringify(v, null, 2));
28
44
 
29
45
  /** The `--json` flag every read command takes. */
package/src/entry/deps.ts CHANGED
@@ -1,4 +1,5 @@
1
1
  import type { HostContext } from "../domain/host.ts";
2
+ import { roleScopeFromEnv } from "../domain/role-scope.ts";
2
3
  import { readSessionEnv } from "../infra/claude-session.ts";
3
4
  import { VERSION } from "../infra/version.ts";
4
5
  import type { Deps } from "../services/ports.ts";
@@ -15,6 +16,7 @@ export function defaultDeps(host: HostContext = { host: "unknown", session: null
15
16
  pollMs: 250,
16
17
  session: readSessionEnv(process.env),
17
18
  now: Date.now,
19
+ role: roleScopeFromEnv(process.env),
18
20
  };
19
21
  return deps;
20
22
  }