catherd-cli 1.3.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 (159) hide show
  1. package/README.md +144 -42
  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 +11 -0
  7. package/plugin/.mcp-codex.json +10 -0
  8. package/plugin/bin/catherd-mcp +1 -1
  9. package/plugin/skills/catherd/SKILL.md +150 -79
  10. package/plugin/skills/catherd-setup/SKILL.md +24 -8
  11. package/src/adapters/access.ts +7 -1
  12. package/src/adapters/antigravity/index.ts +18 -3
  13. package/src/adapters/backend.ts +18 -2
  14. package/src/adapters/claude-code/index.ts +126 -14
  15. package/src/adapters/cli.ts +1 -64
  16. package/src/adapters/codex/events.ts +10 -0
  17. package/src/adapters/codex/index.ts +17 -5
  18. package/src/adapters/cursor/index.ts +7 -1
  19. package/src/adapters/grok/home.ts +75 -20
  20. package/src/adapters/grok/index.ts +27 -8
  21. package/src/adapters/opencode/index.ts +38 -1
  22. package/src/cli.ts +53 -19
  23. package/src/domain/agents.ts +3 -1
  24. package/src/domain/brief.ts +91 -0
  25. package/src/domain/calibration.ts +7 -0
  26. package/src/domain/catalog.ts +12 -1
  27. package/src/domain/changes.ts +23 -1
  28. package/src/domain/cost.ts +8 -3
  29. package/src/domain/errors.ts +4 -0
  30. package/src/domain/failover.ts +17 -5
  31. package/src/domain/gate-brief.ts +52 -0
  32. package/src/domain/hints.ts +13 -2
  33. package/src/domain/host.ts +17 -0
  34. package/src/domain/lane.ts +99 -3
  35. package/src/domain/notice.ts +14 -4
  36. package/src/domain/profile-rules.ts +129 -16
  37. package/src/domain/profile.ts +56 -5
  38. package/src/domain/record.ts +17 -2
  39. package/src/domain/reply.ts +29 -0
  40. package/src/domain/role-prompts.ts +56 -16
  41. package/src/domain/role-scope.ts +106 -0
  42. package/src/domain/role-tools.ts +34 -0
  43. package/src/domain/route.ts +73 -0
  44. package/src/domain/select.ts +177 -25
  45. package/src/domain/state.ts +5 -0
  46. package/src/domain/util.ts +22 -0
  47. package/src/domain/workspace.ts +130 -0
  48. package/src/entry/catalog-command.ts +18 -8
  49. package/src/entry/cli-kit.ts +17 -1
  50. package/src/entry/deps.ts +8 -3
  51. package/src/entry/doctor-command.ts +29 -2
  52. package/src/entry/host-arg.ts +56 -0
  53. package/src/entry/init-command.ts +20 -8
  54. package/src/entry/knowledge-command.ts +78 -3
  55. package/src/entry/lock-command.ts +111 -10
  56. package/src/entry/mcp/dispatch-tools.ts +3 -3
  57. package/src/entry/mcp/handshake.ts +8 -2
  58. package/src/entry/mcp/lane-tools.ts +55 -8
  59. package/src/entry/mcp/protocol-tools.ts +10 -4
  60. package/src/entry/mcp/push-tools.ts +16 -0
  61. package/src/entry/mcp/role-bin.ts +20 -0
  62. package/src/entry/mcp/role-server.ts +116 -0
  63. package/src/entry/mcp/run-tools.ts +25 -6
  64. package/src/entry/mcp/server.ts +224 -44
  65. package/src/entry/mcp/setup-tools.ts +11 -5
  66. package/src/entry/mcp/workspace-tools.ts +142 -0
  67. package/src/entry/pause-command.ts +67 -0
  68. package/src/entry/profile-command.ts +131 -16
  69. package/src/entry/role-cli-command.ts +115 -0
  70. package/src/entry/runs-command.ts +157 -11
  71. package/src/entry/supervise-command.ts +30 -3
  72. package/src/entry/tui/effects.ts +23 -8
  73. package/src/entry/tui/fixtures.ts +33 -10
  74. package/src/entry/tui/run.tsx +10 -4
  75. package/src/entry/tui/state.ts +56 -16
  76. package/src/entry/tui/stories.ts +11 -4
  77. package/src/entry/tui/views/profile-actions.ts +4 -2
  78. package/src/entry/tui/views/profiles.tsx +3 -3
  79. package/src/entry/tui/views/runs.tsx +66 -20
  80. package/src/entry/tui/views/save-dialog.tsx +8 -4
  81. package/src/entry/tui/views/status.tsx +6 -2
  82. package/src/entry/workspace-command.ts +44 -0
  83. package/src/infra/boot-lock.ts +25 -0
  84. package/src/infra/cli.ts +65 -0
  85. package/src/infra/codex-queue.ts +165 -0
  86. package/src/infra/delivery.ts +86 -0
  87. package/src/infra/dispatch-dir.ts +3 -0
  88. package/src/infra/env.ts +15 -2
  89. package/src/infra/git.ts +6 -0
  90. package/src/infra/heavy-lock.ts +93 -4
  91. package/src/infra/host-context.ts +58 -0
  92. package/src/infra/host-probe.ts +16 -0
  93. package/src/infra/lock-activity.ts +88 -0
  94. package/src/infra/login-env.ts +48 -0
  95. package/src/infra/paths.ts +35 -0
  96. package/src/infra/peer-inbox.ts +4 -1
  97. package/src/infra/role-mcp.ts +69 -0
  98. package/src/infra/sources/arena.ts +11 -3
  99. package/src/infra/sources/artificial-analysis.ts +8 -1
  100. package/src/infra/sources/cache.ts +2 -0
  101. package/src/infra/supervisor.ts +110 -18
  102. package/src/services/admission.ts +117 -26
  103. package/src/services/agent-links.ts +30 -5
  104. package/src/services/backends.ts +7 -0
  105. package/src/services/budget.ts +26 -6
  106. package/src/services/catalog-service.ts +20 -12
  107. package/src/services/credentials.ts +11 -6
  108. package/src/services/dispatch-service.ts +220 -22
  109. package/src/services/dispatches.ts +36 -3
  110. package/src/services/doctor-access.ts +8 -3
  111. package/src/services/doctor-backends.ts +30 -7
  112. package/src/services/doctor-checks.ts +20 -5
  113. package/src/services/doctor-docker.ts +221 -0
  114. package/src/services/doctor-push.ts +69 -90
  115. package/src/services/doctor-role-mcp.ts +65 -0
  116. package/src/services/doctor.ts +172 -38
  117. package/src/services/end-push.ts +157 -0
  118. package/src/services/finalize.ts +13 -3
  119. package/src/services/gate-env.ts +218 -0
  120. package/src/services/gate-service.ts +216 -45
  121. package/src/services/global-install.ts +46 -13
  122. package/src/services/lane-edit.ts +154 -0
  123. package/src/services/lane-service.ts +258 -52
  124. package/src/services/milestones.ts +137 -26
  125. package/src/services/notifier.ts +377 -72
  126. package/src/services/orchestrator-wait.ts +82 -0
  127. package/src/services/pause.ts +161 -0
  128. package/src/services/peek.ts +122 -23
  129. package/src/services/ports.ts +39 -4
  130. package/src/services/preflight.ts +153 -19
  131. package/src/services/profile-service.ts +117 -30
  132. package/src/services/profile-store.ts +31 -7
  133. package/src/services/protocol.ts +119 -25
  134. package/src/services/provenance.ts +16 -12
  135. package/src/services/questions.ts +15 -0
  136. package/src/services/reconcile.ts +22 -1
  137. package/src/services/reentry.ts +7 -6
  138. package/src/services/role-access.ts +27 -0
  139. package/src/services/routing-service.ts +150 -37
  140. package/src/services/run-debug.ts +98 -4
  141. package/src/services/run-pin.ts +125 -0
  142. package/src/services/run-service.ts +153 -19
  143. package/src/services/run-store.ts +119 -19
  144. package/src/services/runs-page.ts +45 -9
  145. package/src/services/scratch.ts +60 -0
  146. package/src/services/session-view.ts +18 -13
  147. package/src/services/sessions.ts +34 -16
  148. package/src/services/setup.ts +31 -10
  149. package/src/services/source-derive.ts +28 -5
  150. package/src/services/source-sync.ts +9 -2
  151. package/src/services/standins.ts +40 -6
  152. package/src/services/state.ts +21 -0
  153. package/src/services/summary.ts +86 -11
  154. package/src/services/treat-likes.ts +41 -12
  155. package/src/services/verifier-brief.ts +23 -0
  156. package/src/services/verifier-step.ts +48 -0
  157. package/src/services/workspace-admission.ts +267 -0
  158. package/src/services/workspace-service.ts +254 -0
  159. package/src/services/workspace-store.ts +95 -0
