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
@@ -0,0 +1,254 @@
1
+ import { existsSync, readFileSync, realpathSync, statSync } from "node:fs";
2
+ import { join, resolve } from "node:path";
3
+ import { z } from "zod";
4
+ import { type Budget, budgetStatus } from "../domain/budget.ts";
5
+ import { CatherdError } from "../domain/errors.ts";
6
+ import { WORKSPACE_LIMIT, WorkspaceIdSchema, WorkspaceSchema, type Workspace } from "../domain/workspace.ts";
7
+ import { withFileLock } from "../infra/filelock.ts";
8
+ import { gitToplevel } from "../infra/git.ts";
9
+ import { readVersioned, writeTextAtomic } from "../infra/store.ts";
10
+ import { listDispatches } from "./dispatches.ts";
11
+ import { landedCommits, landedMilestones } from "./milestones.ts";
12
+ import { assertNotPaused, pausesOver } from "./pause.ts";
13
+ import { writePin } from "./run-pin.ts";
14
+ import type { Deps } from "./ports.ts";
15
+ import { createRun, readRecords, type Run } from "./run-store.ts";
16
+ import { claimRun, currentSession } from "./sessions.ts";
17
+ import { refreshState } from "./state.ts";
18
+ import { summarizeRun } from "./summary.ts";
19
+ import { assertWorkspaceBudget, dependencyBlockers, workspaceSpend } from "./workspace-admission.ts";
20
+ import {
21
+ createWorkspace,
22
+ findWorkspace,
23
+ unreadableWarning,
24
+ WORKSPACE_LOCK_WAIT_MS,
25
+ workspaceChildren,
26
+ workspaceDirectory,
27
+ workspaceListing,
28
+ workspacePaths,
29
+ } from "./workspace-store.ts";
30
+
31
+ const ManifestSchema = z.strictObject({
32
+ schema: z.literal(1),
33
+ repos: z
34
+ .record(WorkspaceIdSchema, z.string().min(1))
35
+ .refine((repos) => Object.keys(repos).length <= WORKSPACE_LIMIT),
36
+ });
37
+ const invalid = (message: string) => new CatherdError("E_INPUT_INVALID", message);
38
+
39
+ function registry(root: string, repos?: Record<string, string>): Record<string, string> {
40
+ if (repos !== undefined) {
41
+ const parsed = ManifestSchema.safeParse({ schema: 1, repos });
42
+ if (!parsed.success) throw invalid("workspace repositories require safe aliases and nonempty paths");
43
+ return parsed.data.repos;
44
+ }
45
+ return readVersioned(join(root, "catherd.workspace.json"), ManifestSchema, 1).repos;
46
+ }
47
+
48
+ function workspaceRoot(root: string): string {
49
+ try {
50
+ const real = realpathSync(root);
51
+ if (!statSync(real).isDirectory()) throw new Error("not a directory");
52
+ return real;
53
+ } catch {
54
+ throw new CatherdError("E_IO_PATH", `workspace root is not an existing directory: ${root}`);
55
+ }
56
+ }
57
+
58
+ /** No repository discovery scan: paths are an explicit registry, relative to the workspace root. */
59
+ export async function inspectWorkspace(
60
+ root: string,
61
+ repos?: Record<string, string>,
62
+ ): Promise<{ root: string; repos: Record<string, string> }> {
63
+ root = workspaceRoot(root);
64
+ const declared = registry(root, repos);
65
+ if (!Object.keys(declared).length) throw invalid("workspace requires at least one repository");
66
+ const canonical: Record<string, string> = {};
67
+ for (const [alias, path] of Object.entries(declared)) {
68
+ const top = await gitToplevel(resolve(root, path));
69
+ if (!top)
70
+ throw new CatherdError(
71
+ "E_IO_PATH",
72
+ `workspace repository ${alias} is not inside a git repository: ${path}`,
73
+ );
74
+ const real = realpathSync(top);
75
+ if (Object.values(canonical).includes(real))
76
+ throw invalid(`workspace repository ${alias} duplicates a canonical git root`);
77
+ canonical[alias] = real;
78
+ }
79
+ return { root, repos: canonical };
80
+ }
81
+
82
+ export async function startWorkspace(
83
+ deps: Deps,
84
+ input: {
85
+ root: string;
86
+ title: string;
87
+ aLines: string[];
88
+ repos?: Record<string, string>;
89
+ steps: Array<{
90
+ id: string;
91
+ repo: string;
92
+ title: string;
93
+ aLines: string[];
94
+ dependsOn?: string[];
95
+ milestone?: string;
96
+ release?: "land" | "merge";
97
+ base?: string;
98
+ }>;
99
+ budget?: Budget;
100
+ },
101
+ ): Promise<{ workspace: Workspace; dir: string }> {
102
+ const root = workspaceRoot(input.root);
103
+ const declared = registry(root, input.repos);
104
+ // Only selected repositories are captured. An unused unavailable checkout cannot block this task.
105
+ const selected: Record<string, string> = {};
106
+ for (const step of input.steps) {
107
+ if (!Object.hasOwn(declared, step.repo)) throw invalid(`unknown workspace repository ${step.repo}`);
108
+ selected[step.repo] = declared[step.repo]!;
109
+ }
110
+ const snapshot = WorkspaceSchema.safeParse({
111
+ schema: 1,
112
+ id: "validation",
113
+ root,
114
+ title: input.title,
115
+ aLines: input.aLines,
116
+ createdAt: new Date(deps.now()).toISOString(),
117
+ repos: selected,
118
+ steps: input.steps,
119
+ // #43 finding 4: no cap unless the owner sets one; each child keeps its own profile budget
120
+ budget: input.budget ?? {},
121
+ });
122
+ if (!snapshot.success)
123
+ throw invalid(
124
+ `workspace requires unique step ids, ordered repository reuse, valid budget, and an acyclic dependency graph: ${snapshot.error.issues[0]?.message ?? "invalid"}`,
125
+ );
126
+ const canonical = await inspectWorkspace(root, selected);
127
+ const workspace = createWorkspace({ ...snapshot.data, repos: canonical.repos }, new Date(deps.now()));
128
+ return { workspace, dir: workspaceDirectory(workspace.id) };
129
+ }
130
+
131
+ /** Whether a child still has dispatches nobody collected; null when its records cannot be read. */
132
+ function ownPending(run: Run): boolean | null {
133
+ const { records, corrupt } = readRecords(run);
134
+ if (corrupt) return null;
135
+ const recorded = new Set(records.map((r) => r.dispatchId));
136
+ try {
137
+ return listDispatches(run, true).some((d) => !recorded.has(d.admit.dispatchId));
138
+ } catch {
139
+ return null;
140
+ }
141
+ }
142
+
143
+ export async function startWorkspaceChild(
144
+ deps: Deps,
145
+ input: { workspace: string; step: string },
146
+ ): Promise<{ run: string; dir: string; contract: string | null }> {
147
+ const workspace = findWorkspace(input.workspace);
148
+ const paths = workspacePaths(workspaceDirectory(workspace.id));
149
+ const step = workspace.steps.find((s) => s.id === input.step);
150
+ if (!step) throw invalid(`unknown workspace step ${input.step}`);
151
+ // Recovery must validate the checkout too; stored status remains readable without it. The git work runs
152
+ // outside the workspace lock (#43 finding 5).
153
+ const repo = workspace.repos[step.repo]!;
154
+ if ((await gitToplevel(repo)) !== repo)
155
+ throw new CatherdError(
156
+ "E_IO_PATH",
157
+ `workspace repository ${step.repo} is no longer the captured git root`,
158
+ );
159
+ const run = await withFileLock(
160
+ paths.admission,
161
+ () => childFor(deps, workspace, step, repo, paths.contract),
162
+ { timeoutMs: WORKSPACE_LOCK_WAIT_MS },
163
+ );
164
+ const contract = existsSync(paths.contract) ? join(run.dir, "workspace-contract.md") : null;
165
+ await claimRun(deps, run);
166
+ await refreshState(run);
167
+ return { run: run.id, dir: run.dir, contract };
168
+ }
169
+
170
+ /** Under the workspace lock: the step's child, created once its dependencies release it. */
171
+ async function childFor(
172
+ deps: Deps,
173
+ workspace: Workspace,
174
+ step: Workspace["steps"][number],
175
+ repo: string,
176
+ contractFile: string,
177
+ ): Promise<Run> {
178
+ const children = workspaceChildren(workspace);
179
+ let run = children.find((r) => r.meta.workspace?.step === step.id);
180
+ if (!run) {
181
+ assertNotPaused({ meta: { workspace: { id: workspace.id, step: step.id } } });
182
+ const blockers = await dependencyBlockers(workspace, step, children, deps.now(), { merge: true });
183
+ if (blockers.length)
184
+ throw invalid(`workspace step ${step.id} waits: ${blockers.map((b) => b.why).join("; ")}`);
185
+ await assertWorkspaceBudget(workspace, deps.now(), children);
186
+ run = createRun({
187
+ repo,
188
+ title: step.title,
189
+ aLines: step.aLines,
190
+ version: deps.version,
191
+ now: new Date(deps.now()),
192
+ startedBy: currentSession(deps),
193
+ workspace: { id: workspace.id, step: step.id },
194
+ });
195
+ writePin(deps, run);
196
+ }
197
+ const contract = existsSync(contractFile) ? join(run.dir, "workspace-contract.md") : null;
198
+ if (contract && !existsSync(contract)) writeTextAtomic(contract, readFileSync(contractFile, "utf8"));
199
+ return run;
200
+ }
201
+
202
+ export async function workspaceStatus(deps: Deps, id: string) {
203
+ const workspace = findWorkspace(id);
204
+ const { children, unreadable } = workspaceListing(workspace);
205
+ // #43 finding 2: a corrupt child is a warning here, never a failed status
206
+ const warnings = unreadable.map(unreadableWarning);
207
+ const steps = [];
208
+ for (const step of workspace.steps) {
209
+ const child = children.find((r) => r.meta.workspace?.step === step.id);
210
+ const summary = child ? summarizeRun(deps, child) : null;
211
+ const landed = child ? landedMilestones(child) : [];
212
+ const pending = child ? ownPending(child) : false;
213
+ if (child && pending === null)
214
+ warnings.push(`${step.id}: run ${child.id} has unreadable dispatch records`);
215
+ // #43 finding 7: a milestone that differs only by case never releases the dependents
216
+ const twin = landed.find((m) => m !== step.milestone && m.toLowerCase() === step.milestone.toLowerCase());
217
+ if (twin && !landed.includes(step.milestone))
218
+ warnings.push(`${step.id} completes on ${step.milestone}, but its run landed ${twin}`);
219
+ const blockers = await dependencyBlockers(workspace, step, children, deps.now(), { merge: true });
220
+ const state: "waiting" | "ready" | "active" | "landed" = child
221
+ ? landed.includes(step.milestone) && pending === false
222
+ ? "landed"
223
+ : "active"
224
+ : blockers.length
225
+ ? "waiting"
226
+ : "ready";
227
+ steps.push({
228
+ id: step.id,
229
+ repo: step.repo,
230
+ state,
231
+ run: child?.id ?? null,
232
+ blockedBy: blockers.map((b) => b.step),
233
+ waitingFor: blockers.map((b) => b.why),
234
+ landedMilestones: landed,
235
+ landedCommits: child ? landedCommits(child) : [],
236
+ failures: summary?.totals.notOk ?? [],
237
+ questions: summary?.questions ?? [],
238
+ live: summary?.live ?? [],
239
+ childBudget: summary?.budget ?? null,
240
+ warnings: summary?.warnings ?? [],
241
+ });
242
+ }
243
+ const spend = await workspaceSpend(workspace, deps.now(), children, warnings);
244
+ return {
245
+ // spec 1.5 "Group pause": a pause over the workspace comes first
246
+ paused: pausesOver([{ meta: { workspace: { id, step: workspace.steps[0]!.id } } }]),
247
+ workspace,
248
+ dir: workspaceDirectory(id),
249
+ steps,
250
+ spend,
251
+ budget: budgetStatus(spend, workspace.budget),
252
+ warnings,
253
+ };
254
+ }
@@ -0,0 +1,95 @@
1
+ import { randomUUID } from "node:crypto";
2
+ import { existsSync } from "node:fs";
3
+ import { join } from "node:path";
4
+ import { CatherdError } from "../domain/errors.ts";
5
+ import { assertId } from "../domain/ids.ts";
6
+ import { type Workspace, WorkspaceSchema } from "../domain/workspace.ts";
7
+ import { dataDir } from "../infra/paths.ts";
8
+ import { ensurePrivateDir, readVersioned, writeJsonAtomic } from "../infra/store.ts";
9
+ import { listRuns, type Run } from "./run-store.ts";
10
+
11
+ export function workspaceDirectory(id: string): string {
12
+ assertId("workspace", id);
13
+ return join(dataDir(), "workspaces", id);
14
+ }
15
+
16
+ export function workspacePaths(dir: string) {
17
+ return {
18
+ meta: join(dir, "meta.json"),
19
+ admission: join(dir, "admission"),
20
+ contract: join(dir, "contract.md"),
21
+ };
22
+ }
23
+
24
+ /**
25
+ * How long a workspace-lock waiter waits (#43 finding 5): longer than the slowest holder, an admission
26
+ * whose git status snapshot may take up to 15 s, so a dispatch in another repository never fails E_IO_LOCK.
27
+ */
28
+ export const WORKSPACE_LOCK_WAIT_MS = 120_000;
29
+
30
+ export function createWorkspace(
31
+ input: Omit<Workspace, "schema" | "id" | "createdAt">,
32
+ now = new Date(),
33
+ ): Workspace {
34
+ const workspace = WorkspaceSchema.parse({
35
+ ...input,
36
+ schema: 1,
37
+ id: `workspace-${randomUUID()}`,
38
+ createdAt: now.toISOString(),
39
+ });
40
+ const dir = workspaceDirectory(workspace.id);
41
+ ensurePrivateDir(dir);
42
+ writeJsonAtomic(workspacePaths(dir).meta, workspace);
43
+ return workspace;
44
+ }
45
+
46
+ /** Rewrites the workspace's meta.json, validated; callers hold the workspace's admission lock. */
47
+ export function saveWorkspace(workspace: Workspace): Workspace {
48
+ const parsed = WorkspaceSchema.parse(workspace);
49
+ writeJsonAtomic(workspacePaths(workspaceDirectory(parsed.id)).meta, parsed);
50
+ return parsed;
51
+ }
52
+
53
+ export function findWorkspace(id: string): Workspace {
54
+ const file = workspacePaths(workspaceDirectory(id)).meta;
55
+ if (!existsSync(file)) throw new CatherdError("E_RUN_NOT_FOUND", `no workspace "${id}"`);
56
+ const workspace = readVersioned(file, WorkspaceSchema, 1);
57
+ if (workspace.id !== id) throw new CatherdError("E_RUN_CORRUPT", `workspace ${id} has mismatched identity`);
58
+ return workspace;
59
+ }
60
+
61
+ export interface WorkspaceListing {
62
+ /** the runs linked to this workspace, each with a readable meta.json */
63
+ children: Run[];
64
+ /** run folders in a member repository whose meta.json cannot be read (#43 finding 1): skipped, named */
65
+ unreadable: { id: string; dir: string; reason: string }[];
66
+ }
67
+
68
+ /**
69
+ * Linkage is in the child's atomic meta write, so a folder without a readable meta.json is never a child:
70
+ * `createRun` writes meta last, and a run being created, or left by a crash, is skipped and named, never
71
+ * fatal (#43 finding 1). Two children of one step, or a child in the wrong repository, still throw.
72
+ */
73
+ export function workspaceListing(workspace: Workspace): WorkspaceListing {
74
+ const listing = listRuns(Object.values(workspace.repos));
75
+ const children = listing.runs.filter((r) => r.meta.workspace?.id === workspace.id);
76
+ const seen = new Set<string>();
77
+ for (const run of children) {
78
+ const step = workspace.steps.find((s) => s.id === run.meta.workspace?.step);
79
+ if (!step || workspace.repos[step.repo] !== run.meta.repo || seen.has(step.id))
80
+ throw new CatherdError(
81
+ "E_RUN_CORRUPT",
82
+ `workspace ${workspace.id} has duplicate or mismatched child ${run.id}`,
83
+ { fix: `fix or delete ${run.dir}` },
84
+ );
85
+ seen.add(step.id);
86
+ }
87
+ return { children, unreadable: listing.corrupt };
88
+ }
89
+
90
+ /** The workspace's children (see workspaceListing); folders without a readable meta.json are skipped. */
91
+ export const workspaceChildren = (workspace: Workspace): Run[] => workspaceListing(workspace).children;
92
+
93
+ /** What `workspace_status` says about a skipped folder: its path and why. */
94
+ export const unreadableWarning = (u: { dir: string; reason: string }): string =>
95
+ `skipped run folder ${u.dir}: ${u.reason}`;