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
@@ -0,0 +1,142 @@
1
+ import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
2
+ import { z } from "zod";
3
+ import {
4
+ BaseRefSchema,
5
+ RELEASES,
6
+ WORKSPACE_LIMIT,
7
+ WorkspaceBudgetSchema,
8
+ WorkspaceIdSchema,
9
+ } from "../../domain/workspace.ts";
10
+ import { pauseWorkspace, resumeWorkspace } from "../../services/pause.ts";
11
+ import type { Deps } from "../../services/ports.ts";
12
+ import { setWorkspaceBudget, workspaceContract } from "../../services/workspace-admission.ts";
13
+ import {
14
+ inspectWorkspace,
15
+ startWorkspace,
16
+ startWorkspaceChild,
17
+ workspaceStatus,
18
+ } from "../../services/workspace-service.ts";
19
+ import { handle } from "./result.ts";
20
+
21
+ const id = WorkspaceIdSchema;
22
+ const repos = z
23
+ .record(id, z.string().min(1))
24
+ .refine((r) => Object.keys(r).length <= WORKSPACE_LIMIT)
25
+ .optional();
26
+ const aLines = z.array(z.string().min(1)).min(1).max(100);
27
+
28
+ export function registerWorkspaceTools(server: McpServer, deps: Deps): void {
29
+ server.registerTool(
30
+ "workspace_inspect",
31
+ {
32
+ description:
33
+ "Resolve explicit workspace members from catherd.workspace.json or a supplied repo map. The root may be a plain directory. Reads only; never scans unrelated repositories.",
34
+ inputSchema: { root: z.string().min(1), repos },
35
+ },
36
+ (a) => handle(() => inspectWorkspace(a.root, a.repos)),
37
+ );
38
+ server.registerTool(
39
+ "workspace_start",
40
+ {
41
+ description:
42
+ "Snapshot a workspace's selected repositories, step dependency graph and optional shared budget (no cap unless given; raise it later with workspace_budget). A step releases its dependents when its milestone lands, or with release: 'merge' and base (e.g. origin/main) once its landed commit is an ancestor of that ref, checked when a dependent asks. Returns workspace.id and dir. Child single-repo runs are created later with workspace_child_start; no commits or pushes are performed.",
43
+ inputSchema: {
44
+ root: z.string().min(1),
45
+ title: z.string().min(1),
46
+ a_lines: aLines,
47
+ repos,
48
+ steps: z
49
+ .array(
50
+ // #43 finding 6: an unknown key (dependsOn for depends_on) is refused, never dropped
51
+ z.strictObject({
52
+ id,
53
+ repo: id,
54
+ title: z.string().min(1),
55
+ a_lines: aLines,
56
+ depends_on: z.array(id).max(WORKSPACE_LIMIT).optional(),
57
+ milestone: id.optional(),
58
+ release: z.enum(RELEASES).optional(),
59
+ base: BaseRefSchema.optional(),
60
+ }),
61
+ )
62
+ .min(1)
63
+ .max(WORKSPACE_LIMIT),
64
+ budget: WorkspaceBudgetSchema.optional(),
65
+ },
66
+ },
67
+ (a) =>
68
+ handle(() =>
69
+ startWorkspace(deps, {
70
+ root: a.root,
71
+ title: a.title,
72
+ aLines: a.a_lines,
73
+ repos: a.repos,
74
+ budget: a.budget,
75
+ steps: a.steps.map((s) => ({
76
+ id: s.id,
77
+ repo: s.repo,
78
+ title: s.title,
79
+ aLines: s.a_lines,
80
+ dependsOn: s.depends_on,
81
+ milestone: s.milestone,
82
+ release: s.release,
83
+ base: s.base,
84
+ })),
85
+ }),
86
+ ),
87
+ );
88
+ server.registerTool(
89
+ "workspace_contract",
90
+ {
91
+ description:
92
+ "Read or write the workspace's shared contract before any child run exists. Once a child is created the contract is frozen and copied into each child as workspace-contract.md.",
93
+ inputSchema: { workspace: z.string().min(1), content: z.string().optional() },
94
+ },
95
+ (a) => handle(() => workspaceContract(a)),
96
+ );
97
+ server.registerTool(
98
+ "workspace_child_start",
99
+ {
100
+ description:
101
+ "Create or return a step's single-repo run once its dependencies have landed their declared milestone, every dispatch of theirs is collected, and each 'merge' release's commit is in its base ref (fetch first; nothing is polled). Returns run, dir and the frozen contract path. Use the existing route, dispatch, verification and land tools on this child run.",
102
+ inputSchema: { workspace: z.string().min(1), step: id },
103
+ },
104
+ (a) => handle(() => startWorkspaceChild(deps, a)),
105
+ );
106
+ server.registerTool(
107
+ "workspace_pause",
108
+ {
109
+ description:
110
+ "Pause every child of a workspace on one blocker (a VPN, Docker down): admission refuses each dispatch and child start with E_ADMIT_PAUSED and the reason until workspace_resume. Running roles finish. status shows the pause first. Push the reason to the owner once.",
111
+ inputSchema: { workspace: z.string().min(1), reason: z.string().min(1) },
112
+ },
113
+ (a) => handle(() => pauseWorkspace(deps.now(), a)),
114
+ );
115
+ server.registerTool(
116
+ "workspace_resume",
117
+ {
118
+ description: "Lift a workspace's pause. Returns the pause it lifted, or null when none was in force.",
119
+ inputSchema: { workspace: z.string().min(1) },
120
+ },
121
+ (a) => handle(() => resumeWorkspace(deps.now(), a)),
122
+ );
123
+ const cap = z.number().finite().nonnegative().nullable().optional();
124
+ server.registerTool(
125
+ "workspace_budget",
126
+ {
127
+ description:
128
+ "Set or raise a workspace's shared budget: each of minutes, tokens and usd given as a number replaces that cap, null removes it, and one left out stays. A workspace has no cap unless one is set; its minutes count from its first child. Returns the budget, the spend and its status.",
129
+ inputSchema: { workspace: z.string().min(1), minutes: cap, tokens: cap, usd: cap },
130
+ },
131
+ (a) => handle(() => setWorkspaceBudget(deps, a)),
132
+ );
133
+ server.registerTool(
134
+ "workspace_status",
135
+ {
136
+ description:
137
+ "Read a workspace's step readiness, child run ids, landed milestones, aggregate spending and shared budget. Reads only; never starts children automatically.",
138
+ inputSchema: { workspace: z.string().min(1) },
139
+ },
140
+ (a) => handle(() => workspaceStatus(deps, a.workspace)),
141
+ );
142
+ }
@@ -0,0 +1,67 @@
1
+ import { defineCommand } from "citty";
2
+ import { CatherdError } from "../domain/errors.ts";
3
+ import {
4
+ pauseLine,
5
+ pauseMachine,
6
+ pauseWorkspace,
7
+ resumeMachine,
8
+ resumeWorkspace,
9
+ } from "../services/pause.ts";
10
+ import { JSON_ARG, printJson, refuseInRole } from "./cli-kit.ts";
11
+
12
+ /** Exactly one of --machine and --workspace: what a pause or resume covers. */
13
+ function scope(args: { machine?: boolean; workspace?: string }, usage: string): "machine" | string {
14
+ if (args.machine === true && args.workspace === undefined) return "machine";
15
+ if (args.machine !== true && args.workspace) return args.workspace;
16
+ throw new CatherdError(
17
+ "E_INPUT_INVALID",
18
+ "name what to pause: --machine or --workspace <id>, one of them",
19
+ {
20
+ fix: usage,
21
+ },
22
+ );
23
+ }
24
+
25
+ const SCOPE_ARGS = {
26
+ machine: { type: "boolean", description: "every run on this machine" },
27
+ workspace: { type: "string", description: "every child of this workspace" },
28
+ } as const;
29
+
30
+ const PAUSE_USAGE = 'catherd pause --machine "the VPN takes the default route"';
31
+ const RESUME_USAGE = "catherd resume --machine";
32
+
33
+ /** Spec 1.5 "Group pause": one blocker, one pause, instead of a park per run and milestone. */
34
+ export const pauseCommand = defineCommand({
35
+ meta: {
36
+ name: "pause",
37
+ description:
38
+ "Pause every run on this machine (--machine) or in a workspace: dispatches are refused until resume",
39
+ },
40
+ args: {
41
+ reason: { type: "positional", required: true, description: "why: what blocks the runs" },
42
+ ...SCOPE_ARGS,
43
+ ...JSON_ARG,
44
+ },
45
+ run({ args }) {
46
+ refuseInRole(process.env, "catherd pause");
47
+ const at = scope(args, PAUSE_USAGE);
48
+ const r =
49
+ at === "machine"
50
+ ? pauseMachine(Date.now(), args.reason)
51
+ : pauseWorkspace(Date.now(), { workspace: at, reason: args.reason });
52
+ if (args.json) return printJson(r);
53
+ console.log(pauseLine(r.pause));
54
+ },
55
+ });
56
+
57
+ export const resumeCommand = defineCommand({
58
+ meta: { name: "resume", description: "Lift a pause on this machine (--machine) or on a workspace" },
59
+ args: { ...SCOPE_ARGS, ...JSON_ARG },
60
+ run({ args }) {
61
+ refuseInRole(process.env, "catherd resume");
62
+ const at = scope(args, RESUME_USAGE);
63
+ const r = at === "machine" ? resumeMachine(Date.now()) : resumeWorkspace(Date.now(), { workspace: at });
64
+ if (args.json) return printJson(r);
65
+ console.log(r.resumed ? `resumed: ${pauseLine(r.resumed)}` : "nothing was paused");
66
+ },
67
+ });
@@ -1,9 +1,12 @@
1
+ import { readFileSync } from "node:fs";
2
+ import { HOST_ARG, terminalHost } from "./host-arg.ts";
1
3
  import { assertProfileName } from "../domain/profile.ts";
