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
@@ -2,7 +2,7 @@ import { existsSync, readFileSync } from "node:fs";
2
2
  import { join } from "node:path";
3
3
  import { CatherdError } from "../domain/errors.ts";
4
4
  import type { RunRecord } from "../domain/record.ts";
5
- import { git } from "../infra/git.ts";
5
+ import { commitExists, git } from "../infra/git.ts";
6
6
  import { nonBlankLines } from "../infra/store.ts";
7
7
  import { listDispatches } from "./dispatches.ts";
8
8
  import { readAgentRuns, readRecords, readRoutes, type Run, runPaths } from "./run-store.ts";
@@ -50,27 +50,95 @@ export interface MilestoneReviewer {
50
50
  record: RunRecord | null;
51
51
  }
52
52
 
53
- /**
54
- * The milestone's latest passing reviewer since its lanes started: a dispatch named reviewer-<m>…, status
55
- * ok; or a record_agent_run row with role reviewer and the same name, status ok. Null when there is none.
56
- */
57
- export function milestoneReviewer(
53
+ /** Every reviewer attempt of the milestone since its lanes started that ended ok, oldest first, partial or not. */
54
+ function reviewerAttempts(
58
55
  run: Run,
59
56
  m: string,
60
- start = milestoneStart(run, m),
61
- ): MilestoneReviewer | null {
62
- const found: MilestoneReviewer[] = [
57
+ start: string | null,
58
+ ): (MilestoneReviewer & { partial: boolean })[] {
59
+ return [
63
60
  ...readRecords(run)
64
61
  .records.filter((r) => reviewsMilestone(r.name, m) && r.status === "ok" && since(r.endedAt, start))
65
- .map((r) => ({ name: r.name, at: r.endedAt, record: r })),
62
+ .map((r) => ({ name: r.name, at: r.endedAt, record: r, partial: r.replyStatus === "partial" })),
66
63
  ...readAgentRuns(run)
67
64
  .filter(
68
65
  (a) =>
69
66
  a.role === "reviewer" && reviewsMilestone(a.name, m) && a.status === "ok" && since(a.at, start),
70
67
  )
71
- .map((a) => ({ name: a.name, at: a.at, record: null })),
72
- ];
73
- return found.sort((a, b) => Date.parse(a.at) - Date.parse(b.at)).at(-1) ?? null;
68
+ .map((a) => ({ name: a.name, at: a.at, record: null, partial: a.replyStatus === "partial" })),
69
+ ].sort((a, b) => Date.parse(a.at) - Date.parse(b.at));
70
+ }
71
+
72
+ /**
73
+ * The milestone's latest passing reviewer since its lanes started: a dispatch named reviewer-<m>…, status
74
+ * ok; or a record_agent_run row with role reviewer and the same name, status ok. A reply of STATUS: partial
75
+ * read only part of the diff and is not the milestone's review (plan 23). Null when there is none.
76
+ */
77
+ export function milestoneReviewer(
78
+ run: Run,
79
+ m: string,
80
+ start = milestoneStart(run, m),
81
+ ): MilestoneReviewer | null {
82
+ const full = reviewerAttempts(run, m, start).filter((a) => !a.partial);
83
+ const last = full.at(-1);
84
+ return last ? { name: last.name, at: last.at, record: last.record } : null;
85
+ }
86
+
87
+ /** Plan 23: the milestone's latest reviewer that stopped at STATUS: partial, when no full review stands; else null. */
88
+ export function partialReviewer(
89
+ run: Run,
90
+ m: string,
91
+ start = milestoneStart(run, m),
92
+ ): MilestoneReviewer | null {
93
+ if (milestoneReviewer(run, m, start)) return null;
94
+ const last = reviewerAttempts(run, m, start)
95
+ .filter((a) => a.partial)
96
+ .at(-1);
97
+ return last ? { name: last.name, at: last.at, record: last.record } : null;
98
+ }
99
+
100
+ export const FINDING = /^\s*(?:[-*]\s*)?(BLOCKER|BUG|NIT)\b/;
101
+
102
+ /** A reviewer reply's findings by severity; null when there is no reply to read. */
103
+ export function countFindings(run: Run, r: RunRecord): { BLOCKER: number; BUG: number; NIT: number } | null {
104
+ const file = join(run.dir, r.replyPath);
105
+ if (!r.replyPath || !existsSync(file)) return null;
106
+ const n = { BLOCKER: 0, BUG: 0, NIT: 0 };
107
+ for (const line of readFileSync(file, "utf8").split("\n")) {
108
+ const k = FINDING.exec(line)?.[1] as keyof typeof n | undefined;
109
+ if (k) n[k]++;
110
+ }
111
+ return n;
112
+ }
113
+
114
+ /** The roles a fix round runs as. */
115
+ const FIX_ROLES = new Set(["worker", "writer", "artist"]);
116
+
117
+ /**
118
+ * Plan 23: the milestone reviewer's BLOCKER and BUG lines while no fix round has ended since (a worker,
119
+ * writer or artist of the milestone, ended ok after the review); null when there are none, or for a native
120
+ * reviewer, whose reply catherd does not keep.
121
+ */
122
+ export function openFindings(
123
+ run: Run,
124
+ m: string,
125
+ start = milestoneStart(run, m),
126
+ ): { name: string; blocker: number; bug: number } | null {
127
+ const reviewer = milestoneReviewer(run, m, start);
128
+ if (!reviewer?.record) return null;
129
+ const n = countFindings(run, reviewer.record);
130
+ if (!n || n.BLOCKER + n.BUG === 0) return null;
131
+ const after = (at: string) => Date.parse(at) > Date.parse(reviewer.at);
132
+ const ofM = (name: string, lane: string | null | undefined) =>
133
+ inMilestone(lane, m) || namesMilestone(name, m);
134
+ const fixed =
135
+ readRecords(run).records.some(
136
+ (r) => FIX_ROLES.has(r.role) && r.status === "ok" && ofM(r.name, r.lane) && after(r.endedAt),
137
+ ) ||
138
+ readAgentRuns(run).some(
139
+ (a) => FIX_ROLES.has(a.role) && a.status === "ok" && ofM(a.name, a.lane) && after(a.at),
140
+ );
141
+ return fixed ? null : { name: reviewer.name, blocker: n.BLOCKER, bug: n.BUG };
74
142
  }
75
143
 
76
144
  /** A reviewer record for the milestone since its lanes started (see milestoneReviewer). */
@@ -80,6 +148,15 @@ export const reviewerPassed = (run: Run, m: string, start = milestoneStart(run,
80
148
  /** The verifier's contract puts `VERDICT: PASS` or `VERDICT: FAIL` on the reply's first line (role-prompts.ts). */
81
149
  const VERDICT_PASS = /^VERDICT: PASS\b/;
82
150
 
151
+ /**
152
+ * Plan 23: `VERDICT: BLOCKED: environment — <the probe that proves it>`, a verdict on the machine, not on the
153
+ * work. Its probe, or null when `line` is no such verdict ("" when it names none).
154
+ */
155
+ export function blockedByEnvironment(line: string | null | undefined): string | null {
156
+ const m = /^VERDICT:\s*BLOCKED:\s*environment\b\s*(?:—|–|-|:)?\s*(.*)$/i.exec(line?.trim() ?? "");
157
+ return m ? (m[1] ?? "").trim() : null;
158
+ }
159
+
83
160
  /** A headless verifier's reply: its first non-blank line, trimmed; null when there is no reply to read. */
84
161
  export function replyVerdict(run: Run, r: RunRecord): string | null {
85
162
  const file = join(run.dir, r.replyPath);
@@ -106,11 +183,17 @@ export interface MilestoneVerifier {
106
183
  passed: boolean;
107
184
  /** what the attempt said: a native row's status, or a headless reply's first non-blank line ("no reply") */
108
185
  verdict: string;
186
+ /** plan 23: the probe of a `VERDICT: BLOCKED: environment` attempt ("" when it named none); null otherwise */
187
+ blocked: string | null;
109
188
  }
110
189
 
190
+ /** The one name a milestone's verifier is counted under (spec 1.5 "The ledger"): verifier-<m>, exactly. */
191
+ export const verifierName = (m: string): string => `verifier-${m}`;
192
+
111
193
  /**
112
194
  * The milestone's latest verifier attempt since its lanes started, whatever it said: a record_agent_run row
113
- * with role verifier whose name names the milestone (the skill records a FAIL as failed), passing when its
195
+ * with role verifier named exactly verifier-<m> (the skill records a FAIL as failed; verifier-M2-pre or
196
+ * verifier-M2-gate1 is not M2's verifier), passing when its
114
197
  * status is ok; or a headless verifier's dispatch record of the same shape, passing when it exited ok and its
115
198
  * reply opens VERDICT: PASS (status ok means only the CLI exited). A later FAIL undoes an earlier PASS. Null
116
199
  * when there is none.
@@ -122,16 +205,21 @@ export function milestoneVerifier(
122
205
  ): MilestoneVerifier | null {
123
206
  const found: MilestoneVerifier[] = [
124
207
  ...readAgentRuns(run)
125
- .filter((a) => a.role === "verifier" && namesMilestone(a.name, m) && since(a.at, start))
126
- .map((a) => ({
127
- name: a.name,
128
- at: a.at,
129
- headless: false,
130
- passed: a.status === "ok",
131
- verdict: a.status,
132
- })),
208
+ .filter((a) => a.role === "verifier" && a.name === verifierName(m) && since(a.at, start))
209
+ .map((a) => {
210
+ // plan 23: a native verifier's verdict line, when record_agent_run passed it
211
+ const blocked = a.status === "ok" ? null : blockedByEnvironment(a.verdict);
212
+ return {
213
+ name: a.name,
214
+ at: a.at,
215
+ headless: false,
216
+ passed: a.status === "ok",
217
+ verdict: blocked === null ? a.status : (a.verdict ?? a.status),
218
+ blocked,
219
+ };
220
+ }),
133
221
  ...readRecords(run)
134
- .records.filter((r) => r.role === "verifier" && namesMilestone(r.name, m) && since(r.endedAt, start))
222
+ .records.filter((r) => r.role === "verifier" && r.name === verifierName(m) && since(r.endedAt, start))
135
223
  .map((r) => {
136
224
  const first = replyVerdict(run, r);
137
225
  const passed = r.status === "ok" && first !== null && VERDICT_PASS.test(first);
@@ -142,6 +230,7 @@ export function milestoneVerifier(
142
230
  headless: true,
143
231
  passed,
144
232
  verdict: r.status === "ok" ? said : `${r.status}, ${said}`,
233
+ blocked: r.status === "ok" ? blockedByEnvironment(first) : null,
145
234
  };
146
235
  }),
147
236
  ];
@@ -170,12 +259,16 @@ export function landedMilestones(run: Run): string[] {
170
259
  }
171
260
 
172
261
  /**
173
- * The files the milestone's commit range changed: from the previous landed commit (else the commit's own
174
- * parent) to `commit`. Throws E_IO_UNEXPECTED when git cannot say.
262
+ * The files the milestone's commit range changed: from the previous landed commit, else the HEAD the run
263
+ * started on (meta.json `startHead`, when that commit is still there), else the commit's own parent, to
264
+ * `commit`. Throws E_IO_UNEXPECTED when git cannot say.
175
265
  */
176
266
  export async function milestoneFiles(run: Run, commit: string): Promise<string[]> {
177
- const base = landedCommits(run).at(-1);
178
267
  const repo = run.meta.repo;
268
+ const start = run.meta.startHead;
269
+ const base =
270
+ landedCommits(run).at(-1) ??
271
+ (start && (await commitExists(repo, start).catch(() => false)) ? start : undefined);
179
272
  const r = base
180
273
  ? await git(repo, ["diff", "--name-only", base, commit])
181
274
  : await git(repo, ["show", "--name-only", "--format=", "--first-parent", commit]);
@@ -186,6 +279,24 @@ export async function milestoneFiles(run: Run, commit: string): Promise<string[]
186
279
  return r.out.split("\n").filter(Boolean);
187
280
  }
188
281
 
282
+ /** The most commits a digest lists; the rest are counted. */
283
+ const DIGEST_COMMITS = 20;
284
+
285
+ /**
286
+ * The commits the milestone lands, `<short hash> <subject>`, oldest first: from the previous landed commit
287
+ * (else the commit alone) to `commit`. Just the hash when git cannot say: a digest never fails a landing.
288
+ */
289
+ export async function milestoneCommits(run: Run, commit: string): Promise<string[]> {
290
+ const base = landedCommits(run).at(-1);
291
+ const range = base ? [`${base}..${commit}`] : ["-1", commit];
292
+ const r = await git(run.meta.repo, ["log", "--reverse", "--format=%h %s", ...range]);
293
+ if (r.kind !== "ok") return [commit.slice(0, 7)];
294
+ const all = r.out.split("\n").filter(Boolean);
295
+ return all.length > DIGEST_COMMITS
296
+ ? [...all.slice(-DIGEST_COMMITS), `and ${all.length - DIGEST_COMMITS} earlier`]
297
+ : all;
298
+ }
299
+
189
300
  /** `rev`'s full commit hash in the run's repo. Throws E_IO_UNEXPECTED when git cannot say. */
190
301
  export async function fullCommit(run: Run, rev: string): Promise<string> {
191
302
  const r = await git(run.meta.repo, ["rev-parse", "--verify", "--quiet", `${rev}^{commit}`]);