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.
- package/README.md +144 -42
- package/catalog/ATTRIBUTION.md +12 -4
- package/catalog/models.json +8 -0
- package/package.json +1 -1
- package/plugin/.claude-plugin/plugin.json +1 -1
- package/plugin/.codex-plugin/plugin.json +11 -0
- package/plugin/.mcp-codex.json +10 -0
- package/plugin/bin/catherd-mcp +1 -1
- package/plugin/skills/catherd/SKILL.md +150 -79
- package/plugin/skills/catherd-setup/SKILL.md +24 -8
- package/src/adapters/access.ts +7 -1
- package/src/adapters/antigravity/index.ts +18 -3
- package/src/adapters/backend.ts +18 -2
- package/src/adapters/claude-code/index.ts +126 -14
- package/src/adapters/cli.ts +1 -64
- package/src/adapters/codex/events.ts +10 -0
- package/src/adapters/codex/index.ts +17 -5
- package/src/adapters/cursor/index.ts +7 -1
- package/src/adapters/grok/home.ts +75 -20
- package/src/adapters/grok/index.ts +27 -8
- package/src/adapters/opencode/index.ts +38 -1
- package/src/cli.ts +53 -19
- package/src/domain/agents.ts +3 -1
- package/src/domain/brief.ts +91 -0
- package/src/domain/calibration.ts +7 -0
- package/src/domain/catalog.ts +12 -1
- package/src/domain/changes.ts +23 -1
- package/src/domain/cost.ts +8 -3
- package/src/domain/errors.ts +4 -0
- package/src/domain/failover.ts +17 -5
- package/src/domain/gate-brief.ts +52 -0
- package/src/domain/hints.ts +13 -2
- package/src/domain/host.ts +17 -0
- package/src/domain/lane.ts +99 -3
- package/src/domain/notice.ts +14 -4
- package/src/domain/profile-rules.ts +129 -16
- package/src/domain/profile.ts +56 -5
- package/src/domain/record.ts +17 -2
- package/src/domain/reply.ts +29 -0
- package/src/domain/role-prompts.ts +56 -16
- package/src/domain/role-scope.ts +106 -0
- package/src/domain/role-tools.ts +34 -0
- package/src/domain/route.ts +73 -0
- package/src/domain/select.ts +177 -25
- package/src/domain/state.ts +5 -0
- package/src/domain/util.ts +22 -0
- package/src/domain/workspace.ts +130 -0
- package/src/entry/catalog-command.ts +18 -8
- package/src/entry/cli-kit.ts +17 -1
- package/src/entry/deps.ts +8 -3
- package/src/entry/doctor-command.ts +29 -2
- package/src/entry/host-arg.ts +56 -0
- package/src/entry/init-command.ts +20 -8
- package/src/entry/knowledge-command.ts +78 -3
- package/src/entry/lock-command.ts +111 -10
- package/src/entry/mcp/dispatch-tools.ts +3 -3
- package/src/entry/mcp/handshake.ts +8 -2
- package/src/entry/mcp/lane-tools.ts +55 -8
- package/src/entry/mcp/protocol-tools.ts +10 -4
- package/src/entry/mcp/push-tools.ts +16 -0
- package/src/entry/mcp/role-bin.ts +20 -0
- package/src/entry/mcp/role-server.ts +116 -0
- package/src/entry/mcp/run-tools.ts +25 -6
- package/src/entry/mcp/server.ts +224 -44
- package/src/entry/mcp/setup-tools.ts +11 -5
- package/src/entry/mcp/workspace-tools.ts +142 -0
- package/src/entry/pause-command.ts +67 -0
- package/src/entry/profile-command.ts +131 -16
- package/src/entry/role-cli-command.ts +115 -0
- package/src/entry/runs-command.ts +157 -11
- package/src/entry/supervise-command.ts +30 -3
- package/src/entry/tui/effects.ts +23 -8
- package/src/entry/tui/fixtures.ts +33 -10
- package/src/entry/tui/run.tsx +10 -4
- package/src/entry/tui/state.ts +56 -16
- package/src/entry/tui/stories.ts +11 -4
- package/src/entry/tui/views/profile-actions.ts +4 -2
- package/src/entry/tui/views/profiles.tsx +3 -3
- package/src/entry/tui/views/runs.tsx +66 -20
- package/src/entry/tui/views/save-dialog.tsx +8 -4
- package/src/entry/tui/views/status.tsx +6 -2
- package/src/entry/workspace-command.ts +44 -0
- package/src/infra/boot-lock.ts +25 -0
- package/src/infra/cli.ts +65 -0
- package/src/infra/codex-queue.ts +165 -0
- package/src/infra/delivery.ts +86 -0
- package/src/infra/dispatch-dir.ts +3 -0
- package/src/infra/env.ts +15 -2
- package/src/infra/git.ts +6 -0
- package/src/infra/heavy-lock.ts +93 -4
- package/src/infra/host-context.ts +58 -0
- package/src/infra/host-probe.ts +16 -0
- package/src/infra/lock-activity.ts +88 -0
- package/src/infra/login-env.ts +48 -0
- package/src/infra/paths.ts +35 -0
- package/src/infra/peer-inbox.ts +4 -1
- package/src/infra/role-mcp.ts +69 -0
- package/src/infra/sources/arena.ts +11 -3
- package/src/infra/sources/artificial-analysis.ts +8 -1
- package/src/infra/sources/cache.ts +2 -0
- package/src/infra/supervisor.ts +110 -18
- package/src/services/admission.ts +117 -26
- package/src/services/agent-links.ts +30 -5
- package/src/services/backends.ts +7 -0
- package/src/services/budget.ts +26 -6
- package/src/services/catalog-service.ts +20 -12
- package/src/services/credentials.ts +11 -6
- package/src/services/dispatch-service.ts +220 -22
- package/src/services/dispatches.ts +36 -3
- package/src/services/doctor-access.ts +8 -3
- package/src/services/doctor-backends.ts +30 -7
- package/src/services/doctor-checks.ts +20 -5
- package/src/services/doctor-docker.ts +221 -0
- package/src/services/doctor-push.ts +69 -90
- package/src/services/doctor-role-mcp.ts +65 -0
- package/src/services/doctor.ts +172 -38
- package/src/services/end-push.ts +157 -0
- package/src/services/finalize.ts +13 -3
- package/src/services/gate-env.ts +218 -0
- package/src/services/gate-service.ts +216 -45
- package/src/services/global-install.ts +46 -13
- package/src/services/lane-edit.ts +154 -0
- package/src/services/lane-service.ts +258 -52
- package/src/services/milestones.ts +137 -26
- package/src/services/notifier.ts +377 -72
- package/src/services/orchestrator-wait.ts +82 -0
- package/src/services/pause.ts +161 -0
- package/src/services/peek.ts +122 -23
- package/src/services/ports.ts +39 -4
- package/src/services/preflight.ts +153 -19
- package/src/services/profile-service.ts +117 -30
- package/src/services/profile-store.ts +31 -7
- package/src/services/protocol.ts +119 -25
- package/src/services/provenance.ts +16 -12
- package/src/services/questions.ts +15 -0
- package/src/services/reconcile.ts +22 -1
- package/src/services/reentry.ts +7 -6
- package/src/services/role-access.ts +27 -0
- package/src/services/routing-service.ts +150 -37
- package/src/services/run-debug.ts +98 -4
- package/src/services/run-pin.ts +125 -0
- package/src/services/run-service.ts +153 -19
- package/src/services/run-store.ts +119 -19
- package/src/services/runs-page.ts +45 -9
- package/src/services/scratch.ts +60 -0
- package/src/services/session-view.ts +18 -13
- package/src/services/sessions.ts +34 -16
- package/src/services/setup.ts +31 -10
- package/src/services/source-derive.ts +28 -5
- package/src/services/source-sync.ts +9 -2
- package/src/services/standins.ts +40 -6
- package/src/services/state.ts +21 -0
- package/src/services/summary.ts +86 -11
- package/src/services/treat-likes.ts +41 -12
- package/src/services/verifier-brief.ts +23 -0
- package/src/services/verifier-step.ts +48 -0
- package/src/services/workspace-admission.ts +267 -0
- package/src/services/workspace-service.ts +254 -0
- 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
|
|
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:
|
|
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: {
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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: {
|
|
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(
|
|
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
|
-
|
|
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
|
+
});
|