2
4
  import { defineCommand } from "citty";
3
5
  import type { Catalog } from "../domain/catalog.ts";
4
6
  import { rungInfo } from "../domain/catalog.ts";
5
7
  import { CatherdError } from "../domain/errors.ts";
6
- import { type Change, patchAt, type Profile } from "../domain/profile.ts";
8
+ import type { OrchestrationHost } from "../domain/host.ts";
9
+ import { ProfileDocSchema, type Change, patchAt, type Profile } from "../domain/profile.ts";
7
10
  import { inferredScores, type Issue } from "../domain/profile-rules.ts";
8
11
  import { ROLES, type Role } from "../domain/roles.ts";
9
12
  import { gitToplevel } from "../infra/git.ts";
@@ -15,6 +18,7 @@ import {
15
18
  deleteProfile,
16
19
  diffNamed,
17
20
  patchProfile,
21
+ resetHostDefaults,
18
22
  unbind,
19
23
  } from "../services/profile-service.ts";
20
24
  import {
@@ -24,12 +28,13 @@ import {
24
28
  listProfiles,
25
29
  profileExists,
26
30
  readProjects,
31
+ readProfileDoc,
27
32
  requireProfile,
28
33
  roleEnforcement,
29
34
  runnableBackends,
30
35
  validateNamed,
31
36
  } from "../services/profile-store.ts";
32
- import { EXIT, JSON_ARG, mark, printJson } from "./cli-kit.ts";
37
+ import { EXIT, JSON_ARG, mark, printJson, refuseInRole } from "./cli-kit.ts";
33
38
 
34
39
  const json = JSON_ARG;
35
40
 
@@ -77,6 +82,10 @@ export function formatProfile(
77
82
  `objective ${p.objective} · jev ${p.jev.use} · heavy slots ${p.lock.heavy} · notify ${p.notify.join(", ") || "none"}`,
78
83
  "roles",
79
84
  ];
85
+ const accessOf = (role: Role) =>
86
+ `${p.roles[role].access}${p.roles[role].network === false ? " (no network)" : ""}, ${o.enforcement[role]}`;
87
+ // the ladders start in one column, however long the longest access text ("(no network)" included)
88
+ const accessWidth = Math.max(27, ...ROLES.filter((r) => p.roles[r].enabled).map((r) => accessOf(r).length));
80
89
  for (const role of ROLES) {
81
90
  const rc = p.roles[role];
82
91
  if (!rc.enabled) {
@@ -84,9 +93,7 @@ export function formatProfile(
84
93
  continue;
85
94
  }
86
95
  const ladder = rc.rungs.map((r) => (r === rc.defaultRung ? `${r} (default)` : r)).join(" → ");
87
- lines.push(
88
- ` ${role.padEnd(width)} ${`${rc.access}${rc.network === false ? " (no network)" : ""}, ${o.enforcement[role]}`.padEnd(27)} ${ladder || "no rungs"}`,
89
- );
96
+ lines.push(` ${role.padEnd(width)} ${accessOf(role).padEnd(accessWidth)} ${ladder || "no rungs"}`);
90
97
  }
91
98
  // only what catherd can run today: a backend without an adapter has nothing to bill or isolate
92
99
  const runs = ([key]: [string, unknown]) => keyRunnable(key, o.backends);
@@ -99,6 +106,16 @@ export function formatProfile(
99
106
  const budget = Object.entries(p.budget).map(([k, v]) => (k === "usd" ? `$${v}` : `${v} ${k}`));
100
107
  lines.push(`budget ${budget.join(" · ") || "no cap"}`);
101
108
  lines.push(`timeouts idle ${p.timeouts.idleMin} min · wall ${p.timeouts.wallMin} min`);
109
+ // plan 23: a role's own timeouts, under the profile's
110
+ for (const role of ROLES) {
111
+ const t = p.roles[role].timeouts;
112
+ if (!t) continue;
113
+ const parts = [
114
+ ...(t.idleMin !== undefined ? [`idle ${t.idleMin} min`] : []),
115
+ ...(t.wallMin !== undefined ? [`wall ${t.wallMin} min`] : []),
116
+ ];
117
+ lines.push(` ${role} ${parts.join(" · ")}`);
118
+ }
102
119
  lines.push(
103
120
  `preflight ${p.preflight.confirm ? "shows its commands and asks first" : "runs the checks without asking"}`,
104
121
  );
@@ -139,7 +156,10 @@ const list = defineCommand({
139
156
  name: "list",
140
157
  description: "Every profile, the one this repo runs on (*), and the repos bound to each",
141
158
  },
142
- args: json,
159
+ args: {
160
+ ...HOST_ARG,
161
+ ...json,
162
+ },
143
163
  async run({ args }) {
144
164
  const active = await activeHere();
145
165
  const bindings = Object.entries(readProjects().bindings);
@@ -161,11 +181,20 @@ const show = defineCommand({
161
181
  name: "show",
162
182
  description: "A profile with every default filled in (without a name: the one this repo runs on)",
163
183
  },
164
- args: { name: { type: "positional", required: false, description: "profile name" }, ...json },
184
+ args: {
185
+ ...HOST_ARG,
186
+ name: { type: "positional", required: false, description: "profile name" },
187
+ raw: { type: "boolean", description: "stored document without effective defaults" },
188
+ ...json,
189
+ },
165
190
  async run({ args }) {
166
191
  const here = await activeHere();
167
192
  const name = args.name === undefined ? here : requireProfile(args.name);
168
- const p = getProfile(name);
193
+ if (args.raw)
194
+ return args.json
195
+ ? printJson(readProfileDoc(name))
196
+ : console.log(JSON.stringify(readProfileDoc(name), null, 2));
197
+ const p = getProfile(name, terminalHost(args.host).host);
169
198
  const o = {
170
199
  active: name === here,
171
200
  enforcement: roleEnforcement(p),
@@ -183,17 +212,19 @@ const use = defineCommand({
183
212
  "Make a profile active, bind it to this repo with --repo, or unbind this repo with --repo --clear",
184
213
  },
185
214
  args: {
215
+ ...HOST_ARG,
186
216
  name: { type: "positional", required: false, description: "profile name" },
187
217
  repo: { type: "boolean", description: "bind it to the git repo you are in, instead of making it active" },
188
218
  clear: { type: "boolean", description: "with --repo and no name: unbind the git repo you are in" },
189
219
  },
190
220
  async run({ args }) {
221
+ refuseInRole(process.env, "catherd profile use");
191
222
  if (args.clear) {
192
223
  if (!args.repo || args.name !== undefined)
193
224
  throw new CatherdError("E_INPUT_INVALID", "--clear goes with --repo and no profile name", {
194
225
  fix: "catherd profile use --repo --clear, inside the repo to unbind",
195
226
  });
196
- const r = unbind(await repoHere());
227
+ const r = unbind(await repoHere(), terminalHost(args.host).host);
197
228
  console.log(`${mark("ok")} ${r.repo} is unbound`);
198
229
  printSynced(r);
199
230
  return;
@@ -202,7 +233,7 @@ const use = defineCommand({
202
233
  throw new CatherdError("E_INPUT_INVALID", "which profile? name one", {
203
234
  fix: "catherd profile use <name> [--repo], or catherd profile use --repo --clear",
204
235
  });
205
- const r = activate(args.name, args.repo ? await repoHere() : null);
236
+ const r = activate(args.name, args.repo ? await repoHere() : null, terminalHost(args.host).host);
206
237
  console.log(`${mark("ok")} ${r.active} ${r.repo ? `is bound to ${r.repo}` : "is active"}`);
207
238
  printSynced(r);
208
239
  },
@@ -211,11 +242,13 @@ const use = defineCommand({
211
242
  const create = defineCommand({
212
243
  meta: { name: "new", description: "A new profile from the default one, or from --from <profile>" },
213
244
  args: {
245
+ ...HOST_ARG,
214
246
  name: { type: "positional", required: true, description: "new profile name" },
215
247
  from: { type: "string", description: "copy this profile instead of the default one" },
216
248
  },
217
249
  run({ args }) {
218
- const r = createProfile(args.name, args.from);
250
+ refuseInRole(process.env, "catherd profile new");
251
+ const r = createProfile(args.name, args.from, terminalHost(args.host).host);
219
252
  if (!r.saved) throw refused(r.errors);
220
253
  console.log(`${mark("ok")} created ${args.name}${args.from ? ` from ${args.from}` : ""}`);
221
254
  printIssues([], r.warnings);
@@ -225,11 +258,13 @@ const create = defineCommand({
225
258
  const copy = defineCommand({
226
259
  meta: { name: "copy", description: "Copy a profile under a new name" },
227
260
  args: {
261
+ ...HOST_ARG,
228
262
  from: { type: "positional", required: true, description: "profile to copy" },
229
263
  to: { type: "positional", required: true, description: "new profile name" },
230
264
  },
231
265
  run({ args }) {
232
- const r = createProfile(args.to, args.from);
266
+ refuseInRole(process.env, "catherd profile copy");
267
+ const r = createProfile(args.to, args.from, terminalHost(args.host).host);
233
268
  if (!r.saved) throw refused(r.errors);
234
269
  console.log(`${mark("ok")} copied ${args.from} to ${args.to}`);
235
270
  printIssues([], r.warnings);
@@ -238,8 +273,12 @@ const copy = defineCommand({
238
273
 
239
274
  const rm = defineCommand({
240
275
  meta: { name: "rm", description: "Delete a profile and its agent files (never the active or a bound one)" },
241
- args: { name: { type: "positional", required: true, description: "profile name" } },
276
+ args: {
277
+ ...HOST_ARG,
278
+ name: { type: "positional", required: true, description: "profile name" },
279
+ },
242
280
  run({ args }) {
281
+ refuseInRole(process.env, "catherd profile rm");
243
282
  deleteProfile(args.name);
244
283
  console.log(`${mark("ok")} deleted ${args.name}`);
245
284
  },
@@ -252,17 +291,21 @@ const set = defineCommand({
252
291
  "Set one field: e.g. roles.worker.access read-only, budget.usd 20, failover.<rung> <rung>, roles.worker.rungs a,b; null removes",
253
292
  },
254
293
  args: {
294
+ ...HOST_ARG,
255
295
  path: { type: "positional", required: true, description: "dotted path, e.g. roles.verifier.access" },
256
296
  value: { type: "positional", required: true, description: "JSON, or a plain word; null removes the key" },
257
297
  profile: { type: "string", description: "the profile to change (default: the one this repo runs on)" },
258
298
  },
259
299
  async run({ args }) {
300
+ refuseInRole(process.env, "catherd profile set");
260
301
  // the CLI never creates a profile by setting a field of it: a typo would start a new one
261
302
  if (args.profile !== undefined && !profileExists(assertProfileName(args.profile)))
262
303
  throw new CatherdError("E_INPUT_INVALID", `no profile named "${args.profile}"`, {
263
304
  fix: `catherd profile new ${args.profile}`,
264
305
  });
265
- const r = patchProfile(args.profile ?? (await activeHere()), patchAt(args.path, args.value));
306
+ const r = patchProfile(args.profile ?? (await activeHere()), patchAt(args.path, args.value), {
307
+ host: terminalHost(args.host).host,
308
+ });
266
309
  if (!r.saved) throw refused(r.errors);
267
310
  if (r.diff.length === 0) console.log("no change");
268
311
  for (const c of r.diff) console.log(`${mark("ok")} ${formatChange(c)}`);
@@ -282,6 +325,7 @@ const diff = defineCommand({
282
325
  description: "What differs between two profiles (the second defaults to the one this repo runs on)",
283
326
  },
284
327
  args: {
328
+ ...HOST_ARG,
285
329
  a: { type: "positional", required: true, description: "profile" },
286
330
  b: { type: "positional", required: false, description: "profile (default: the one this repo runs on)" },
287
331
  ...json,
@@ -290,6 +334,7 @@ const diff = defineCommand({
290
334
  const changes = diffNamed(
291
335
  args.b === undefined ? await activeHere() : requireProfile(args.b),
292
336
  requireProfile(args.a),
337
+ terminalHost(args.host).host,
293
338
  );
294
339
  if (args.json) return printJson(changes);
295
340
  if (changes.length === 0) console.log("no differences");
@@ -300,6 +345,7 @@ const diff = defineCommand({
300
345
  const validate = defineCommand({
301
346
  meta: { name: "validate", description: "Errors that block a save, and warnings that do not" },
302
347
  args: {
348
+ ...HOST_ARG,
303
349
  name: {
304
350
  type: "positional",
305
351
  required: false,
@@ -309,7 +355,12 @@ const validate = defineCommand({
309
355
  },
310
356
  async run({ args }) {
311
357
  const repo = await gitToplevel(process.cwd());
312
- const v = validateNamed(args.name === undefined ? activeName(repo) : requireProfile(args.name), repo);
358
+ const v = validateNamed(
359
+ args.name === undefined ? activeName(repo) : requireProfile(args.name),
360
+ repo,
361
+ terminalHost(args.host).host,
362
+ { reach: true },
363
+ );
313
364
  if (args.json) printJson({ valid: v.errors.length === 0, ...v });
314
365
  else if (v.errors.length === 0 && v.warnings.length === 0) console.log(`${mark("ok")} valid`);
315
366
  else printIssues(v.errors, v.warnings);
@@ -317,8 +368,72 @@ const validate = defineCommand({
317
368
  },
318
369
  });
319
370
 
371
+ const HOSTS: readonly OrchestrationHost[] = ["claude-code", "codex", "unknown"];
372
+
373
+ const resetDefaults = defineCommand({
374
+ meta: {
375
+ name: "reset-host-defaults",
376
+ description: "Review and explicitly reset only architect/verifier host defaults",
377
+ },
378
+ args: {
379
+ ...HOST_ARG,
380
+ ...JSON_ARG,
381
+ name: { type: "positional", required: false },
382
+ preview: { type: "boolean" },
383
+ expect: { type: "string", description: "reviewed preview JSON file" },
384
+ },
385
+ async run({ args }) {
386
+ const name = args.name === undefined ? await activeHere() : requireProfile(args.name);
387
+ const host = terminalHost(args.host).host;
388
+ let r;
389
+ if (args.preview) r = resetHostDefaults(name, host, { preview: true });
390
+ else {
391
+ refuseInRole(process.env, "catherd profile reset-host-defaults");
392
+ if (!args.expect)
393
+ throw new CatherdError(
394
+ "E_INPUT_INVALID",
395
+ "saving host defaults requires --expect <reviewed-preview.json>",
396
+ { fix: "run reset-host-defaults --preview --json and review the output first" },
397
+ );
398
+ let expected, reviewed;
399
+ try {
400
+ const raw = JSON.parse(readFileSync(args.expect, "utf8")) as Record<string, unknown> | null;
401
+ expected = ProfileDocSchema.parse(raw?.expect);
402
+ const { profile, host: previewed } = raw ?? {};
403
+ if (typeof profile !== "string" || !HOSTS.includes(previewed as OrchestrationHost))
404
+ throw new Error("unbound preview");
405
+ reviewed = { profile, host: previewed as OrchestrationHost };
406
+ } catch {
407
+ throw new CatherdError("E_INPUT_INVALID", "invalid reviewed preview file", {
408
+ fix: "pass the JSON output of reset-host-defaults --preview --json",
409
+ });
410
+ }
411
+ r = resetHostDefaults(name, host, { preview: false, expect: expected, reviewed });
412
+ }
413
+ if (args.json) printJson(r);
414
+ else {
415
+ for (const c of r.diff) console.log(formatChange(c));
416
+ printIssues(r.errors, r.warnings);
417
+ printSynced(r);
418
+ }
419
+ if (r.errors.length) process.exitCode = EXIT.error;
420
+ },
421
+ });
422
+
320
423
  /** Spec §8: `catherd profile list|show|use [--repo [--clear]]|new|copy|rm|set <path> <value>|diff|validate`. */
321
424
  export const profileCommand = defineCommand({
322
425
  meta: { name: "profile", description: "Profiles: which models each role runs, and how" },
323
- subCommands: { list, show, use, new: create, copy, rm, set, diff, validate },
426
+ args: HOST_ARG,
427
+ subCommands: {
428
+ "reset-host-defaults": resetDefaults,
429
+ list,
430
+ show,
431
+ use,
432
+ new: create,
433
+ copy,
434
+ rm,
435
+ set,
436
+ diff,
437
+ validate,
438
+ },
324
439
  });
@@ -0,0 +1,115 @@
1
+ import { defineCommand } from "citty";
2
+ import { CatherdError } from "../domain/errors.ts";
3
+ import { ID_PATTERN } from "../domain/ids.ts";
4
+ import { roleScopeFromEnv } from "../domain/role-scope.ts";
5
+ import { gateCheckOrList, gatePass } from "../services/gate-service.ts";
6
+ import { assertRoleMay } from "../services/role-access.ts";
7
+ import { readRunFile, writeRunFile } from "../services/run-service.ts";
8
+ import { printJson } from "./cli-kit.ts";
9
+ import { defaultDeps } from "./deps.ts";
10
+
11
+ /**
12
+ * Spec 1.5 plan 21: the role server's operations as commands any role can run from its shell, on a backend
13
+ * whose harness has no role server (opencode, Cursor, Grok Build, agy) or when no MCP tool is listed. In a role
14
+ * (CATHERD_ROLE, or its scratch TMPDIR) they are bound to its own run and its role's operations.
15
+ */
16
+ const scope = () => roleScopeFromEnv(process.env);
17
+
18
+ const RUN_PATH = {
19
+ run: { type: "positional", required: true, description: "the run id" },
20
+ path: { type: "positional", required: true, description: "a path relative to the run folder" },
21
+ } as const;
22
+
23
+ const read = defineCommand({
24
+ meta: { name: "read", description: "Print a file of the run folder (read_run_file)" },
25
+ args: RUN_PATH,
26
+ run({ args }) {
27
+ assertRoleMay(scope(), args.run, "read_run_file");
28
+ const text = readRunFile({ run: args.run, path: args.path });
29
+ process.stdout.write(text.endsWith("\n") ? text : `${text}\n`);
30
+ },
31
+ });
32
+
33
+ const write = defineCommand({
34
+ meta: { name: "write", description: "Write stdin to a file of the run folder (write_run_file)" },
35
+ args: RUN_PATH,
36
+ async run({ args }) {
37
+ assertRoleMay(scope(), args.run, "write_run_file");
38
+ const content = await Bun.stdin.text();
39
+ printJson(writeRunFile({ run: args.run, path: args.path, content }));
40
+ },
41
+ });
42
+
43
+ export const runFileCommand = defineCommand({
44
+ meta: { name: "run-file", description: "Read or write a run's plan, lanes, dossier and notes" },
45
+ subCommands: { read, write },
46
+ });
47
+
48
+ const GATE = {
49
+ run: { type: "positional", required: true, description: "the run id" },
50
+ item: { type: "string", required: true, description: "the gate item's name" },
51
+ command: { type: "string", required: true, description: "the command that checks it" },
52
+ paths: { type: "string", required: true, description: "the repo paths it depends on, comma-separated" },
53
+ } as const;
54
+
55
+ const pathsOf = (s: string): string[] => {
56
+ const paths = s
57
+ .split(",")
58
+ .map((p) => p.trim())
59
+ .filter(Boolean);
60
+ if (!paths.length)
61
+ throw new CatherdError("E_INPUT_INVALID", "--paths names no path", {
62
+ fix: "pass --paths src,package.json",
63
+ });
64
+ return paths;
65
+ };
66
+
67
+ const check = defineCommand({
68
+ meta: { name: "check", description: "Whether a gate item passed on unchanged content (gate_check)" },
69
+ // plan 23: with only the run and --milestone, it lists the milestone's recorded items (as gate_check does)
70
+ args: {
71
+ run: GATE.run,
72
+ item: { ...GATE.item, required: false },
73
+ command: { ...GATE.command, required: false },
74
+ paths: { ...GATE.paths, required: false },
75
+ milestone: { type: "string", description: "the milestone you verify, like M1" },
76
+ },
77
+ async run({ args }) {
78
+ assertRoleMay(scope(), args.run, "gate_check");
79
+ if (args.milestone !== undefined && !ID_PATTERN.test(args.milestone))
80
+ throw new CatherdError("E_INPUT_INVALID", `bad milestone "${args.milestone}"`, {
81
+ fix: "pass --milestone M1",
82
+ });
83
+ printJson(
84
+ await gateCheckOrList(defaultDeps(), {
85
+ run: args.run,
86
+ item: args.item,
87
+ command: args.command,
88
+ paths: args.paths === undefined ? undefined : pathsOf(args.paths),
89
+ ...(args.milestone ? { milestone: args.milestone } : {}),
90
+ }),
91
+ );
92
+ },
93
+ });
94
+
95
+ const pass = defineCommand({
96
+ meta: { name: "pass", description: "Record a passed gate item with its evidence (gate_pass)" },
97
+ args: { ...GATE, evidence: { type: "string", required: true, description: "what showed it passed" } },
98
+ async run({ args }) {
99
+ assertRoleMay(scope(), args.run, "gate_pass");
100
+ printJson(
101
+ await gatePass(defaultDeps(), {
102
+ run: args.run,
103
+ item: args.item,
104
+ command: args.command,
105
+ paths: pathsOf(args.paths),
106
+ evidence: args.evidence,
107
+ }),
108
+ );
109
+ },
110
+ });
111
+
112
+ export const gateCommand = defineCommand({
113
+ meta: { name: "gate", description: "A verifier's gate evidence: check an item, record a pass" },
114
+ subCommands: { check, pass },
115
+ });