@@ -17,9 +17,12 @@ export type ErrorCode =
17
17
  | "E_ADMIT_OVERLAP"
18
18
  | "E_ADMIT_ID"
19
19
  | "E_ADMIT_THREAD"
20
+ | "E_ADMIT_PAUSED"
21
+ | "E_ADMIT_ORDER"
20
22
  | "E_LANE_INVALID"
21
23
  | "E_LAND_GATE"
22
24
  | "E_CLIMB_DESIGN"
25
+ | "E_CLIMB_ENV"
23
26
  | "E_JEV_KEY"
24
27
  | "E_JEV_NETWORK"
25
28
  | "E_JEV_RESPONSE"
@@ -28,6 +31,7 @@ export type ErrorCode =
28
31
  | "E_IO_PATH"
29
32
  | "E_IO_UNEXPECTED"
30
33
  | "E_INPUT_INVALID"
34
+ | "E_ROLE_SCOPE"
31
35
  | "E_RUNTIME_TOO_OLD";
32
36
 
33
37
  /**
@@ -45,10 +45,18 @@ export function barDims(c: Catalog, rung: string): Dim[] {
45
45
  * well: no downgrade.
46
46
  */
47
47
  export function downgradeDims(c: Catalog, rung: string, standIn: string): Dim[] {
48
- const a = valuesOf(c, rung);
48
+ return downgradeAgainst(valuesOf(c, rung), barDims(c, rung), valuesOf(c, standIn));
49
+ }
50
+
51
+ /** downgradeDims with the rung's values and bar dims computed once, for a pool of stand-ins. */
52
+ function downgradeAgainst(
53
+ a: Partial<Record<Dim, number>> | null,
54
+ dims: Dim[],
55
+ standIn: Partial<Record<Dim, number>> | null,
56
+ ): Dim[] {
49
57
  if (!a) return [];
50
- const b = valuesOf(c, standIn) ?? {};
51
- return barDims(c, rung).filter((d) => (b[d] ?? Number.NEGATIVE_INFINITY) < (a[d] as number));
58
+ const b = standIn ?? {};
59
+ return dims.filter((d) => (b[d] ?? Number.NEGATIVE_INFINITY) < (a[d] as number));
52
60
  }
53
61
 
54
62
  /**
@@ -85,11 +93,15 @@ export function rankStandIns(
85
93
  ): string[] {
86
94
  const from = tryParseRung(rung);
87
95
  if (!from) return [];
96
+ // the rung's own values and bar dims, once for the whole pool
97
+ const own = valuesOf(c, rung);
98
+ const dims = barDims(c, rung);
88
99
  const fits = [...new Set(pool)].filter((x) => {
89
100
  const r = tryParseRung(x);
90
- if (!r || r.backend === "claude" || quotaOf(r) === quotaOf(from) || !valuesOf(c, x)) return false;
101
+ const values = r ? valuesOf(c, x) : null;
102
+ if (!r || r.backend === "claude" || quotaOf(r) === quotaOf(from) || !values) return false;
91
103
  if (costFor(c, billing, x).tier === 1) return false;
92
- return downgradeDims(c, rung, x).length === 0;
104
+ return downgradeAgainst(own, dims, values).length === 0;
93
105
  });
94
106
  const gap = (x: string) => effortGap(from.effort, tryParseRung(x)?.effort ?? "");
95
107
  return fits.sort(
@@ -0,0 +1,52 @@
1
+ // Plan 23: how a verifier runs a gate, which every verifier gets (its agent file, or its dispatch brief), and
2
+ // the run's own facts a dispatched verifier's brief adds: the items already recorded, the failed ones to
3
+ // re-check first, and the repo's gate environment.
4
+
5
+ /** A re-check's cap on each command, in minutes: a hung command fails its item, not the verifier. */
6
+ export const RECHECK_COMMAND_MIN = 10;
7
+
8
+ /** The rules of the gate, one paragraph each. */
9
+ export const VERIFIER_GATE_RULES = [
10
+ "Run the root gate (the milestone's full check) as one gate item and each per-service acceptance suite as its own item, never as one command: a failure then names its item, and a re-check reruns only that item. Acceptance that builds from HEAD (git archive, a commit's image) is yours: workers do not commit, so they never run it.",
11
+ "Stay in the foreground until your verdict: no background watcher, and never end your turn while a command runs.",
12
+ `When the machine, not the work, stops an item (a VPN filter, a Docker client proxy, a dead registry, no DNS), probe it directly, twice, 5 s apart, and if it still fails, reply VERDICT: BLOCKED: environment — <the probe and its output>. That is a blocker for the owner, not a FAIL.`,
13
+ "Last, when you built Docker images, run docker image prune -f, so the gates of later milestones do not fill the disk.",
14
+ ];
15
+
16
+ /** The marker a verifier brief's catherd section starts with: added once. */
17
+ export const GATE_NOTES_HEADING = "## The gate (catherd)";
18
+
19
+ /** The run's facts for one verifier dispatch. */
20
+ export interface GateNotes {
21
+ milestone: string | null;
22
+ /** the milestone's recorded items, with whether each passed */
23
+ recorded: { item: string; passed: boolean }[];
24
+ /** the repo's gate environment, as `NAME=value` or `NAME=$FROM` */
25
+ env: string[];
26
+ }
27
+
28
+ /** The section a dispatched verifier's brief ends with (before the reply contract). */
29
+ export function gateNotes(n: GateNotes): string {
30
+ const failed = n.recorded.filter((r) => !r.passed).map((r) => r.item);
31
+ const lines = [GATE_NOTES_HEADING, "", ...VERIFIER_GATE_RULES.map((r) => `- ${r}`)];
32
+ if (n.milestone && n.recorded.length) {
33
+ lines.push(
34
+ `- Items already recorded for ${n.milestone}; reuse these names in gate_check, so what passed is carried over: ${n.recorded.map((r) => r.item).join(", ")}.`,
35
+ );
36
+ if (failed.length)
37
+ lines.push(
38
+ `- This is a re-check. Run the failed items first: ${failed.join(", ")}. Cap each command at ${RECHECK_COMMAND_MIN} minutes; a command that runs longer fails its item (say so), it never holds the gate.`,
39
+ );
40
+ }
41
+ if (n.env.length)
42
+ lines.push(
43
+ `- The repo's gate environment is already set in your env (a secret as $NAME of where it came from): ${n.env.join(", ")}.`,
44
+ );
45
+ return lines.join("\n");
46
+ }
47
+
48
+ /** `brief` with the gate notes appended, once: a brief that already has them is kept. */
49
+ export function withGateNotes(brief: string, n: GateNotes): string {
50
+ if (brief.includes(GATE_NOTES_HEADING)) return brief;
51
+ return `${brief.trimEnd()}\n\n${gateNotes(n)}\n`;
52
+ }
@@ -3,11 +3,22 @@ import type { RunRecord } from "./record.ts";
3
3
  /** Spec §4.4: what the orchestrator does next, read off one finished record. `dir` is the dispatch folder, relative to the run. */
4
4
  export function dispatchHints(r: RunRecord, owns: string[], dir: string): string[] {
5
5
  const h: string[] = [];
6
- if (r.status === "limit") h.push(`limit: ${r.backend} hit a usage limit on ${r.rung}`);
6
+ if (r.status === "limit")
7
+ h.push(
8
+ r.error?.code === "provider-unavailable"
9
+ ? `limit: ${r.rung}'s provider is unavailable (${r.error.message}); it fails over as on a usage limit`
10
+ : `limit: ${r.backend} hit a usage limit on ${r.rung}`,
11
+ );
7
12
  if (r.status === "cli-too-old") h.push(`cli-too-old: ${r.error?.message ?? `upgrade ${r.backend}`}`);
8
13
  if (r.status === "failed") h.push(`failed: read ${dir}/stderr`);
9
14
  if (r.gitUnavailable) h.push("git-unavailable: changed files unknown");
10
- if (r.replyStatus === "refused" || r.replyStatus === "blocked") h.push(`climb: ${r.replyStatus}`);
15
+ // plan 23: the environment stopped it, and a higher rung would stop the same way: fix it or surface it
16
+ if (r.environment) h.push(`environment: ${r.environment}`);
17
+ else if (r.replyStatus === "flaky")
18
+ h.push(
19
+ `flaky: ${r.replyWhy || "a check failed, then passed alone"}; rerun it alone, accept it, or climb: your call`,
20
+ );
21
+ else if (r.replyStatus === "refused" || r.replyStatus === "blocked") h.push(`climb: ${r.replyStatus}`);
11
22
  else if (
12
23
  r.status === "ok" &&
13
24
  !r.gitUnavailable &&
@@ -0,0 +1,17 @@
1
+ export type OrchestrationHost = "claude-code" | "codex" | "unknown";
2
+ export type KnownHost = Exclude<OrchestrationHost, "unknown">;
3
+ export interface HostSessionRef {
4
+ host: KnownHost;
5
+ sessionId: string;
6
+ hostSessionId: string | null;
7
+ name: string | null;
8
+ }
9
+ export interface HostContext {
10
+ host: OrchestrationHost;
11
+ session: HostSessionRef | null;
12
+ conflict: string | null;
13
+ }
14
+ export function sessionKey(s: Pick<HostSessionRef, "host" | "sessionId">): string {
15
+ // a Codex thread is a UUID, whatever its casing: an owner stored before normalization still matches
16
+ return JSON.stringify([s.host, s.host === "codex" ? s.sessionId.toLowerCase() : s.sessionId]);
17
+ }
@@ -11,18 +11,47 @@ export interface LaneHeader {
11
11
  fastCheck: string | null;
12
12
  kind: Kind | null;
13
13
  difficulty: Difficulty | null;
14
+ /** `After: M1.L1, M1.L2`: lanes that must finish first (protocol.next and admission keep the order) */
15
+ after: string[];
16
+ /** `Allow: path[:line], …` under the check: hits of the lane's absence check that are allowed exceptions */
17
+ allow: string[];
14
18
  }
15
19
 
20
+ /** A lane id in an After: line: Mx.Ly, the shape lanes/<id>.md takes. */
21
+ const LANE_ID = /^[A-Za-z0-9][A-Za-z0-9_-]*\.[A-Za-z0-9][A-Za-z0-9._-]*$/;
22
+
23
+ /** An Allow: entry: a repo-relative path, optionally `:<line>`. */
24
+ const ALLOW_ENTRY = /^([^\s:]+)(?::(\d+))?$/;
25
+
16
26
  const unquote = (s: string) =>
17
27
  s
18
28
  .trim()
19
29
  .replace(/^`+|`+$/g, "")
20
30
  .trim();
21
31
 
22
- /** The value of the first `Label:` line, allowing `**Label:**` and `_Label_:` emphasis. */
32
+ /**
33
+ * The lane's header block, as [start, end) line indexes: the lines under the `# ` title (blank lines right
34
+ * after it skipped) up to the first blank line or heading. Header lines are read only here, so a body's prose
35
+ * (`After: the handler returns 404`) is never a header value.
36
+ */
37
+ export function headerBlock(lines: string[]): [number, number] {
38
+ const title = lines.findIndex((l) => /^#\s+/.test(l));
39
+ let start = title + 1;
40
+ while (start < lines.length && (lines[start] ?? "").trim() === "") start++;
41
+ let end = start;
42
+ while (end < lines.length && (lines[end] ?? "").trim() !== "" && !(lines[end] ?? "").startsWith("#")) end++;
43
+ return [start, end];
44
+ }
45
+
46
+ /** A header line `Label:`, allowing `**Label:**` and `_Label_:` emphasis; group 1 is its value. */
47
+ export const headerLine = (label: string) => new RegExp(`^\\s*[*_]*${label}[*_]*\\s*:[*_]*\\s*(.*)$`, "i");
48
+
49
+ /** The value of the first `Label:` line of the header block. */
23
50
  function field(text: string, label: string): string | null {
24
- const re = new RegExp(`^\\s*[*_]*${label}[*_]*\\s*:[*_]*\\s*(.*)$`, "i");
25
- for (const line of text.split("\n")) {
51
+ const re = headerLine(label);
52
+ const lines = text.split("\n");
53
+ const [start, end] = headerBlock(lines);
54
+ for (const line of lines.slice(start, end)) {
26
55
  const m = re.exec(line);
27
56
  if (m) return (m[1] ?? "").trim();
28
57
  }
@@ -51,15 +80,81 @@ export function parseLaneHeader(text: string): LaneHeader {
51
80
  const title = /^#\s+(.+)$/m.exec(text)?.[1]?.trim() ?? null;
52
81
  const owns = (field(text, "owns") ?? "").split(",").map(unquote).filter(Boolean).map(normalizeOwned);
53
82
  const check = field(text, "fast check");
83
+ const list = (label: string) => (field(text, label) ?? "").split(",").map(unquote).filter(Boolean);
54
84
  return {
55
85
  title,
56
86
  owns,
57
87
  fastCheck: check ? unquote(check) || null : null,
58
88
  kind: oneOf(field(text, "kind"), KINDS),
59
89
  difficulty: oneOf(field(text, "difficulty"), DIFFICULTIES),
90
+ after: list("after"),
91
+ allow: list("allow"),
60
92
  };
61
93
  }
62
94
 
95
+ /** What is wrong with the After: and Allow: entries, one line each; none when they are well formed. */
96
+ function orderProblems(h: LaneHeader): string[] {
97
+ const out: string[] = [];
98
+ for (const a of h.after) if (!LANE_ID.test(a)) out.push(`After "${a}" is not a lane id like M1.L1`);
99
+ for (const a of h.allow) {
100
+ const m = ALLOW_ENTRY.exec(a);
101
+ try {
102
+ if (!m) throw new Error();
103
+ normalizeOwned(m[1] as string);
104
+ } catch {
105
+ out.push(`Allow "${a}" is not a repo path, or path:line`);
106
+ }
107
+ }
108
+ return out;
109
+ }
110
+
111
+ /**
112
+ * Spec 1.5 "Lane editing": `write_run_file` refuses a lane whose header values are present but wrong (a Kind
113
+ * or Difficulty the catalog does not know, an Owns path that leaves the repo, a malformed After: or Allow:),
114
+ * so a typo is caught when the lane is written, not at preflight. A missing line is left to routing.
115
+ */
116
+ export function assertLaneValues(text: string, where: string): LaneHeader {
117
+ let h: LaneHeader;
118
+ try {
119
+ h = parseLaneHeader(text);
120
+ } catch (e) {
121
+ throw new CatherdError("E_LANE_INVALID", `${where}: ${(e as Error).message}`, {
122
+ fix: "write Owns: paths like src/foo/ or src/bar.ts",
123
+ });
124
+ }
125
+ const problems: string[] = [];
126
+ const kind = field(text, "kind");
127
+ const difficulty = field(text, "difficulty");
128
+ if (kind !== null && h.kind === null) problems.push(`Kind "${unquote(kind)}" is not one the catalog knows`);
129
+ if (difficulty !== null && h.difficulty === null)
130
+ problems.push(`Difficulty "${unquote(difficulty)}" is not one the catalog knows`);
131
+ problems.push(...orderProblems(h));
132
+ if (problems.length)
133
+ throw new CatherdError("E_LANE_INVALID", `${where}: ${problems.join("; ")}`, {
134
+ fix: `${LANE_HEADER_FIX}; After: M1.L1, M1.L2; Allow: path or path:line`,
135
+ });
136
+ return h;
137
+ }
138
+
139
+ /**
140
+ * Whether every line a failing check printed is a `path:line` hit its lane allows: an absence check (a grep
141
+ * that must find nothing) whose only hits are the lane's declared exceptions has passed.
142
+ */
143
+ export function onlyAllowedHits(lines: string[], allow: string[]): boolean {
144
+ const hits = lines.map((l) => /^([^\s:]+):(\d+)(?::|$)/.exec(l.trim()));
145
+ if (allow.length === 0 || hits.length === 0 || hits.some((m) => !m)) return false;
146
+ return hits.every((m) =>
147
+ allow.some((entry) => {
148
+ const [, path, line] = ALLOW_ENTRY.exec(entry) ?? [];
149
+ if (!path) return false;
150
+ const file = (m as RegExpExecArray)[1] as string;
151
+ const at = (m as RegExpExecArray)[2];
152
+ const inPath = file === path || (path.endsWith("/") && file.startsWith(path));
153
+ return inPath && (line === undefined || line === at);
154
+ }),
155
+ );
156
+ }
157
+
63
158
  /** The fix every lane-header refusal carries: the values the catalog knows. */
64
159
  export const LANE_HEADER_FIX = `write the lane's header lines as Kind: ${KINDS.join("|")} and Difficulty: ${DIFFICULTIES.join("|")}`;
65
160
 
@@ -78,6 +173,7 @@ export function assertLaneHeader(text: string, where: string): LaneHeader {
78
173
  };
79
174
  check("Kind", field(text, "kind"), h.kind !== null);
80
175
  check("Difficulty", field(text, "difficulty"), h.difficulty !== null);
176
+ problems.push(...orderProblems(h));
81
177
  if (problems.length)
82
178
  throw new CatherdError("E_LANE_INVALID", `${where}: ${problems.join("; ")}`, { fix: LANE_HEADER_FIX });
83
179
  return h;
@@ -9,6 +9,7 @@ export type NoticePriority = "later" | "next";
9
9
  /** One role's news: it finished (`record`), or an event while it runs (`stall`). */
10
10
  export interface Notice {
11
11
  kind: "finished" | "stalled";
12
+ eventId: string;
12
13
  runId: string;
13
14
  runTitle: string;
14
15
  dispatchId: string;
@@ -16,6 +17,8 @@ export interface Notice {
16
17
  role: string;
17
18
  /** the lane it worked, when it had one: the multi-role preview names it */
18
19
  lane: string | null;
20
+ /** the role's thread, so a fix round resumes it without reading runs.jsonl; null before the CLI named one */
21
+ thread: string | null;
19
22
  rung: string;
20
23
  /** the record's status, or for a limit what failover did ("limit on X; failed over to Y"), or the event */
21
24
  status: string;
@@ -33,15 +36,19 @@ export const REPLY_CAP = 2_000;
33
36
 
34
37
  const plural = (n: number, one: string, many: string) => `${n} ${n === 1 ? one : many}`;
35
38
 
39
+ /** `thread: <id>`, or `thread: none` before the CLI named one: every message's first line carries it. */
40
+ const threadWord = (n: Notice): string => `thread: ${n.thread ?? "none"}`;
41
+
36
42
  /** The first line of one role's block: it must stand alone, since the Desktop preview shows only it. */
37
43
  export function noticeHeader(n: Notice): string {
38
44
  const head = `catherd · ${n.runTitle} · ${n.name} ${n.role} · ${n.rung} · ${n.status}`;
39
- if (n.kind === "stalled") return `${head} · running ${n.secs}s`;
45
+ if (n.kind === "stalled") return `${head} · running ${n.secs}s · ${threadWord(n)}`;
40
46
  return [
41
47
  head,
42
48
  n.replyStatus ? `STATUS: ${n.replyStatus}` : "no STATUS",
43
49
  `${n.secs}s`,
44
50
  plural(n.changedOwned, "owned file changed", "owned files changed"),
51
+ threadWord(n),
45
52
  ].join(" · ");
46
53
  }
47
54
 
@@ -62,9 +69,11 @@ const neutral = (s: string) => s.replace(/(cross)-(session)-(message)/gi, "$1 $2
62
69
 
63
70
  function block(n: Notice): string {
64
71
  const call = `run: "${n.runId}", name: "${n.name}"`;
65
- if (n.kind === "stalled") return `${noticeHeader(n)}\nPeek: peek(${call})`;
72
+ if (n.kind === "stalled") return `${noticeHeader(n)}\nPeek: peek(${call})\nEvent: ${n.eventId}`;
66
73
  const reply = capNoticeReply(n.reply);
67
- return [noticeHeader(n), ...(reply ? [reply] : []), `Record: result(${call})`].join("\n");
74
+ return [noticeHeader(n), ...(reply ? [reply] : []), `Record: result(${call})`, `Event: ${n.eventId}`].join(
75
+ "\n",
76
+ );
68
77
  }
69
78
 
70
79
  /** The preview line of a message that carries several roles: `catherd · <title> · 3 roles finished: …`. */
@@ -75,7 +84,8 @@ function previewOf(ns: Notice[]): string {
75
84
  ? `${ns.length} roles finished`
76
85
  : `${plural(ns.length, "role", "roles")} to look at`;
77
86
  const each = ns.map(
78
- (n) => `${n.lane ?? n.name} ${n.kind === "stalled" ? "stalled" : n.status.split(";")[0]}`,
87
+ (n) =>
88
+ `${n.lane ?? n.name} ${n.kind === "stalled" ? "stalled" : n.status.split(";")[0]} (${threadWord(n)})`,
79
89
  );
80
90
  return `catherd · ${where} · ${what}: ${each.join(", ")}`;
81
91
  }
@@ -1,3 +1,4 @@
1
+ import type { OrchestrationHost } from "./host.ts";
1
2
  import {
2
3
  type Catalog,
3
4
  type Dim,
@@ -20,10 +21,25 @@ import { tryParseRung } from "./ids.ts";
20
21
  import { DIFFICULTIES, KINDS } from "./lane.ts";
21
22
  import { type Profile, type ProfileDoc, unknownValues } from "./profile.ts";
22
23
  import { DEFAULT_ACCESS, ROLES, type Role } from "./roles.ts";
23
- import { candidates, clearsBar, type RoutingProfile } from "./select.ts";
24
+ import {
25
+ type Candidate,
26
+ candidates,
27
+ clearsBar,
28
+ defaultLadder,
29
+ type RoutingProfile,
30
+ select,
31
+ } from "./select.ts";
24
32
 
25
33
  export { quotaOf };
26
34
 
35
+ /**
36
+ * `reach`: also warn about the kinds and difficulties no worker rung clears (spec 1.5 plan 24), which only an
37
+ * explicit `profile validate` asks for.
38
+ */
39
+ export interface ValidateOptions {
40
+ reach?: boolean;
41
+ }
42
+
27
43
  /** One finding of `validate`: where in the profile, what is wrong, and the action that fixes it. */
28
44
  export interface Issue {
29
45
  path: string;
@@ -113,7 +129,8 @@ export const routingProfileOf = (p: Profile, role: Role): RoutingProfile => ({
113
129
  /**
114
130
  * Whether a rung's scores are catherd's guess: borrowed through a treat-like, filled by an inferred stand-in
115
131
  * (spec 1.2 §6.1), or only `inferred` ones. `note` says what a treat-like lends: "scores borrowed from X"
116
- * when the rung has no value of its own, else the dimensions it borrows (a rung a sync scored on some).
132
+ * when every value is borrowed, else the dimensions it borrows (a rung a sync scored on some); and (1.2
133
+ * minor) what each inferred stand-in lends: "honesty inferred from Y".
117
134
  */
118
135
  export function inferredScores(
119
136
  c: Catalog,
@@ -124,9 +141,17 @@ export function inferredScores(
124
141
  const records = Object.values(s.records);
125
142
  const guessed =
126
143
  s.via !== null || s.inferred.length > 0 || records.every((r) => r.confidence === "inferred");
127
- const all = s.borrowed.length + s.inferred.length === records.length;
128
- const note = s.via ? `${all ? "scores" : s.borrowed.join(", ")} borrowed from ${s.via}` : null;
129
- return { inferred: guessed, via: s.via, note };
144
+ const all = s.borrowed.length === records.length;
145
+ const byStandIn = new Map<string, Dim[]>();
146
+ for (const d of s.inferred) {
147
+ const like = s.standIns[d] as string;
148
+ byStandIn.set(like, [...(byStandIn.get(like) ?? []), d]);
149
+ }
150
+ const parts = [
151
+ ...(s.via ? [`${all ? "scores" : s.borrowed.join(", ")} borrowed from ${s.via}`] : []),
152
+ ...[...byStandIn].map(([like, dims]) => `${dims.join(", ")} inferred from ${like}`),
153
+ ];
154
+ return { inferred: guessed, via: s.via, note: parts.length ? parts.join("; ") : null };
130
155
  }
131
156
 
132
157
  /**
@@ -145,6 +170,8 @@ export function validateProfile(
145
170
  c: Catalog,
146
171
  backends: readonly string[],
147
172
  doc?: ProfileDoc,
173
+ host: OrchestrationHost = "unknown",
174
+ o: ValidateOptions = {},
148
175
  ): Validation {
149
176
  const errors: Issue[] = [];
150
177
  const warnings: Issue[] = [];
@@ -170,7 +197,16 @@ export function validateProfile(
170
197
  message: `${role} runs ${rc.access}; catherd's default for it is ${DEFAULT_ACCESS[role]}`,
171
198
  });
172
199
  if (!rc.enabled) continue;
200
+ // spec §5: network: false takes a workspace-write role's network grants; read-only and full have none to take
201
+ if (rc.network === false && rc.access !== "workspace-write")
202
+ warnings.push({
203
+ path: `${at}.network`,
204
+ message: `network: false does nothing here: it applies to workspace-write roles, and ${role} runs ${rc.access}`,
205
+ fix: `catherd profile set ${at}.network null`,
206
+ });
173
207
  for (const rung of rc.rungs) {
208
+ const native = nativeClaudeIssue(rung, host, `${at}.rungs`);
209
+ if (native) errors.push(native);
174
210
  const r = tryParseRung(rung);
175
211
  if (!r) {
176
212
  errors.push({
@@ -214,7 +250,7 @@ export function validateProfile(
214
250
  if (!scoresOf(c, info.canonical))
215
251
  warnings.push({
216
252
  path: `${at}.rungs`,
217
- message: `${rung} is unscored and no rung is near enough to stand in for it: routing skips it`,
253
+ message: `${rung} is unscored and no rung is near enough to stand in for it: routing skips it${rung === rc.defaultRung ? `; it is the ${role}'s default rung, so ${defaultFallback(c, p, role)}` : ""}`,
218
254
  fix: TREAT_LIKE_FIX(rung),
219
255
  });
220
256
  }
@@ -253,27 +289,53 @@ export function validateProfile(
253
289
  path: `${at}.rungs`,
254
290
  message: `${x.rung} clears no routing bar, so a lane starts on it only as the role's default rung and never climbs onto it`,
255
291
  });
256
- // spec 1.2 §5.2: the logic and hard bars sit at the 60th and 75th percentiles, which a cost-minded ladder
257
- // may never reach, and those lanes start at the default rung and climb; only a kind whose every bar the
258
- // ladder misses routes blind (plan 14 Ruling 12)
292
+ // spec 1.5 plan 24: every kind and difficulty no worker rung clears, in one warning; those lanes start at the
293
+ // default rung (or an easier difficulty's start) and climb only onto rungs at least as strong. Only an
294
+ // explicit validate says it (Ruling 3): the shipped bars put logic and hard above every Sol rung, the
295
+ // owner's call, so every save and doctor would repeat it
296
+ if (o.reach && role === "worker" && usable.length > 1) {
297
+ const unreached = unreachable(c, usable);
298
+ if (unreached.length)
299
+ warnings.push({
300
+ path: `${at}.rungs`,
301
+ message: `no worker rung clears ${unreached.join("; ")}: those lanes start at the default rung and climb only onto rungs at least as strong (route names the closest)`,
302
+ });
303
+ }
304
+ // spec 1.5 plan 24: a quota whose rungs never start a lane sits idle but for climbs (the identity run's Go).
305
+ // Only the worker routes lanes and climbs (Ruling 9); another role routes lane-less to one rung, so a rung
306
+ // on a second quota is a deliberate spare there, not an idle quota (final review Important 4, Ruling 8)
259
307
  if (role === "worker" && usable.length > 1) {
260
- const blind = KINDS.filter((k) => !DIFFICULTIES.some((d) => usable.some((x) => clearsBar(c, x, k, d))));
261
- if (blind.length)
308
+ const idle = idleQuotas(c, p, role, usable);
309
+ for (const [q, rungs] of idle) {
310
+ // the billing key, not the quota, is what billing is set on (`claude:` and `claude-code:` share one)
311
+ const keys = [...new Set(usable.filter((x) => rungs.includes(x.rung)).map((x) => x.info.key))];
262
312
  warnings.push({
263
313
  path: `${at}.rungs`,
264
- message: `no worker rung clears any bar for ${blind.join(", ")}; those lanes always start at the default rung`,
314
+ message: `${q} never starts a ${role} lane: ${rungs.join(", ")} ${rungs.length > 1 ? "run" : "runs"} only on a climb, since another rung starts every lane`,
315
+ fix: `check ${keys.map((k) => `billing.${k}`).join(" and ")} (a metered rung starts only where nothing paid from a plan clears the bar), or order ${at}.rungs so its rungs come first among equals`,
265
316
  });
317
+ }
266
318
  }
267
319
  }
268
320
 
269
321
  const ladders = new Set(ROLES.filter((r) => p.roles[r].enabled).flatMap((r) => p.roles[r].rungs));
270
- // the stand-ins catherd could run here, for the fixes below
322
+ // the stand-ins the fixes below may suggest: on a backend catherd runs and the profile already names (a
323
+ // ladder or a failover entry), never one this machine may not have (Cursor on a machine without it); a
324
+ // native claude: rung names claude-code too, the same Claude Code CLI
325
+ const named = new Set(
326
+ [...ladders, ...Object.keys(p.failover), ...Object.values(p.failover)].flatMap((x) => {
327
+ const b = tryParseRung(x)?.backend;
328
+ return b === undefined ? [] : b === "claude" ? [b, "claude-code"] : [b];
329
+ }),
330
+ );
271
331
  const pool = catalogRungs(c).filter((x) => {
272
332
  const r = tryParseRung(x);
273
- return r !== null && backends.includes(r.backend);
333
+ return r !== null && backends.includes(r.backend) && named.has(r.backend);
274
334
  });
275
335
  for (const [from, to] of Object.entries(p.failover)) {
276
336
  const at = `failover.${from}`;
337
+ const native = ladders.has(from) ? nativeClaudeIssue(to, host, at) : null;
338
+ if (native) errors.push(native);
277
339
  const a = tryParseRung(from);
278
340
  const b = tryParseRung(to);
279
341
  if (!a || !b) {
@@ -288,7 +350,8 @@ export function validateProfile(
288
350
  errors.push({ path: at, message: `stand-in ${to}: catherd cannot run ${b.backend} yet` });
289
351
  continue;
290
352
  }
291
- if (!scoresOf(c, rungInfo(c, to).canonical))
353
+ const unscored = !scoresOf(c, rungInfo(c, to).canonical);
354
+ if (unscored)
292
355
  warnings.push({
293
356
  path: at,
294
357
  message: `stand-in ${to} is unscored and no rung is near enough to stand in for it`,
@@ -301,7 +364,8 @@ export function validateProfile(
301
364
  fix: "name a stand-in on another backend or plan",
302
365
  });
303
366
  const ranked = rankStandIns(c, p.billing, from, pool);
304
- const down = downgradeDims(c, from, to);
367
+ // an unscored stand-in has its own warning above: no "downgrade" on every bar dim besides
368
+ const down = unscored ? [] : downgradeDims(c, from, to);
305
369
  if (down.length)
306
370
  warnings.push({
307
371
  path: at,
@@ -327,3 +391,52 @@ export function validateProfile(
327
391
  warnings.push({ path: x.path, message: standInMessage(x), fix: SUGGEST_FIX(x.rungs[0] as string) });
328
392
  return { errors, warnings };
329
393
  }
394
+
395
+ /** The role's default rung when it is unscored: what routing starts on instead. */
396
+ function defaultFallback(c: Catalog, p: Profile, role: Role): string {
397
+ try {
398
+ return `routing falls back to ${defaultLadder(c, routingProfileOf(p, role), role).rung}`;
399
+ } catch {
400
+ return "routing has no rung to fall back to";
401
+ }
402
+ }
403
+
404
+ /** `repo_code logic, hard` per kind: the kinds and difficulties no rung of `usable` clears. */
405
+ function unreachable(c: Catalog, usable: Candidate[]): string[] {
406
+ return KINDS.flatMap((k) => {
407
+ const ds = DIFFICULTIES.filter((d) => !usable.some((x) => clearsBar(c, x, k, d)));
408
+ return ds.length ? [`${k} ${ds.join(", ")}`] : [];
409
+ });
410
+ }
411
+
412
+ /**
413
+ * Spec 1.5 plan 24: each quota of the role's usable rungs that no kind and difficulty (nor a lane-less route)
414
+ * ever starts on, whichever quota the run has used least, with its rungs.
415
+ */
416
+ function idleQuotas(c: Catalog, p: Profile, role: Role, usable: Candidate[]): [string, string[]][] {
417
+ const quotas = [...new Set(usable.map((x) => quotaOf(x.info.parsed)))];
418
+ if (quotas.length < 2) return [];
419
+ const starts = new Set<string>();
420
+ // every quota gets its turn as the least used, so a tie it could win counts as a start
421
+ for (const fresh of [null, ...quotas]) {
422
+ const usage = Object.fromEntries(quotas.map((q) => [q, q === fresh ? 0 : 1]));
423
+ const rp = { ...routingProfileOf(p, role), usage };
424
+ starts.add(defaultLadder(c, rp, role).rung);
425
+ for (const k of KINDS) for (const d of DIFFICULTIES) starts.add(select(c, rp, role, k, d).rung);
426
+ }
427
+ const started = new Set(usable.filter((x) => starts.has(x.rung)).map((x) => quotaOf(x.info.parsed)));
428
+ return quotas
429
+ .filter((q) => !started.has(q))
430
+ .map((q) => [q, usable.filter((x) => quotaOf(x.info.parsed) === q).map((x) => x.rung)]);
431
+ }
432
+
433
+ export function nativeClaudeIssue(rung: string, host: OrchestrationHost, path: string): Issue | null {
434
+ const r = tryParseRung(rung);
435
+ return host !== "claude-code" && r?.backend === "claude"
436
+ ? {
437
+ path,
438
+ message: `${rung}: native Claude subagents require the claude-code orchestration host`,
439
+ fix: `use claude-code:${r.model}#${r.effort} for headless execution, or --host claude-code`,
440
+ }
441
+ : null;
442
+ }