crewhaus 0.1.4 → 0.1.6
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/dist/doctor-checks.d.ts +45 -0
- package/dist/doctor-checks.js +178 -0
- package/{src/egress-matcher.ts → dist/egress-matcher.d.ts} +17 -52
- package/dist/egress-matcher.js +57 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.js +2362 -0
- package/{src/justification-gate.ts → dist/justification-gate.d.ts} +21 -61
- package/dist/justification-gate.js +72 -0
- package/{src/scope-audit.ts → dist/scope-audit.d.ts} +3 -42
- package/dist/scope-audit.js +85 -0
- package/package.json +71 -68
- package/src/doctor-checks.test.ts +0 -122
- package/src/doctor-checks.ts +0 -220
- package/src/egress-matcher.test.ts +0 -273
- package/src/eval.test.ts +0 -130
- package/src/index.test.ts +0 -819
- package/src/index.ts +0 -2559
- package/src/justification-gate.test.ts +0 -292
- package/src/scope-audit.test.ts +0 -207
package/dist/index.js
ADDED
|
@@ -0,0 +1,2362 @@
|
|
|
1
|
+
#!/usr/bin/env bun
|
|
2
|
+
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
3
|
+
import { basename, dirname, join, relative, resolve } from "node:path";
|
|
4
|
+
import { SpecParseError, compile, lower } from "@crewhaus/compiler";
|
|
5
|
+
import { buildContextBundle, discoverRoots } from "@crewhaus/context-bundle";
|
|
6
|
+
import { CrewhausError } from "@crewhaus/errors";
|
|
7
|
+
import { loadDataset } from "@crewhaus/eval-dataset";
|
|
8
|
+
import { parseGradersConfig } from "@crewhaus/eval-grader";
|
|
9
|
+
import { optimizeSpec } from "@crewhaus/eval-optimizer-orchestrator";
|
|
10
|
+
import { diffReports, loadRun, renderReport } from "@crewhaus/eval-report";
|
|
11
|
+
import { runEval as runEvalLib } from "@crewhaus/eval-runner";
|
|
12
|
+
import { loadHooks } from "@crewhaus/hooks-engine";
|
|
13
|
+
import { ArgParseError, parseArgs, } from "@crewhaus/infra-utils";
|
|
14
|
+
import { createLogger } from "@crewhaus/logging";
|
|
15
|
+
import { McpHost } from "@crewhaus/mcp-host";
|
|
16
|
+
import { BUILTIN_DEFAULT_RULES, PermissionConfigError, parsePermissionsConfig, tagRules, } from "@crewhaus/permission-engine";
|
|
17
|
+
import { runChatLoop } from "@crewhaus/runtime-core";
|
|
18
|
+
import { createSessionStore } from "@crewhaus/session-store";
|
|
19
|
+
import { createSkillTool, discoverSkills } from "@crewhaus/skills-registry";
|
|
20
|
+
import { loadCommands } from "@crewhaus/slash-commands";
|
|
21
|
+
import { parseSpec } from "@crewhaus/spec";
|
|
22
|
+
import { spawnSubAgent } from "@crewhaus/sub-agent-spawner";
|
|
23
|
+
import { ToolCatalog } from "@crewhaus/tool-catalog";
|
|
24
|
+
import { registerMcpServer } from "@crewhaus/tool-mcp";
|
|
25
|
+
import { createTaskTool } from "@crewhaus/tool-task";
|
|
26
|
+
import { TraceEventBus } from "@crewhaus/trace-event-bus";
|
|
27
|
+
// Model-aware doctor credential checks (provider parsed from the cwd spec's
|
|
28
|
+
// agent.model via the model-router grammar), in a side-effect-free module so
|
|
29
|
+
// it is unit-testable (this entry file runs an argv switch on import).
|
|
30
|
+
import { buildCredentialChecks, extractSpecModel } from "./doctor-checks";
|
|
31
|
+
// FR-006 — Pillar 3 sink-side egress-matcher selection (the substring/semantic
|
|
32
|
+
// selector lowered to ir.security.egressMatcher). Side-effect-free + lazily
|
|
33
|
+
// imports the optional semantic package only when "semantic" is selected, so
|
|
34
|
+
// the default path pulls in no embedding dependency.
|
|
35
|
+
import { DEFAULT_EGRESS_EMBEDDER_MODEL, InvalidEgressMatcherChoiceError, createEgressMatcher, resolveEgressMatcherChoice, } from "./egress-matcher";
|
|
36
|
+
// FR-004 — Pillar 3 intent-gate judge + durable audit-sink resolution, in a
|
|
37
|
+
// side-effect-free module so it is unit-testable (this entry file runs an
|
|
38
|
+
// argv switch on import).
|
|
39
|
+
import { InvalidJudgeChoiceError, createJustificationJudge, openJustificationAuditSink, resolveJudgeChoice, } from "./justification-gate";
|
|
40
|
+
// FR-002 — Pillar 3 sink-side scope gate, shared by `compile --strict` and
|
|
41
|
+
// `doctor --philosophy-alignment`. Kept in a side-effect-free module so it is
|
|
42
|
+
// unit-testable (this entry file runs an argv switch on import).
|
|
43
|
+
import { auditSpecToolNames, auditToolScopes, collectToolNames } from "./scope-audit";
|
|
44
|
+
/**
|
|
45
|
+
* crewhaus — slice-scope CLI.
|
|
46
|
+
* Subcommands:
|
|
47
|
+
* compile <spec.yaml> -o <out-dir> parse → IR → emit bundle to disk
|
|
48
|
+
* compile <spec.yaml> --emit-ir parse → IR → print IR JSON (no codegen)
|
|
49
|
+
* run <spec.yaml> [--model <model>] compile in-memory and execute the agent
|
|
50
|
+
* init [name] scaffold a new crewhaus.yaml
|
|
51
|
+
* doctor check environment health
|
|
52
|
+
*
|
|
53
|
+
* Future (per catalog F4 spec-cli): deploy, eval, watch.
|
|
54
|
+
*
|
|
55
|
+
* User-facing messages (status, errors) go directly to stdout/stderr for clean
|
|
56
|
+
* UX. The logger is for diagnostic events visible only when CREWHAUS_LOG_LEVEL
|
|
57
|
+
* is set to debug (or CREWHAUS_LOG=json for machine-readable traces).
|
|
58
|
+
*/
|
|
59
|
+
const logger = createLogger({ bindings: { app: "crewhaus" } });
|
|
60
|
+
const COMPILE_SCHEMA = {
|
|
61
|
+
flags: [
|
|
62
|
+
{ name: "out", short: "o", takesValue: true },
|
|
63
|
+
{ name: "emit-ir", takesValue: false },
|
|
64
|
+
// FR-002 — the strict scope gate (fail the build when an outward-reaching
|
|
65
|
+
// tool is left at a non-"external" scope) now runs by DEFAULT on every
|
|
66
|
+
// compile. `--strict` is retained as an accepted no-op so existing
|
|
67
|
+
// invocations and CI scripts keep working; the gate fires with or without
|
|
68
|
+
// it (see whitepaper §6).
|
|
69
|
+
{ name: "strict", takesValue: false },
|
|
70
|
+
// FR-002 — explicit opt-out for users who knowingly bypass the gate (e.g.
|
|
71
|
+
// an outward sink whose external scope is verified out of band). Either
|
|
72
|
+
// spelling disables the gate; both are accepted so neither reach is a
|
|
73
|
+
// surprise. Without one of these flags an unmarked outward/io-capable tool
|
|
74
|
+
// FAILS the compile.
|
|
75
|
+
{ name: "allow-unmarked-sinks", takesValue: false },
|
|
76
|
+
{ name: "no-strict-scope", takesValue: false },
|
|
77
|
+
{ name: "help", short: "h" },
|
|
78
|
+
],
|
|
79
|
+
};
|
|
80
|
+
const RUN_SCHEMA = {
|
|
81
|
+
flags: [
|
|
82
|
+
{ name: "model", takesValue: true },
|
|
83
|
+
{ name: "permission-mode", takesValue: true },
|
|
84
|
+
{ name: "resume", takesValue: true },
|
|
85
|
+
{ name: "continue", takesValue: false },
|
|
86
|
+
{ name: "prompt", takesValue: true },
|
|
87
|
+
// FR-004 — select the Pillar 3 intent-gate judge for this run.
|
|
88
|
+
// rule-based (default) | claude. Overrides the spec's
|
|
89
|
+
// security.justification.judge when supplied.
|
|
90
|
+
{ name: "justification-judge", takesValue: true },
|
|
91
|
+
// FR-004 — disable the durable `permission_justification_evaluated`
|
|
92
|
+
// audit-log record (on by default for `run`). The ephemeral trace-bus
|
|
93
|
+
// event is unaffected; this only skips opening `.crewhaus/audit`.
|
|
94
|
+
{ name: "no-justification-audit", takesValue: false },
|
|
95
|
+
// FR-006 — select the Pillar 3 sink-side egress matcher for this run.
|
|
96
|
+
// substring (default) | semantic. Overrides the spec's
|
|
97
|
+
// security.egressMatcher when supplied.
|
|
98
|
+
{ name: "egress-matcher", takesValue: true },
|
|
99
|
+
// FR-006 — embedder model for the "semantic" egress matcher (the
|
|
100
|
+
// @crewhaus/embedder prefix grammar, e.g. openai/text-embedding-3-small,
|
|
101
|
+
// mock/deterministic). Flag > CREWHAUS_EGRESS_EMBEDDER env > default.
|
|
102
|
+
{ name: "egress-embedder", takesValue: true },
|
|
103
|
+
{ name: "help", short: "h" },
|
|
104
|
+
],
|
|
105
|
+
};
|
|
106
|
+
const SESSION_ID_REGEX = /^sess_[0-9a-f]{16}$/;
|
|
107
|
+
const VALID_PERMISSION_MODES = ["default", "plan", "auto", "bypass"];
|
|
108
|
+
function isValidPermissionMode(s) {
|
|
109
|
+
return VALID_PERMISSION_MODES.includes(s);
|
|
110
|
+
}
|
|
111
|
+
const INIT_SCHEMA = {
|
|
112
|
+
flags: [{ name: "help", short: "h" }],
|
|
113
|
+
};
|
|
114
|
+
const DOCTOR_SCHEMA = {
|
|
115
|
+
flags: [
|
|
116
|
+
{ name: "philosophy-alignment", takesValue: false },
|
|
117
|
+
// Process-liveness only — exit 0 fast, no credential/spec checks. The
|
|
118
|
+
// probe target for container HEALTHCHECKs and k8s exec probes.
|
|
119
|
+
{ name: "liveness", takesValue: false },
|
|
120
|
+
{ name: "help", short: "h" },
|
|
121
|
+
],
|
|
122
|
+
};
|
|
123
|
+
const CONTEXT_SCHEMA = {
|
|
124
|
+
flags: [
|
|
125
|
+
{ name: "bundle", takesValue: false },
|
|
126
|
+
{ name: "out", short: "o", takesValue: true },
|
|
127
|
+
{ name: "factory-root", takesValue: true },
|
|
128
|
+
{ name: "docs-root", takesValue: true },
|
|
129
|
+
{ name: "demos-root", takesValue: true },
|
|
130
|
+
{ name: "help", short: "h" },
|
|
131
|
+
],
|
|
132
|
+
};
|
|
133
|
+
const OPTIMIZE_SCHEMA = {
|
|
134
|
+
flags: [
|
|
135
|
+
{ name: "dataset", takesValue: true },
|
|
136
|
+
{ name: "graders", takesValue: true },
|
|
137
|
+
{ name: "mutator", takesValue: true },
|
|
138
|
+
{ name: "iterations", takesValue: true },
|
|
139
|
+
{ name: "seed", takesValue: true },
|
|
140
|
+
{ name: "improvement-threshold", takesValue: true },
|
|
141
|
+
// FR-003 — dollar ceiling for model-driven runs (composes with --iterations).
|
|
142
|
+
{ name: "budget-usd", takesValue: true },
|
|
143
|
+
{ name: "write-back", takesValue: false },
|
|
144
|
+
{ name: "out", short: "o", takesValue: true },
|
|
145
|
+
{ name: "help", short: "h" },
|
|
146
|
+
],
|
|
147
|
+
};
|
|
148
|
+
const EVAL_SCHEMA = {
|
|
149
|
+
flags: [
|
|
150
|
+
{ name: "dataset", takesValue: true },
|
|
151
|
+
{ name: "graders", takesValue: true },
|
|
152
|
+
{ name: "judge-model", takesValue: true },
|
|
153
|
+
{ name: "concurrency", takesValue: true },
|
|
154
|
+
{ name: "seed", takesValue: true },
|
|
155
|
+
{ name: "out", short: "o", takesValue: true },
|
|
156
|
+
{ name: "help", short: "h" },
|
|
157
|
+
],
|
|
158
|
+
};
|
|
159
|
+
const EVAL_REPORT_SCHEMA = {
|
|
160
|
+
flags: [
|
|
161
|
+
{ name: "out", short: "o", takesValue: true },
|
|
162
|
+
{ name: "help", short: "h" },
|
|
163
|
+
],
|
|
164
|
+
};
|
|
165
|
+
const COST_SUMMARY_SCHEMA = {
|
|
166
|
+
flags: [
|
|
167
|
+
{ name: "session", takesValue: true },
|
|
168
|
+
{ name: "tenant", takesValue: true },
|
|
169
|
+
{ name: "format", takesValue: true },
|
|
170
|
+
{ name: "help", short: "h" },
|
|
171
|
+
],
|
|
172
|
+
};
|
|
173
|
+
const SANDBOX_SCHEMA = {
|
|
174
|
+
flags: [
|
|
175
|
+
{ name: "probe", takesValue: false },
|
|
176
|
+
{ name: "format", takesValue: true },
|
|
177
|
+
{ name: "help", short: "h" },
|
|
178
|
+
],
|
|
179
|
+
};
|
|
180
|
+
const COMPLIANCE_SCHEMA = {
|
|
181
|
+
flags: [
|
|
182
|
+
{ name: "framework", takesValue: true },
|
|
183
|
+
{ name: "control", takesValue: true },
|
|
184
|
+
{ name: "period", takesValue: true },
|
|
185
|
+
{ name: "audit-dir", takesValue: true },
|
|
186
|
+
{ name: "out-dir", takesValue: true },
|
|
187
|
+
{ name: "signing-key-env", takesValue: true },
|
|
188
|
+
{ name: "help", short: "h" },
|
|
189
|
+
],
|
|
190
|
+
};
|
|
191
|
+
const SECRETS_SCHEMA = {
|
|
192
|
+
flags: [
|
|
193
|
+
{ name: "backend", takesValue: true },
|
|
194
|
+
{ name: "root-dir", takesValue: true },
|
|
195
|
+
{ name: "value", takesValue: true },
|
|
196
|
+
{ name: "help", short: "h" },
|
|
197
|
+
],
|
|
198
|
+
};
|
|
199
|
+
const SPEC_SCHEMA = {
|
|
200
|
+
flags: [
|
|
201
|
+
{ name: "root-dir", takesValue: true },
|
|
202
|
+
{ name: "tenant", takesValue: true },
|
|
203
|
+
{ name: "help", short: "h" },
|
|
204
|
+
],
|
|
205
|
+
};
|
|
206
|
+
const DEPLOY_SCHEMA = {
|
|
207
|
+
flags: [
|
|
208
|
+
{ name: "root-dir", takesValue: true },
|
|
209
|
+
{ name: "tenant", takesValue: true },
|
|
210
|
+
{ name: "actor", takesValue: true },
|
|
211
|
+
{ name: "help", short: "h" },
|
|
212
|
+
],
|
|
213
|
+
};
|
|
214
|
+
const MIGRATE_SCHEMA = {
|
|
215
|
+
flags: [
|
|
216
|
+
{ name: "root-dir", takesValue: true },
|
|
217
|
+
{ name: "from", takesValue: true },
|
|
218
|
+
{ name: "to", takesValue: true },
|
|
219
|
+
{ name: "dry-run" },
|
|
220
|
+
{ name: "help", short: "h" },
|
|
221
|
+
],
|
|
222
|
+
};
|
|
223
|
+
const BUILD_IMAGE_SCHEMA = {
|
|
224
|
+
flags: [
|
|
225
|
+
{ name: "tag", takesValue: true },
|
|
226
|
+
{ name: "platform", takesValue: true },
|
|
227
|
+
{ name: "push" },
|
|
228
|
+
{ name: "help", short: "h" },
|
|
229
|
+
],
|
|
230
|
+
};
|
|
231
|
+
const CLOUD_SCHEMA = {
|
|
232
|
+
flags: [
|
|
233
|
+
{ name: "provider", takesValue: true },
|
|
234
|
+
{ name: "region", takesValue: true },
|
|
235
|
+
{ name: "tier", takesValue: true },
|
|
236
|
+
{ name: "image-tag", takesValue: true },
|
|
237
|
+
{ name: "working-dir", takesValue: true },
|
|
238
|
+
{ name: "help", short: "h" },
|
|
239
|
+
],
|
|
240
|
+
};
|
|
241
|
+
const FEDERATION_SCHEMA = {
|
|
242
|
+
flags: [
|
|
243
|
+
{ name: "srv-domain", takesValue: true },
|
|
244
|
+
{ name: "format", takesValue: true },
|
|
245
|
+
{ name: "help", short: "h" },
|
|
246
|
+
],
|
|
247
|
+
};
|
|
248
|
+
function usageText() {
|
|
249
|
+
return [
|
|
250
|
+
"usage: crewhaus <subcommand> [args]",
|
|
251
|
+
"",
|
|
252
|
+
"subcommands:",
|
|
253
|
+
" compile <spec.yaml> -o <out-dir> compile a spec to a runnable bundle",
|
|
254
|
+
" (fails if an outward tool is left non-external — FR-002)",
|
|
255
|
+
" [--allow-unmarked-sinks] opt out of the external-sink scope gate",
|
|
256
|
+
" compile <spec.yaml> --emit-ir print the lowered IR as JSON (debug)",
|
|
257
|
+
" run <spec.yaml> [--model <model>] compile in-memory and execute the agent",
|
|
258
|
+
" [--resume <id>] resume a specific session (cli targets only)",
|
|
259
|
+
" [--continue] resume the most-recent session (cli targets only)",
|
|
260
|
+
" [--prompt <text>] initial user prompt (browser targets; defaults to stdin)",
|
|
261
|
+
" [--justification-judge rule-based|claude] Pillar 3 intent-gate judge (FR-004)",
|
|
262
|
+
" [--egress-matcher substring|semantic] Pillar 3 sink-side matcher (FR-006)",
|
|
263
|
+
" [--egress-embedder <model>] embedder for --egress-matcher semantic",
|
|
264
|
+
" eval <spec.yaml> --dataset <data> run the agent against a dataset and grade",
|
|
265
|
+
" --graders <graders.yaml> (deterministic graders + LLM-as-judge)",
|
|
266
|
+
" [--judge-model <model>] [--concurrency N] [--seed N] -o <out-dir>",
|
|
267
|
+
" eval-report diff <prev> <new> compare two eval runs and emit a diff report",
|
|
268
|
+
" [-o <out-dir>]",
|
|
269
|
+
" optimize <spec.yaml> --dataset <data> --graders <graders.yaml>",
|
|
270
|
+
" [--mutator rule-based|claude] [--iterations N] [--seed N]",
|
|
271
|
+
" [--budget-usd N] stop a model-driven run before it exceeds $N (FR-003)",
|
|
272
|
+
" [--write-back] [-o <out-dir>] active eval-driven optimization (Pillar 2)",
|
|
273
|
+
" init [name] scaffold a new crewhaus.yaml",
|
|
274
|
+
" doctor check environment health",
|
|
275
|
+
" context --bundle [-o <file>] emit a single-markdown orientation manifest",
|
|
276
|
+
" [--factory-root <p>] [--docs-root <p>] [--demos-root <p>]",
|
|
277
|
+
" cost-summary --session <id> summarize cost_accrual events for a session",
|
|
278
|
+
" secrets doctor list known secrets via the configured backend",
|
|
279
|
+
" secrets rotate <name> [--value V] rotate a named secret (file backend)",
|
|
280
|
+
" spec put|list|get|pin|alias ... versioned spec storage (Section 28 spec-registry)",
|
|
281
|
+
" deploy promote|rollback ... re-pin a spec for an environment (Section 28)",
|
|
282
|
+
" migrate-all --from N --to N batch-migrate every spec in the registry",
|
|
283
|
+
" build-image <target> --tag <tag> build the docker image for a target shape (Section 32)",
|
|
284
|
+
" [--platform <p>] [--push]",
|
|
285
|
+
" cloud deploy --provider <p> deploy a managed CrewHaus cluster (Section 32)",
|
|
286
|
+
" --region <r> [--tier <t>] [--image-tag <tag>]",
|
|
287
|
+
" cloud teardown --provider <p> tear down a managed cluster",
|
|
288
|
+
" --region <r>",
|
|
289
|
+
" federation discover <deployment> resolve a federated peer's endpoint + cert fingerprint (Section 34)",
|
|
290
|
+
" [--srv-domain <d>] [--format json|yaml]",
|
|
291
|
+
" sandbox doctor [--probe] list registered sandbox images + healthcheck status (Section 36)",
|
|
292
|
+
" [--format json|table]",
|
|
293
|
+
" compliance evidence collect SOC 2 / ISO 27001 / HIPAA evidence (Section 39)",
|
|
294
|
+
" --framework <id> [--control <id>] --period <p> [--audit-dir <d>]",
|
|
295
|
+
" [--out-dir <d>] [--signing-key-env <ENV>]",
|
|
296
|
+
" version print the CLI version (also: --version, -v)",
|
|
297
|
+
"",
|
|
298
|
+
].join("\n");
|
|
299
|
+
}
|
|
300
|
+
/** No subcommand given (or a parse error) — usage to stderr, exit 1. */
|
|
301
|
+
function usage() {
|
|
302
|
+
process.stderr.write(usageText());
|
|
303
|
+
process.exit(1);
|
|
304
|
+
}
|
|
305
|
+
/**
|
|
306
|
+
* Explicit `-h`/`--help` at the top level — usage to stdout, exit 0. Help was
|
|
307
|
+
* requested, so it is not an error; this matches every subcommand's own
|
|
308
|
+
* `--help` (stdout, exit 0) and lets `crewhaus --help` work in `set -e` health
|
|
309
|
+
* checks instead of looking like a broken CLI.
|
|
310
|
+
*/
|
|
311
|
+
function help() {
|
|
312
|
+
process.stdout.write(usageText());
|
|
313
|
+
process.exit(0);
|
|
314
|
+
}
|
|
315
|
+
function die(message) {
|
|
316
|
+
process.stderr.write(`crewhaus: ${message}\n`);
|
|
317
|
+
process.exit(1);
|
|
318
|
+
}
|
|
319
|
+
function printVersion() {
|
|
320
|
+
if (typeof CREWHAUS_EMBEDDED_VERSION === "string") {
|
|
321
|
+
process.stdout.write(`${CREWHAUS_EMBEDDED_VERSION}\n`);
|
|
322
|
+
return;
|
|
323
|
+
}
|
|
324
|
+
// The package ships src/ directly (bin → src/index.ts) and tsc -b also
|
|
325
|
+
// emits dist/, so resolve package.json relative to this module — one level
|
|
326
|
+
// up lands on apps/cli/package.json from either tree, and on
|
|
327
|
+
// node_modules/@crewhaus/cli/package.json when installed.
|
|
328
|
+
let version;
|
|
329
|
+
try {
|
|
330
|
+
version = JSON.parse(readFileSync(new URL("../package.json", import.meta.url), "utf-8")).version;
|
|
331
|
+
}
|
|
332
|
+
catch {
|
|
333
|
+
die("could not locate package.json to determine the version");
|
|
334
|
+
}
|
|
335
|
+
process.stdout.write(`${version}\n`);
|
|
336
|
+
}
|
|
337
|
+
function parseFor(rest, schema) {
|
|
338
|
+
try {
|
|
339
|
+
return parseArgs(rest, schema);
|
|
340
|
+
}
|
|
341
|
+
catch (err) {
|
|
342
|
+
if (err instanceof ArgParseError)
|
|
343
|
+
die(err.message);
|
|
344
|
+
throw err;
|
|
345
|
+
}
|
|
346
|
+
}
|
|
347
|
+
async function runCompile(args) {
|
|
348
|
+
if (args.flags["help"]) {
|
|
349
|
+
process.stdout.write("usage: crewhaus compile <spec.yaml> [-o <out-dir>] [--emit-ir]\n" +
|
|
350
|
+
" [--allow-unmarked-sinks]\n" +
|
|
351
|
+
" --emit-ir Skip code emission; print the lowered IR as JSON to\n" +
|
|
352
|
+
" stdout (or to <out-dir>/ir.json when -o is set).\n" +
|
|
353
|
+
"\n" +
|
|
354
|
+
" FR-002 — the strict scope gate runs by DEFAULT: the build fails\n" +
|
|
355
|
+
" (exit 1) if any I/O-capable tool the spec uses is left at a\n" +
|
|
356
|
+
' non-"external" scope. It flags:\n' +
|
|
357
|
+
" (a) a resolvable built-in whose declared io-capability or\n" +
|
|
358
|
+
" outward name disagrees with its scope;\n" +
|
|
359
|
+
" (b) an outward-by-name sink — any mcp__* tool or known outward\n" +
|
|
360
|
+
' built-in — that cannot be resolved to a scope:"external"\n' +
|
|
361
|
+
" tool offline (its egress scope is unverifiable at compile\n" +
|
|
362
|
+
" time, so the gate refuses it).\n" +
|
|
363
|
+
" A non-outward custom tool whose name carries no signal is left to\n" +
|
|
364
|
+
" the live `doctor --philosophy-alignment` audit / runtime egress\n" +
|
|
365
|
+
" fabric.\n" +
|
|
366
|
+
"\n" +
|
|
367
|
+
" --allow-unmarked-sinks Opt out of the gate for this compile (alias:\n" +
|
|
368
|
+
" --no-strict-scope). Use only when you knowingly bypass it.\n" +
|
|
369
|
+
" --strict Accepted no-op — the gate is already on by default.\n");
|
|
370
|
+
return;
|
|
371
|
+
}
|
|
372
|
+
const specPath = args.positional[0];
|
|
373
|
+
const outDir = args.flags["out"];
|
|
374
|
+
const emitIr = args.flags["emit-ir"] === true;
|
|
375
|
+
// FR-002 — the strict scope gate is DEFAULT-ON. It runs unless the user
|
|
376
|
+
// explicitly opts out with --allow-unmarked-sinks (or its alias
|
|
377
|
+
// --no-strict-scope). `--strict` is now a no-op kept for back-compat.
|
|
378
|
+
const allowUnmarkedSinks = args.flags["allow-unmarked-sinks"] === true || args.flags["no-strict-scope"] === true;
|
|
379
|
+
const strict = !allowUnmarkedSinks;
|
|
380
|
+
if (typeof specPath !== "string")
|
|
381
|
+
die("missing <spec.yaml>");
|
|
382
|
+
if (!emitIr && typeof outDir !== "string")
|
|
383
|
+
die("missing -o <out-dir>");
|
|
384
|
+
const absSpec = resolve(specPath);
|
|
385
|
+
logger.debug("compile.start", { spec: absSpec, out: outDir, emitIr, strict, allowUnmarkedSinks });
|
|
386
|
+
let yamlText;
|
|
387
|
+
try {
|
|
388
|
+
yamlText = readFileSync(absSpec, "utf-8");
|
|
389
|
+
}
|
|
390
|
+
catch (err) {
|
|
391
|
+
die(`could not read ${absSpec}: ${err.message}`);
|
|
392
|
+
}
|
|
393
|
+
// FR-006 — `security.egressMatcher: semantic` is now emitted into the
|
|
394
|
+
// standalone cli bundle by `@crewhaus/target-cli` (it constructs
|
|
395
|
+
// `@crewhaus/egress-matcher-semantic` with an injected embedder and threads
|
|
396
|
+
// it into the bundle's `runChatLoop({ egressMatcher })`), so a compiled
|
|
397
|
+
// artifact honours the selection WITHOUT the `run` path. No compile-time
|
|
398
|
+
// warning is needed anymore — emission replaced the warn-only shim.
|
|
399
|
+
// FR-002 — strict scope gate, now DEFAULT-ON. Lower the spec (pure), resolve
|
|
400
|
+
// every referenced built-in tool name to its RegisteredTool, and audit scopes
|
|
401
|
+
// BEFORE any emission. Runs for both --emit-ir and bundle modes so the gate
|
|
402
|
+
// can't be sidestepped by mode choice. `lower()` only carries tool NAMES, not
|
|
403
|
+
// scope, so resolution via loadToolMap() is what surfaces the real
|
|
404
|
+
// RegisteredTool.scope. Skipped only when the user opts out explicitly with
|
|
405
|
+
// --allow-unmarked-sinks (strict === false).
|
|
406
|
+
if (strict) {
|
|
407
|
+
await runStrictScopeGate(yamlText);
|
|
408
|
+
}
|
|
409
|
+
if (emitIr) {
|
|
410
|
+
let ir;
|
|
411
|
+
try {
|
|
412
|
+
ir = lower(parseSpec(yamlText));
|
|
413
|
+
}
|
|
414
|
+
catch (err) {
|
|
415
|
+
// parseSpec throws SpecParseError; lower() can throw CompilerError (e.g. a
|
|
416
|
+
// malformed credential env-ref). Both extend CrewhausError — route the
|
|
417
|
+
// family through die() for a clean one-liner instead of a raw stack trace.
|
|
418
|
+
if (err instanceof CrewhausError)
|
|
419
|
+
die(err.message);
|
|
420
|
+
throw err;
|
|
421
|
+
}
|
|
422
|
+
const json = `${JSON.stringify(ir, null, 2)}\n`;
|
|
423
|
+
if (typeof outDir === "string") {
|
|
424
|
+
const absOut = resolve(outDir);
|
|
425
|
+
mkdirSync(absOut, { recursive: true });
|
|
426
|
+
const irPath = join(absOut, "ir.json");
|
|
427
|
+
writeFileSync(irPath, json);
|
|
428
|
+
process.stdout.write(`wrote ${irPath}\n`);
|
|
429
|
+
}
|
|
430
|
+
else {
|
|
431
|
+
process.stdout.write(json);
|
|
432
|
+
}
|
|
433
|
+
logger.debug("compile.emit-ir.success", { out: outDir ?? "stdout" });
|
|
434
|
+
return;
|
|
435
|
+
}
|
|
436
|
+
const absOut = resolve(outDir);
|
|
437
|
+
let bundle;
|
|
438
|
+
try {
|
|
439
|
+
bundle = compile(yamlText);
|
|
440
|
+
}
|
|
441
|
+
catch (err) {
|
|
442
|
+
// compile() runs parse → lower → emit. parseSpec throws SpecParseError;
|
|
443
|
+
// each target emitter throws its own TargetEmitError (e.g. an unresolvable
|
|
444
|
+
// tool name once the default-on scope gate has been bypassed with
|
|
445
|
+
// --allow-unmarked-sinks). Both — like every structured failure in this
|
|
446
|
+
// pipeline — extend CrewhausError, so route the whole family through die()
|
|
447
|
+
// for a clean one-line error + exit 1 instead of letting the emitter crash
|
|
448
|
+
// escape as an uncaught stack trace. A non-CrewhausError (a genuine bug)
|
|
449
|
+
// still propagates with its full stack for debugging.
|
|
450
|
+
if (err instanceof CrewhausError) {
|
|
451
|
+
die(err.message);
|
|
452
|
+
}
|
|
453
|
+
throw err;
|
|
454
|
+
}
|
|
455
|
+
mkdirSync(absOut, { recursive: true });
|
|
456
|
+
for (const file of bundle.files) {
|
|
457
|
+
const fullPath = join(absOut, file.path);
|
|
458
|
+
mkdirSync(dirname(fullPath), { recursive: true });
|
|
459
|
+
writeFileSync(fullPath, file.content);
|
|
460
|
+
process.stdout.write(`wrote ${fullPath}\n`);
|
|
461
|
+
}
|
|
462
|
+
process.stdout.write(`compiled bundle (${bundle.files.length} file(s)) → ${absOut}\n`);
|
|
463
|
+
logger.debug("compile.success", { files: bundle.files.length, out: absOut });
|
|
464
|
+
}
|
|
465
|
+
/**
|
|
466
|
+
* FR-002 — the `compile --strict` enforcement body. Lowers the spec, resolves
|
|
467
|
+
* the tool names it references to their RegisteredTools, and runs the shared
|
|
468
|
+
* `auditToolScopes`. On any finding, writes a `[strict]` diagnostic per
|
|
469
|
+
* tool to stderr and exits 1 BEFORE emitting. Shares the exact audit with
|
|
470
|
+
* `crewhaus doctor --philosophy-alignment`.
|
|
471
|
+
*/
|
|
472
|
+
async function runStrictScopeGate(yamlText) {
|
|
473
|
+
let ir;
|
|
474
|
+
try {
|
|
475
|
+
ir = lower(parseSpec(yamlText));
|
|
476
|
+
}
|
|
477
|
+
catch (err) {
|
|
478
|
+
// SpecParseError (parse) and CompilerError (lower, e.g. a malformed
|
|
479
|
+
// credential env-ref) both extend CrewhausError — render as a clean die()
|
|
480
|
+
// one-liner rather than an uncaught stack trace.
|
|
481
|
+
if (err instanceof CrewhausError)
|
|
482
|
+
die(err.message);
|
|
483
|
+
throw err;
|
|
484
|
+
}
|
|
485
|
+
const toolNames = collectToolNames(ir);
|
|
486
|
+
if (toolNames.length === 0)
|
|
487
|
+
return;
|
|
488
|
+
const toolMap = await loadToolMap();
|
|
489
|
+
// `loadToolMap` is keyed by the camelCase spec key (`webSearch`), which is how
|
|
490
|
+
// a top-level `tools:` list names its tools. But a sub-agent `tools:` list
|
|
491
|
+
// names tools by their REGISTERED name (PascalCase, e.g. `WebSearch`) — the
|
|
492
|
+
// runtime child-catalog filter (`buildChildCatalog` in tool-task) matches on
|
|
493
|
+
// `RegisteredTool.name`, not the spec key. `collectToolNames` gathers BOTH
|
|
494
|
+
// forms from the IR, so also index the map by registered name; otherwise an
|
|
495
|
+
// outward sub-agent sink like `WebSearch` fails to resolve and is wrongly
|
|
496
|
+
// flagged as an unverifiable external sink (it resolves to a built-in whose
|
|
497
|
+
// scope IS statically "external"). A genuinely unknown name (`mcp__*`, a typo)
|
|
498
|
+
// still resolves to undefined under both keys and is still gated.
|
|
499
|
+
const byRegisteredName = {};
|
|
500
|
+
for (const tool of Object.values(toolMap))
|
|
501
|
+
byRegisteredName[tool.name] = tool;
|
|
502
|
+
// Shared, name-independent audit (see scope-audit.ts):
|
|
503
|
+
// - resolvable built-ins are checked by capability/outward-name vs scope;
|
|
504
|
+
// - an outward-by-name sink we CANNOT resolve to a scope:"external" tool
|
|
505
|
+
// offline (a custom outward name, or any `mcp__*` dynamic sink) is a
|
|
506
|
+
// finding, because --strict refuses to emit a bundle that reaches a
|
|
507
|
+
// sink whose external scope it cannot verify at compile time.
|
|
508
|
+
const findings = auditSpecToolNames(toolNames, (name) => toolMap[name] ?? byRegisteredName[name]);
|
|
509
|
+
if (findings.length > 0) {
|
|
510
|
+
for (const f of findings) {
|
|
511
|
+
process.stderr.write(`crewhaus: [strict] tool "${f.toolName}" ${f.reason}\n`);
|
|
512
|
+
}
|
|
513
|
+
process.stderr.write(`crewhaus: [strict] ${findings.length} scope finding(s) — refusing to emit. Set scope: "external" on each tool above, or pass --allow-unmarked-sinks to bypass the gate.\n`);
|
|
514
|
+
process.exit(1);
|
|
515
|
+
}
|
|
516
|
+
}
|
|
517
|
+
function runInit(args) {
|
|
518
|
+
if (args.flags["help"]) {
|
|
519
|
+
process.stdout.write("usage: crewhaus init [name]\n");
|
|
520
|
+
return;
|
|
521
|
+
}
|
|
522
|
+
const nameArg = args.positional[0];
|
|
523
|
+
const targetDir = typeof nameArg === "string" ? resolve(nameArg) : process.cwd();
|
|
524
|
+
const specName = typeof nameArg === "string" ? nameArg : basename(targetDir);
|
|
525
|
+
const targetFile = join(targetDir, "crewhaus.yaml");
|
|
526
|
+
if (existsSync(targetFile)) {
|
|
527
|
+
die(`${targetFile} already exists — refusing to overwrite`);
|
|
528
|
+
}
|
|
529
|
+
mkdirSync(targetDir, { recursive: true });
|
|
530
|
+
const yamlText = `name: ${specName}
|
|
531
|
+
target: cli
|
|
532
|
+
agent:
|
|
533
|
+
model: claude-opus-4-7
|
|
534
|
+
instructions: |
|
|
535
|
+
You are a helpful assistant. Replace these instructions with your
|
|
536
|
+
agent's actual behavior, persona, and constraints.
|
|
537
|
+
`;
|
|
538
|
+
writeFileSync(targetFile, yamlText);
|
|
539
|
+
process.stdout.write(`wrote ${targetFile}\n`);
|
|
540
|
+
// The runtime resolves the spec and the `.crewhaus/` session store from
|
|
541
|
+
// the current working directory, so guide the user to run from inside
|
|
542
|
+
// the harness directory (where crewhaus.yaml lives), not from here.
|
|
543
|
+
const rel = relative(process.cwd(), targetDir);
|
|
544
|
+
const cd = rel === "" ? "" : `cd ${rel} && `;
|
|
545
|
+
process.stdout.write(`next: ${cd}crewhaus run crewhaus.yaml\n`);
|
|
546
|
+
logger.debug("init.success", { target: targetFile });
|
|
547
|
+
}
|
|
548
|
+
/**
|
|
549
|
+
* Built-in tool name → RegisteredTool, populated lazily so that subcommands
|
|
550
|
+
* which don't need tools (init, doctor) don't pay the import cost. Mirror of
|
|
551
|
+
* `BUILTIN_TOOL_MAP` in packages/target-cli/src/index.ts — keep them in sync.
|
|
552
|
+
*/
|
|
553
|
+
async function loadToolMap() {
|
|
554
|
+
const [fs, bash, todo, web, image, fetchPkg, imageGen, docIngest, codegraph] = await Promise.all([
|
|
555
|
+
import("@crewhaus/tool-fs"),
|
|
556
|
+
import("@crewhaus/tool-bash"),
|
|
557
|
+
import("@crewhaus/tool-todo"),
|
|
558
|
+
import("@crewhaus/tool-web"),
|
|
559
|
+
import("@crewhaus/tool-image"),
|
|
560
|
+
import("@crewhaus/tool-fetch"),
|
|
561
|
+
import("@crewhaus/tool-image-generation"),
|
|
562
|
+
import("@crewhaus/tool-document-ingest"),
|
|
563
|
+
import("@crewhaus/tool-codegraph"),
|
|
564
|
+
]);
|
|
565
|
+
return {
|
|
566
|
+
read: fs.read,
|
|
567
|
+
write: fs.write,
|
|
568
|
+
edit: fs.edit,
|
|
569
|
+
glob: fs.glob,
|
|
570
|
+
grep: fs.grep,
|
|
571
|
+
bash: bash.bash,
|
|
572
|
+
todoWrite: todo.todoWrite,
|
|
573
|
+
webFetch: web.webFetch,
|
|
574
|
+
webSearch: web.webSearch,
|
|
575
|
+
readImage: image.readImage,
|
|
576
|
+
fetch: fetchPkg.fetch,
|
|
577
|
+
imageGenerate: imageGen.imageGenerate,
|
|
578
|
+
ingestDocument: docIngest.ingestDocument,
|
|
579
|
+
// Pillar 2 — AST-aware code intelligence (recipe 54).
|
|
580
|
+
codegraphSearch: codegraph.codegraphSearch,
|
|
581
|
+
codegraphCallers: codegraph.codegraphCallers,
|
|
582
|
+
codegraphCallees: codegraph.codegraphCallees,
|
|
583
|
+
codegraphImpact: codegraph.codegraphImpact,
|
|
584
|
+
};
|
|
585
|
+
}
|
|
586
|
+
/**
|
|
587
|
+
* Section 14 — apply per-tool config from the IR's `toolConfigs` map by
|
|
588
|
+
* calling each tool's registration function. Mirror of the codegen-emitted
|
|
589
|
+
* init calls in target-cli/target-channel-bot. Keep in sync.
|
|
590
|
+
*/
|
|
591
|
+
async function applyToolConfigs(toolNames, toolConfigs) {
|
|
592
|
+
const used = new Set(toolNames);
|
|
593
|
+
if (used.has("fetch") && toolConfigs["fetch"] !== undefined) {
|
|
594
|
+
const { registerFetchConfig } = await import("@crewhaus/tool-fetch");
|
|
595
|
+
registerFetchConfig(toolConfigs["fetch"]);
|
|
596
|
+
}
|
|
597
|
+
if (used.has("webFetch") && toolConfigs["webFetch"] !== undefined) {
|
|
598
|
+
const { registerWebFetchConfig } = await import("@crewhaus/tool-web");
|
|
599
|
+
registerWebFetchConfig(toolConfigs["webFetch"]);
|
|
600
|
+
}
|
|
601
|
+
}
|
|
602
|
+
/**
|
|
603
|
+
* Build the layered permission rule set from the IR (yaml source) and an
|
|
604
|
+
* optional `.crewhaus/settings.json` (settings source). The flag and hooks
|
|
605
|
+
* sources are placeholders for future sections (no rules yet, just modes).
|
|
606
|
+
*
|
|
607
|
+
* All non-flag config goes through `parsePermissionsConfig` which rejects
|
|
608
|
+
* `mode: bypass` (defense in depth on top of the spec parser's check).
|
|
609
|
+
*/
|
|
610
|
+
function buildRuleSet(yamlRules, cwd) {
|
|
611
|
+
let settings = [];
|
|
612
|
+
const settingsPath = join(cwd, ".crewhaus", "settings.json");
|
|
613
|
+
if (existsSync(settingsPath)) {
|
|
614
|
+
let raw;
|
|
615
|
+
try {
|
|
616
|
+
raw = JSON.parse(readFileSync(settingsPath, "utf-8"));
|
|
617
|
+
}
|
|
618
|
+
catch (err) {
|
|
619
|
+
die(`failed to parse ${settingsPath}: ${err.message}`);
|
|
620
|
+
}
|
|
621
|
+
// Section 11 introduced top-level keys (`hooks`) into settings.json.
|
|
622
|
+
// Only parse the `permissions` sub-object — never the bare root —
|
|
623
|
+
// so hooks/skills/slash-command keys don't trip the strict permission
|
|
624
|
+
// validator. If the file has no `permissions` block, treat as empty.
|
|
625
|
+
const root = raw.permissions;
|
|
626
|
+
if (root !== undefined) {
|
|
627
|
+
try {
|
|
628
|
+
const parsed = parsePermissionsConfig(root, "settings");
|
|
629
|
+
settings = tagRules(parsed.rules, "settings");
|
|
630
|
+
}
|
|
631
|
+
catch (err) {
|
|
632
|
+
if (err instanceof PermissionConfigError)
|
|
633
|
+
die(err.message);
|
|
634
|
+
throw err;
|
|
635
|
+
}
|
|
636
|
+
}
|
|
637
|
+
}
|
|
638
|
+
return {
|
|
639
|
+
flag: [],
|
|
640
|
+
settings,
|
|
641
|
+
yaml: tagRules(yamlRules, "yaml"),
|
|
642
|
+
hooks: [],
|
|
643
|
+
builtin: BUILTIN_DEFAULT_RULES,
|
|
644
|
+
};
|
|
645
|
+
}
|
|
646
|
+
/**
|
|
647
|
+
* FR-004 — resolve the Pillar 3 intent-gate judge for a `run`. Precedence:
|
|
648
|
+
* `--justification-judge` flag > spec `security.justification.judge` >
|
|
649
|
+
* `"rule-based"`. Returns `undefined` for the rule-based path so the
|
|
650
|
+
* caller omits `justificationJudge` from `runChatLoop` and runtime-core
|
|
651
|
+
* falls back to `ruleBasedJustificationJudge` (the documented default for
|
|
652
|
+
* tests/offline runs). For `"claude"` it lazily imports the adapter +
|
|
653
|
+
* `@crewhaus/justification-judge-claude` (matching the run path's existing
|
|
654
|
+
* lazy-import style) so the model-backed judge code only loads when
|
|
655
|
+
* actually selected. An unknown value `die()`s.
|
|
656
|
+
*/
|
|
657
|
+
async function resolveJustificationJudge(args, securityJustification) {
|
|
658
|
+
const flag = args.flags["justification-judge"];
|
|
659
|
+
const flagValue = typeof flag === "string" ? flag : undefined;
|
|
660
|
+
let choice;
|
|
661
|
+
try {
|
|
662
|
+
choice = resolveJudgeChoice(flagValue, securityJustification);
|
|
663
|
+
}
|
|
664
|
+
catch (err) {
|
|
665
|
+
if (err instanceof InvalidJudgeChoiceError)
|
|
666
|
+
die(err.message);
|
|
667
|
+
throw err;
|
|
668
|
+
}
|
|
669
|
+
return createJustificationJudge(choice, securityJustification?.model);
|
|
670
|
+
}
|
|
671
|
+
/**
|
|
672
|
+
* FR-006 — resolve the Pillar 3 sink-side egress matcher for a `run`.
|
|
673
|
+
* Precedence: `--egress-matcher` flag > spec `security.egressMatcher` (lowered
|
|
674
|
+
* to `ir.security.egressMatcher`) > `"substring"`. Returns `undefined` for the
|
|
675
|
+
* substring path so the caller omits `egressMatcher` from `runChatLoop` and
|
|
676
|
+
* runtime-core stays on the built-in `substringMatcher` (the default egress
|
|
677
|
+
* path then pulls in no embedding dependency). For `"semantic"` it lazily
|
|
678
|
+
* imports `@crewhaus/embedder` + `@crewhaus/egress-matcher-semantic` and
|
|
679
|
+
* constructs a `SemanticEgressMatcher`; the embedder model comes from
|
|
680
|
+
* `--egress-embedder` > `CREWHAUS_EGRESS_EMBEDDER` env >
|
|
681
|
+
* `DEFAULT_EGRESS_EMBEDDER_MODEL`. An unknown matcher value `die()`s.
|
|
682
|
+
*/
|
|
683
|
+
async function resolveEgressMatcher(args, irEgressMatcher) {
|
|
684
|
+
const flag = args.flags["egress-matcher"];
|
|
685
|
+
const flagValue = typeof flag === "string" ? flag : undefined;
|
|
686
|
+
let choice;
|
|
687
|
+
try {
|
|
688
|
+
choice = resolveEgressMatcherChoice(flagValue, irEgressMatcher);
|
|
689
|
+
}
|
|
690
|
+
catch (err) {
|
|
691
|
+
if (err instanceof InvalidEgressMatcherChoiceError)
|
|
692
|
+
die(err.message);
|
|
693
|
+
throw err;
|
|
694
|
+
}
|
|
695
|
+
const embedderFlag = args.flags["egress-embedder"];
|
|
696
|
+
const embedderModel = (typeof embedderFlag === "string" ? embedderFlag : undefined) ??
|
|
697
|
+
process.env["CREWHAUS_EGRESS_EMBEDDER"] ??
|
|
698
|
+
DEFAULT_EGRESS_EMBEDDER_MODEL;
|
|
699
|
+
return createEgressMatcher(choice, { embedderModel });
|
|
700
|
+
}
|
|
701
|
+
async function runRun(args) {
|
|
702
|
+
if (args.flags["help"]) {
|
|
703
|
+
process.stdout.write("usage: crewhaus run <spec.yaml> [--model <model>] [--permission-mode <default|plan|auto|bypass>] [--resume <sessionId> | --continue] [--prompt <text>] [--justification-judge rule-based|claude] [--egress-matcher substring|semantic] [--egress-embedder <model>]\n" +
|
|
704
|
+
" --model accepts the full router grammar: claude-* (Anthropic), openai/<m>, gemini/<m>, bedrock/<id> (geo prefixes tolerated), local/<m>@<url>\n");
|
|
705
|
+
return;
|
|
706
|
+
}
|
|
707
|
+
const specPath = args.positional[0];
|
|
708
|
+
if (typeof specPath !== "string")
|
|
709
|
+
die("missing <spec.yaml>");
|
|
710
|
+
const absSpec = resolve(specPath);
|
|
711
|
+
logger.debug("run.start", { spec: absSpec });
|
|
712
|
+
let yamlText;
|
|
713
|
+
try {
|
|
714
|
+
yamlText = readFileSync(absSpec, "utf-8");
|
|
715
|
+
}
|
|
716
|
+
catch (err) {
|
|
717
|
+
die(`could not read ${absSpec}: ${err.message}`);
|
|
718
|
+
}
|
|
719
|
+
let ir;
|
|
720
|
+
try {
|
|
721
|
+
ir = lower(parseSpec(yamlText));
|
|
722
|
+
}
|
|
723
|
+
catch (err) {
|
|
724
|
+
if (err instanceof SpecParseError)
|
|
725
|
+
die(err.message);
|
|
726
|
+
throw err;
|
|
727
|
+
}
|
|
728
|
+
if (ir.target === "cli")
|
|
729
|
+
return runRunCli(args, ir, specPath);
|
|
730
|
+
if (ir.target === "browser")
|
|
731
|
+
return runRunBrowser(args, ir);
|
|
732
|
+
die(`crewhaus run supports target: cli or browser (got "${ir.target}"). Other target shapes are compile-only — see PACKAGES.md.`);
|
|
733
|
+
}
|
|
734
|
+
/**
|
|
735
|
+
* cli-target run path. Multi-turn interactive REPL, session-store backed,
|
|
736
|
+
* loads hooks/skills/slash-commands/sub-agents from the user's workspace,
|
|
737
|
+
* and wires every spec-declared MCP server.
|
|
738
|
+
*/
|
|
739
|
+
async function runRunCli(args, ir, specPath) {
|
|
740
|
+
const resumeFlag = args.flags["resume"];
|
|
741
|
+
const continueFlag = args.flags["continue"] === true;
|
|
742
|
+
if (typeof resumeFlag === "string" && continueFlag) {
|
|
743
|
+
die("--resume and --continue are mutually exclusive");
|
|
744
|
+
}
|
|
745
|
+
let resumeId;
|
|
746
|
+
if (typeof resumeFlag === "string") {
|
|
747
|
+
if (!SESSION_ID_REGEX.test(resumeFlag)) {
|
|
748
|
+
die(`invalid --resume sessionId "${resumeFlag}" — expected sess_<16 hex>`);
|
|
749
|
+
}
|
|
750
|
+
resumeId = resumeFlag;
|
|
751
|
+
}
|
|
752
|
+
// --continue resolves to the most-recently-updated session for this
|
|
753
|
+
// spec's name, scoped to the current working directory's session
|
|
754
|
+
// store. session-store's list() returns sessions sorted by updatedAt
|
|
755
|
+
// descending, with a TTL-based eviction sweep as a side effect.
|
|
756
|
+
if (continueFlag) {
|
|
757
|
+
const store = createSessionStore();
|
|
758
|
+
const sessions = await store.list();
|
|
759
|
+
const match = sessions.find((s) => s.name === ir.name);
|
|
760
|
+
if (match === undefined) {
|
|
761
|
+
const absSpec = resolve(specPath);
|
|
762
|
+
die(`no prior session for spec "${ir.name}" in ${process.cwd()}/.crewhaus/sessions/. Sessions are stored under the directory you run from — start one from the harness directory with: cd ${dirname(absSpec)} && crewhaus run ${basename(absSpec)}`);
|
|
763
|
+
}
|
|
764
|
+
resumeId = match.id;
|
|
765
|
+
process.stdout.write(`[continue] resuming session ${match.id} (last updated ${match.updatedAt})\n`);
|
|
766
|
+
}
|
|
767
|
+
let tools = [];
|
|
768
|
+
if (ir.tools.length > 0) {
|
|
769
|
+
// Section 14 — apply per-tool config (e.g. registerFetchConfig) before
|
|
770
|
+
// loading the tools so first-call execution sees the registered config.
|
|
771
|
+
await applyToolConfigs(ir.tools, ir.toolConfigs);
|
|
772
|
+
const toolMap = await loadToolMap();
|
|
773
|
+
tools = ir.tools.map((name) => {
|
|
774
|
+
const tool = toolMap[name];
|
|
775
|
+
if (!tool) {
|
|
776
|
+
const known = Object.keys(toolMap).sort().join(", ");
|
|
777
|
+
die(`unknown tool "${name}" — known tools: ${known}`);
|
|
778
|
+
}
|
|
779
|
+
return tool;
|
|
780
|
+
});
|
|
781
|
+
}
|
|
782
|
+
// Section 9 — connect to declared MCP servers and register their remote
|
|
783
|
+
// tools alongside the built-ins. Mirror of the codegen path in
|
|
784
|
+
// @crewhaus/target-cli (renderMcpServers); keep them in sync.
|
|
785
|
+
let mcpHost;
|
|
786
|
+
if (Object.keys(ir.mcp_servers).length > 0) {
|
|
787
|
+
const host = new McpHost({ logger });
|
|
788
|
+
mcpHost = host;
|
|
789
|
+
for (const [name, cfg] of Object.entries(ir.mcp_servers)) {
|
|
790
|
+
host.addServer(name, cfg);
|
|
791
|
+
}
|
|
792
|
+
const tempCatalog = new ToolCatalog();
|
|
793
|
+
for (const t of tools)
|
|
794
|
+
tempCatalog.register(t);
|
|
795
|
+
await Promise.all(Object.keys(ir.mcp_servers).map((name) => registerMcpServer(host, name, tempCatalog, {
|
|
796
|
+
onRegister: ({ fullName }) => process.stdout.write(`[mcp] registered ${fullName}\n`),
|
|
797
|
+
})));
|
|
798
|
+
tools = tempCatalog.list().slice();
|
|
799
|
+
}
|
|
800
|
+
const modelOverride = args.flags["model"];
|
|
801
|
+
const model = typeof modelOverride === "string" ? modelOverride : ir.agent.model;
|
|
802
|
+
// Permission mode resolution: CLI flag > spec > "default".
|
|
803
|
+
// bypass is reachable only via the flag (the spec parser has already
|
|
804
|
+
// rejected `mode: bypass`).
|
|
805
|
+
const flagMode = args.flags["permission-mode"];
|
|
806
|
+
let permissionMode;
|
|
807
|
+
if (typeof flagMode === "string") {
|
|
808
|
+
if (!isValidPermissionMode(flagMode)) {
|
|
809
|
+
die(`invalid --permission-mode "${flagMode}" — allowed: ${VALID_PERMISSION_MODES.join(", ")}`);
|
|
810
|
+
}
|
|
811
|
+
permissionMode = flagMode;
|
|
812
|
+
}
|
|
813
|
+
else if (ir.permissions.mode !== undefined) {
|
|
814
|
+
permissionMode = ir.permissions.mode;
|
|
815
|
+
}
|
|
816
|
+
else {
|
|
817
|
+
permissionMode = "default";
|
|
818
|
+
}
|
|
819
|
+
const permissionRules = buildRuleSet(ir.permissions.rules, process.cwd());
|
|
820
|
+
// FR-004 — resolve the Pillar 3 intent-gate judge (flag > spec > rule-based).
|
|
821
|
+
// undefined leaves runtime-core on `ruleBasedJustificationJudge`.
|
|
822
|
+
const justificationJudge = await resolveJustificationJudge(args, ir.security?.justification);
|
|
823
|
+
// FR-004 — open the durable audit sink the intent gate appends a
|
|
824
|
+
// `permission_justification_evaluated` record to. On by default for `run`
|
|
825
|
+
// (rooted at .crewhaus/audit); `--no-justification-audit` skips it. undefined
|
|
826
|
+
// leaves runtime-core writing only the ephemeral trace-bus event.
|
|
827
|
+
const justificationAuditSink = await openJustificationAuditSink({
|
|
828
|
+
cwd: process.cwd(),
|
|
829
|
+
enabled: args.flags["no-justification-audit"] !== true,
|
|
830
|
+
});
|
|
831
|
+
// FR-006 — resolve the Pillar 3 sink-side egress matcher (flag > spec >
|
|
832
|
+
// substring). undefined leaves runtime-core on the built-in
|
|
833
|
+
// `substringMatcher`; `"semantic"` constructs the optional
|
|
834
|
+
// `@crewhaus/egress-matcher-semantic` with an injected embedder. The
|
|
835
|
+
// placement (IR-wired, every external sink) and the warn/block policy are
|
|
836
|
+
// unchanged — only *how* lineage matches are detected.
|
|
837
|
+
const egressMatcher = await resolveEgressMatcher(args, ir.security?.egressMatcher);
|
|
838
|
+
// Section 11 — discover hooks, skills, and slash commands from the user's
|
|
839
|
+
// workspace. Hooks come from `~/.crewhaus/settings.json` + `<cwd>/.crewhaus/settings.json`;
|
|
840
|
+
// skills from `~/.crewhaus/skills/*/SKILL.md` + project-equivalent; slash
|
|
841
|
+
// commands from `<cwd>/.crewhaus/commands/*.md`. When skills are present,
|
|
842
|
+
// a synthetic `Skill(name)` tool is appended to the tool list so the
|
|
843
|
+
// model can lazily fetch each skill's body.
|
|
844
|
+
const cwd = process.cwd();
|
|
845
|
+
const [hooks, skills, slashCommands] = await Promise.all([
|
|
846
|
+
loadHooks({ cwd }),
|
|
847
|
+
discoverSkills({ cwd }),
|
|
848
|
+
loadCommands({ cwd }),
|
|
849
|
+
]);
|
|
850
|
+
if (skills.length > 0) {
|
|
851
|
+
tools.push(createSkillTool(skills));
|
|
852
|
+
process.stdout.write(`[skills] ${skills.length} available: ${skills.map((s) => s.name).join(", ")}\n`);
|
|
853
|
+
}
|
|
854
|
+
if (hooks.length > 0)
|
|
855
|
+
process.stdout.write(`[hooks] ${hooks.length} loaded\n`);
|
|
856
|
+
if (slashCommands.size > 0) {
|
|
857
|
+
process.stdout.write(`[slash] ${slashCommands.size} commands: ${[...slashCommands.keys()].join(", ")}\n`);
|
|
858
|
+
}
|
|
859
|
+
// Section 13 — when the IR carries inline sub-agent definitions, build the
|
|
860
|
+
// registry, register the Task tool, and inject `spawnSubAgent` so the
|
|
861
|
+
// runtime can populate the bridge for framework-aware tools.
|
|
862
|
+
let subAgents;
|
|
863
|
+
if (ir.subAgents.length > 0) {
|
|
864
|
+
subAgents = new Map(ir.subAgents.map((d) => [
|
|
865
|
+
d.name,
|
|
866
|
+
{
|
|
867
|
+
name: d.name,
|
|
868
|
+
description: d.description,
|
|
869
|
+
instructions: d.instructions,
|
|
870
|
+
tools: d.tools,
|
|
871
|
+
...(d.model !== undefined ? { model: d.model } : {}),
|
|
872
|
+
permissions: d.permissions,
|
|
873
|
+
inherit_bypass: d.inheritBypass,
|
|
874
|
+
},
|
|
875
|
+
]));
|
|
876
|
+
tools.push(createTaskTool({ subAgents }));
|
|
877
|
+
process.stdout.write(`[sub-agents] ${subAgents.size} available: ${[...subAgents.keys()].join(", ")}\n`);
|
|
878
|
+
}
|
|
879
|
+
try {
|
|
880
|
+
await runChatLoop({
|
|
881
|
+
model,
|
|
882
|
+
instructions: ir.agent.instructions,
|
|
883
|
+
tools,
|
|
884
|
+
permissionMode,
|
|
885
|
+
permissionRules,
|
|
886
|
+
sessionName: ir.name,
|
|
887
|
+
sessionTarget: ir.target,
|
|
888
|
+
hooks,
|
|
889
|
+
skills,
|
|
890
|
+
slashCommands,
|
|
891
|
+
...(subAgents !== undefined ? { subAgents, spawnSubAgent } : {}),
|
|
892
|
+
...(ir.target === "cli" && ir.agent.maxTokens !== undefined
|
|
893
|
+
? { maxTokens: ir.agent.maxTokens }
|
|
894
|
+
: {}),
|
|
895
|
+
...(resumeId !== undefined ? { resume: { sessionId: resumeId } } : {}),
|
|
896
|
+
...(justificationJudge !== undefined ? { justificationJudge } : {}),
|
|
897
|
+
...(justificationAuditSink !== undefined ? { justificationAuditSink } : {}),
|
|
898
|
+
...(egressMatcher !== undefined ? { egressMatcher } : {}),
|
|
899
|
+
});
|
|
900
|
+
}
|
|
901
|
+
finally {
|
|
902
|
+
if (mcpHost)
|
|
903
|
+
await mcpHost.disconnectAll();
|
|
904
|
+
}
|
|
905
|
+
}
|
|
906
|
+
/**
|
|
907
|
+
* browser-target run path. Single-turn: read one prompt (from --prompt or
|
|
908
|
+
* stdin), drive a chromium session via @crewhaus/computer-use-driver,
|
|
909
|
+
* disconnect cleanly. Mirrors the codegen template in
|
|
910
|
+
* packages/target-browser-driver/src/index.ts — keep them in sync.
|
|
911
|
+
*
|
|
912
|
+
* V0 deliberately omits MCP, hooks, skills, slash commands, and sub-agents:
|
|
913
|
+
* runChatLoop runs with `singleTurn: true`, so multi-turn-only features
|
|
914
|
+
* aren't load-bearing here. The cli path keeps them. The browser bundle
|
|
915
|
+
* emitter (`@crewhaus/target-browser-driver`) honors ir.tools +
|
|
916
|
+
* ir.toolConfigs and similarly skips mcp_servers / compaction — extend
|
|
917
|
+
* both in lockstep if that ever changes.
|
|
918
|
+
*/
|
|
919
|
+
async function runRunBrowser(args, ir) {
|
|
920
|
+
if (args.flags["resume"] !== undefined || args.flags["continue"] === true) {
|
|
921
|
+
die("--resume and --continue are not supported for target: browser (single-turn)");
|
|
922
|
+
}
|
|
923
|
+
const promptFlag = args.flags["prompt"];
|
|
924
|
+
let prompt;
|
|
925
|
+
if (typeof promptFlag === "string" && promptFlag.length > 0) {
|
|
926
|
+
prompt = promptFlag;
|
|
927
|
+
}
|
|
928
|
+
else if (process.stdin.isTTY) {
|
|
929
|
+
die("no prompt — pass --prompt <text> or pipe input on stdin");
|
|
930
|
+
}
|
|
931
|
+
else {
|
|
932
|
+
prompt = (await readAllStdin()).trim();
|
|
933
|
+
if (prompt.length === 0) {
|
|
934
|
+
die("no prompt — pass --prompt <text> or pipe non-empty input on stdin");
|
|
935
|
+
}
|
|
936
|
+
}
|
|
937
|
+
let tools = [];
|
|
938
|
+
if (ir.tools.length > 0) {
|
|
939
|
+
await applyToolConfigs(ir.tools, ir.toolConfigs);
|
|
940
|
+
const toolMap = await loadToolMap();
|
|
941
|
+
tools = ir.tools.map((name) => {
|
|
942
|
+
const tool = toolMap[name];
|
|
943
|
+
if (!tool) {
|
|
944
|
+
const known = Object.keys(toolMap).sort().join(", ");
|
|
945
|
+
die(`unknown tool "${name}" — known tools: ${known}`);
|
|
946
|
+
}
|
|
947
|
+
return tool;
|
|
948
|
+
});
|
|
949
|
+
}
|
|
950
|
+
const modelOverride = args.flags["model"];
|
|
951
|
+
const model = typeof modelOverride === "string" ? modelOverride : ir.agent.model;
|
|
952
|
+
const flagMode = args.flags["permission-mode"];
|
|
953
|
+
let permissionMode;
|
|
954
|
+
if (typeof flagMode === "string") {
|
|
955
|
+
if (!isValidPermissionMode(flagMode)) {
|
|
956
|
+
die(`invalid --permission-mode "${flagMode}" — allowed: ${VALID_PERMISSION_MODES.join(", ")}`);
|
|
957
|
+
}
|
|
958
|
+
permissionMode = flagMode;
|
|
959
|
+
}
|
|
960
|
+
else if (ir.permissions.mode !== undefined) {
|
|
961
|
+
permissionMode = ir.permissions.mode;
|
|
962
|
+
}
|
|
963
|
+
else {
|
|
964
|
+
permissionMode = "default";
|
|
965
|
+
}
|
|
966
|
+
const permissionRules = buildRuleSet(ir.permissions.rules, process.cwd());
|
|
967
|
+
// FR-004 — honour --justification-judge on the browser one-shot path too.
|
|
968
|
+
// The browser spec shape carries no `security` block, so this is
|
|
969
|
+
// flag-only (flag > rule-based); both run paths thread the same judge
|
|
970
|
+
// into the same gate inside runChatLoop.
|
|
971
|
+
const justificationJudge = await resolveJustificationJudge(args, undefined);
|
|
972
|
+
// FR-006 — honour --egress-matcher on the browser one-shot path too. The
|
|
973
|
+
// browser spec shape carries no `security` block, so this is flag-only
|
|
974
|
+
// (flag > substring); both run paths thread the same matcher into the same
|
|
975
|
+
// egress check inside runChatLoop.
|
|
976
|
+
const egressMatcher = await resolveEgressMatcher(args, undefined);
|
|
977
|
+
// FR-004 — same durable audit sink as the cli path, so a justification-gated
|
|
978
|
+
// browser tool also writes the `permission_justification_evaluated` record.
|
|
979
|
+
const justificationAuditSink = await openJustificationAuditSink({
|
|
980
|
+
cwd: process.cwd(),
|
|
981
|
+
enabled: args.flags["no-justification-audit"] !== true,
|
|
982
|
+
});
|
|
983
|
+
// Lazy-import the browser-runtime packages so cli/init/doctor invocations
|
|
984
|
+
// don't pay the playwright + computer-use-driver load cost.
|
|
985
|
+
const [{ createDriver }, navigate, screenCapture, mouseKeyboard, visionGrounding] = await Promise.all([
|
|
986
|
+
import("@crewhaus/computer-use-driver"),
|
|
987
|
+
import("@crewhaus/tool-navigate"),
|
|
988
|
+
import("@crewhaus/tool-screen-capture"),
|
|
989
|
+
import("@crewhaus/tool-mouse-keyboard"),
|
|
990
|
+
import("@crewhaus/tool-vision-grounding"),
|
|
991
|
+
]);
|
|
992
|
+
const driver = createDriver({
|
|
993
|
+
backend: ir.driver.backend,
|
|
994
|
+
viewport: ir.driver.viewport,
|
|
995
|
+
});
|
|
996
|
+
emitEvent({ kind: "browser_start", backend: ir.driver.backend });
|
|
997
|
+
await driver.connect();
|
|
998
|
+
try {
|
|
999
|
+
if (ir.driver.startUrl !== undefined) {
|
|
1000
|
+
await driver.goto(ir.driver.startUrl);
|
|
1001
|
+
emitEvent({ kind: "navigated", url: ir.driver.startUrl });
|
|
1002
|
+
}
|
|
1003
|
+
const navigateTool = navigate.createNavigateTool({ driver });
|
|
1004
|
+
const screenshotTool = screenCapture.createScreenshotTool({ driver });
|
|
1005
|
+
const mk = mouseKeyboard.createAllMouseKeyboardTools({ driver });
|
|
1006
|
+
const findElement = visionGrounding.createFindElementTool({
|
|
1007
|
+
driver,
|
|
1008
|
+
model: ir.groundingModel,
|
|
1009
|
+
});
|
|
1010
|
+
const allTools = [
|
|
1011
|
+
navigateTool,
|
|
1012
|
+
screenshotTool,
|
|
1013
|
+
mk.click,
|
|
1014
|
+
mk.type,
|
|
1015
|
+
mk.key,
|
|
1016
|
+
mk.scroll,
|
|
1017
|
+
findElement,
|
|
1018
|
+
...tools,
|
|
1019
|
+
];
|
|
1020
|
+
const finalText = await runChatLoop({
|
|
1021
|
+
model,
|
|
1022
|
+
instructions: ir.agent.instructions,
|
|
1023
|
+
tools: allTools,
|
|
1024
|
+
permissionMode,
|
|
1025
|
+
permissionRules,
|
|
1026
|
+
sessionName: ir.name,
|
|
1027
|
+
sessionTarget: "browser",
|
|
1028
|
+
singleTurn: true,
|
|
1029
|
+
seedMessages: [{ role: "user", content: prompt }],
|
|
1030
|
+
...(justificationJudge !== undefined ? { justificationJudge } : {}),
|
|
1031
|
+
...(justificationAuditSink !== undefined ? { justificationAuditSink } : {}),
|
|
1032
|
+
...(egressMatcher !== undefined ? { egressMatcher } : {}),
|
|
1033
|
+
installSigintHandler: false,
|
|
1034
|
+
maxTokens: 4096,
|
|
1035
|
+
});
|
|
1036
|
+
emitEvent({ kind: "browser_done", finalText });
|
|
1037
|
+
}
|
|
1038
|
+
finally {
|
|
1039
|
+
await driver.disconnect();
|
|
1040
|
+
}
|
|
1041
|
+
}
|
|
1042
|
+
async function readAllStdin() {
|
|
1043
|
+
const chunks = [];
|
|
1044
|
+
for await (const chunk of process.stdin)
|
|
1045
|
+
chunks.push(chunk);
|
|
1046
|
+
return Buffer.concat(chunks).toString("utf-8");
|
|
1047
|
+
}
|
|
1048
|
+
function emitEvent(event) {
|
|
1049
|
+
process.stdout.write(`${JSON.stringify(event)}\n`);
|
|
1050
|
+
}
|
|
1051
|
+
function checkBunVersion(version) {
|
|
1052
|
+
const parts = version.split(".");
|
|
1053
|
+
const major = Number.parseInt(parts[0] ?? "", 10);
|
|
1054
|
+
const minor = Number.parseInt(parts[1] ?? "", 10);
|
|
1055
|
+
if (Number.isNaN(major) || Number.isNaN(minor)) {
|
|
1056
|
+
return { pass: false, reason: `unparseable version "${version}"` };
|
|
1057
|
+
}
|
|
1058
|
+
const ok = major > 1 || (major === 1 && minor >= 2);
|
|
1059
|
+
return ok ? { pass: true } : { pass: false, reason: `bun ${version} is below minimum 1.2.0` };
|
|
1060
|
+
}
|
|
1061
|
+
function runContext(args) {
|
|
1062
|
+
if (args.flags["help"]) {
|
|
1063
|
+
process.stdout.write("usage: crewhaus context --bundle [-o <file>]\n" +
|
|
1064
|
+
" Emits a single-markdown manifest of the spec schema, recipe index,\n" +
|
|
1065
|
+
" module catalog headings, and getting-started guide. Designed for\n" +
|
|
1066
|
+
" piping into an agent's system prompt or for caching as the answer\n" +
|
|
1067
|
+
' to "give me CrewHaus context."\n' +
|
|
1068
|
+
"\n" +
|
|
1069
|
+
" --factory-root <p> override factory checkout (default: $CREWHAUS_FACTORY_ROOT\n" +
|
|
1070
|
+
" or sibling-walk from cwd)\n" +
|
|
1071
|
+
" --docs-root <p> override docs checkout\n" +
|
|
1072
|
+
" --demos-root <p> override demos checkout\n");
|
|
1073
|
+
return;
|
|
1074
|
+
}
|
|
1075
|
+
if (args.flags["bundle"] !== true) {
|
|
1076
|
+
die("missing --bundle (the only mode currently supported)");
|
|
1077
|
+
}
|
|
1078
|
+
const factoryOverride = args.flags["factory-root"];
|
|
1079
|
+
const docsOverride = args.flags["docs-root"];
|
|
1080
|
+
const demosOverride = args.flags["demos-root"];
|
|
1081
|
+
const env = {
|
|
1082
|
+
...process.env,
|
|
1083
|
+
...(typeof factoryOverride === "string" ? { CREWHAUS_FACTORY_ROOT: factoryOverride } : {}),
|
|
1084
|
+
...(typeof docsOverride === "string" ? { CREWHAUS_DOCS_ROOT: docsOverride } : {}),
|
|
1085
|
+
...(typeof demosOverride === "string" ? { CREWHAUS_DEMOS_ROOT: demosOverride } : {}),
|
|
1086
|
+
};
|
|
1087
|
+
let roots;
|
|
1088
|
+
try {
|
|
1089
|
+
roots = discoverRoots({ env });
|
|
1090
|
+
}
|
|
1091
|
+
catch (err) {
|
|
1092
|
+
die(err.message);
|
|
1093
|
+
}
|
|
1094
|
+
const bundle = buildContextBundle({
|
|
1095
|
+
factoryRoot: roots.factoryRoot,
|
|
1096
|
+
docsRoot: roots.docsRoot,
|
|
1097
|
+
demosRoot: roots.demosRoot,
|
|
1098
|
+
});
|
|
1099
|
+
const outFile = args.flags["out"];
|
|
1100
|
+
if (typeof outFile === "string") {
|
|
1101
|
+
const abs = resolve(outFile);
|
|
1102
|
+
const dir = dirname(abs);
|
|
1103
|
+
if (!existsSync(dir))
|
|
1104
|
+
mkdirSync(dir, { recursive: true });
|
|
1105
|
+
writeFileSync(abs, bundle.markdown);
|
|
1106
|
+
process.stderr.write(`wrote bundle: ${abs} (${bundle.markdown.length} chars, ${bundle.sources.length} sources)\n`);
|
|
1107
|
+
return;
|
|
1108
|
+
}
|
|
1109
|
+
process.stdout.write(bundle.markdown);
|
|
1110
|
+
}
|
|
1111
|
+
async function runDoctor(args) {
|
|
1112
|
+
if (args.flags["help"]) {
|
|
1113
|
+
process.stdout.write("usage: crewhaus doctor [--philosophy-alignment] [--liveness]\n" +
|
|
1114
|
+
" --philosophy-alignment audit the codebase + examples against the three architectural pillars\n" +
|
|
1115
|
+
" --liveness process-liveness probe: exit 0 immediately, no credential or\n" +
|
|
1116
|
+
" spec checks (for container HEALTHCHECKs / k8s exec probes)\n" +
|
|
1117
|
+
"\n" +
|
|
1118
|
+
" Credential checks are model-aware: doctor parses the cwd crewhaus.yaml's\n" +
|
|
1119
|
+
" agent.model (claude-*, openai/*, gemini/*, bedrock/*, local/<m>@<url>) and\n" +
|
|
1120
|
+
" checks the matching provider's env; other providers report informationally.\n");
|
|
1121
|
+
return;
|
|
1122
|
+
}
|
|
1123
|
+
// --liveness: pure process-liveness for container/k8s probes. The doctor
|
|
1124
|
+
// binary booting far enough to parse argv IS the signal — no credential or
|
|
1125
|
+
// spec checks (a probe must not flap on missing keys or a spec-less image).
|
|
1126
|
+
if (args.flags["liveness"]) {
|
|
1127
|
+
process.stdout.write("ok\n");
|
|
1128
|
+
process.exit(0);
|
|
1129
|
+
}
|
|
1130
|
+
if (args.flags["philosophy-alignment"]) {
|
|
1131
|
+
await runDoctorPhilosophyAlignment();
|
|
1132
|
+
return;
|
|
1133
|
+
}
|
|
1134
|
+
const checks = [];
|
|
1135
|
+
// Model-aware provider credentials: read the cwd spec's agent.model (when
|
|
1136
|
+
// present + parseable) and check the MATCHING provider's env. Falls back to
|
|
1137
|
+
// the legacy Anthropic-first check when no model is extractable. See
|
|
1138
|
+
// doctor-checks.ts for the full policy (bedrock/local are informational).
|
|
1139
|
+
const specPath = join(process.cwd(), "crewhaus.yaml");
|
|
1140
|
+
let specModel;
|
|
1141
|
+
if (existsSync(specPath)) {
|
|
1142
|
+
try {
|
|
1143
|
+
specModel = extractSpecModel(readFileSync(specPath, "utf-8"));
|
|
1144
|
+
}
|
|
1145
|
+
catch {
|
|
1146
|
+
specModel = undefined;
|
|
1147
|
+
}
|
|
1148
|
+
}
|
|
1149
|
+
checks.push(...buildCredentialChecks(specModel, process.env));
|
|
1150
|
+
const bunCheck = checkBunVersion(Bun.version);
|
|
1151
|
+
checks.push({
|
|
1152
|
+
label: `Bun runtime (${Bun.version})`,
|
|
1153
|
+
pass: bunCheck.pass,
|
|
1154
|
+
reason: bunCheck.reason,
|
|
1155
|
+
});
|
|
1156
|
+
checks.push({
|
|
1157
|
+
label: "crewhaus.yaml in cwd",
|
|
1158
|
+
pass: existsSync(specPath),
|
|
1159
|
+
reason: existsSync(specPath) ? undefined : `not found at ${specPath} — run \`crewhaus init\``,
|
|
1160
|
+
});
|
|
1161
|
+
for (const c of checks) {
|
|
1162
|
+
if (c.warn && c.pass) {
|
|
1163
|
+
process.stdout.write(`~ ${c.label}: ${c.reason ?? "informational"}\n`);
|
|
1164
|
+
}
|
|
1165
|
+
else if (c.pass) {
|
|
1166
|
+
process.stdout.write(`✓ ${c.label}\n`);
|
|
1167
|
+
}
|
|
1168
|
+
else {
|
|
1169
|
+
process.stdout.write(`✗ ${c.label}: ${c.reason ?? "failed"}\n`);
|
|
1170
|
+
}
|
|
1171
|
+
}
|
|
1172
|
+
const allPass = checks.every((c) => c.pass);
|
|
1173
|
+
process.stdout.write(allPass ? "\nall checks passed.\n" : "\nsome checks failed.\n");
|
|
1174
|
+
process.exit(allPass ? 0 : 1);
|
|
1175
|
+
}
|
|
1176
|
+
/**
|
|
1177
|
+
* Workstream D — `crewhaus doctor --philosophy-alignment`. Audits the
|
|
1178
|
+
* repo against the three architectural pillars (compiler-as-protagonist,
|
|
1179
|
+
* eval-is-active, security-is-fabric). Exits 0 on green, 1 on any
|
|
1180
|
+
* finding. Designed to be runnable in CI as a soft gate.
|
|
1181
|
+
*/
|
|
1182
|
+
async function runDoctorPhilosophyAlignment() {
|
|
1183
|
+
const findings = [];
|
|
1184
|
+
// Pillar 1 — compiler-as-protagonist. The IR-discriminated-union is
|
|
1185
|
+
// the contract; the architecture doc must reference the IR variants.
|
|
1186
|
+
// Canonical docs live off-repo at github.com/crewhaus/docs; we look
|
|
1187
|
+
// for a sibling checkout (../docs/) in the developer workspace and
|
|
1188
|
+
// warn-skip when absent rather than failing the audit.
|
|
1189
|
+
const archDocPath = resolve(process.cwd(), "..", "docs", "COMPILER-ARCHITECTURE.md");
|
|
1190
|
+
if (existsSync(archDocPath)) {
|
|
1191
|
+
const content = readFileSync(archDocPath, "utf8");
|
|
1192
|
+
const referencesIrVariants = content.includes("IrV0") && content.includes("IrPipelineV0") && content.includes("IrGraphV0");
|
|
1193
|
+
findings.push({
|
|
1194
|
+
label: "Pillar 1 — ../docs/COMPILER-ARCHITECTURE.md references IR variants",
|
|
1195
|
+
pass: referencesIrVariants,
|
|
1196
|
+
reason: referencesIrVariants
|
|
1197
|
+
? undefined
|
|
1198
|
+
: "doc exists but does not enumerate the IR-discriminated-union variants",
|
|
1199
|
+
});
|
|
1200
|
+
}
|
|
1201
|
+
else {
|
|
1202
|
+
findings.push({
|
|
1203
|
+
label: "Pillar 1 — COMPILER-ARCHITECTURE.md (sibling checkout)",
|
|
1204
|
+
pass: true,
|
|
1205
|
+
warn: true,
|
|
1206
|
+
reason: "sibling ../docs not cloned; canonical docs at github.com/crewhaus/docs — clone alongside factory/ to enable this check",
|
|
1207
|
+
});
|
|
1208
|
+
}
|
|
1209
|
+
// Pillar 2 — eval is active. The eval-optimizer-orchestrator must
|
|
1210
|
+
// be in the workspace and the optimize CLI subcommand must exist.
|
|
1211
|
+
const orchestratorPkg = join(process.cwd(), "packages", "eval-optimizer-orchestrator", "package.json");
|
|
1212
|
+
findings.push({
|
|
1213
|
+
label: "Pillar 2 — eval-optimizer-orchestrator package present",
|
|
1214
|
+
pass: existsSync(orchestratorPkg),
|
|
1215
|
+
reason: existsSync(orchestratorPkg)
|
|
1216
|
+
? undefined
|
|
1217
|
+
: "Workstream B did not land — install or rebuild",
|
|
1218
|
+
});
|
|
1219
|
+
const specPatchPkg = join(process.cwd(), "packages", "spec-patch", "package.json");
|
|
1220
|
+
findings.push({
|
|
1221
|
+
label: "Pillar 2 — spec-patch package present",
|
|
1222
|
+
pass: existsSync(specPatchPkg),
|
|
1223
|
+
reason: existsSync(specPatchPkg) ? undefined : "Workstream B did not land",
|
|
1224
|
+
});
|
|
1225
|
+
// Pillar 3 — security is a fabric. The boundary-classifier package
|
|
1226
|
+
// must exist and be referenced by the canonical boundary sites
|
|
1227
|
+
// (tool-mcp, sub-agent-spawner, skills-registry, compaction-autocompact,
|
|
1228
|
+
// federation-router, channel-adapter-base).
|
|
1229
|
+
const boundaryPkg = join(process.cwd(), "packages", "boundary-classifier", "package.json");
|
|
1230
|
+
findings.push({
|
|
1231
|
+
label: "Pillar 3 — boundary-classifier package present",
|
|
1232
|
+
pass: existsSync(boundaryPkg),
|
|
1233
|
+
reason: existsSync(boundaryPkg) ? undefined : "Workstream C did not land",
|
|
1234
|
+
});
|
|
1235
|
+
const boundarySites = [
|
|
1236
|
+
{ name: "tool-mcp", path: "packages/tool-mcp/src/index.ts" },
|
|
1237
|
+
{ name: "sub-agent-spawner", path: "packages/sub-agent-spawner/src/index.ts" },
|
|
1238
|
+
{ name: "skills-registry", path: "packages/skills-registry/src/index.ts" },
|
|
1239
|
+
{ name: "compaction-autocompact", path: "packages/compaction-autocompact/src/index.ts" },
|
|
1240
|
+
{ name: "federation-router", path: "packages/federation-router/src/index.ts" },
|
|
1241
|
+
{ name: "channel-adapter-base", path: "packages/channel-adapter-base/src/index.ts" },
|
|
1242
|
+
];
|
|
1243
|
+
for (const site of boundarySites) {
|
|
1244
|
+
const filePath = join(process.cwd(), site.path);
|
|
1245
|
+
if (!existsSync(filePath)) {
|
|
1246
|
+
findings.push({
|
|
1247
|
+
label: `Pillar 3 — ${site.name} source present`,
|
|
1248
|
+
pass: false,
|
|
1249
|
+
reason: `${site.path} not found`,
|
|
1250
|
+
});
|
|
1251
|
+
continue;
|
|
1252
|
+
}
|
|
1253
|
+
const body = readFileSync(filePath, "utf8");
|
|
1254
|
+
const classifies = body.includes("classifyBoundary") || body.includes("boundary-classifier");
|
|
1255
|
+
findings.push({
|
|
1256
|
+
label: `Pillar 3 — ${site.name} calls classifyBoundary`,
|
|
1257
|
+
pass: classifies,
|
|
1258
|
+
reason: classifies
|
|
1259
|
+
? undefined
|
|
1260
|
+
: `no reference to classifyBoundary in ${site.path} — security regression`,
|
|
1261
|
+
});
|
|
1262
|
+
}
|
|
1263
|
+
// FR-002 — Pillar 3 sink-side. Audit the full built-in tool map: every
|
|
1264
|
+
// outward-reaching built-in must carry scope: "external" so the egress
|
|
1265
|
+
// classifier fires on it. Uses the SAME `auditToolScopes` helper as
|
|
1266
|
+
// `compile --strict` (acceptance: the two paths share one implementation).
|
|
1267
|
+
// Importing the tool packages is heavier than the rest of doctor, so it's
|
|
1268
|
+
// confined to this --philosophy-alignment branch.
|
|
1269
|
+
const toolMap = await loadToolMap();
|
|
1270
|
+
const scopeFindings = auditToolScopes(Object.values(toolMap));
|
|
1271
|
+
if (scopeFindings.length === 0) {
|
|
1272
|
+
findings.push({
|
|
1273
|
+
label: 'Pillar 3 — all built-in outward tools scope:"external"',
|
|
1274
|
+
pass: true,
|
|
1275
|
+
});
|
|
1276
|
+
}
|
|
1277
|
+
else {
|
|
1278
|
+
for (const f of scopeFindings) {
|
|
1279
|
+
findings.push({
|
|
1280
|
+
label: `Pillar 3 — tool "${f.toolName}" outward but scope!="external"`,
|
|
1281
|
+
pass: false,
|
|
1282
|
+
reason: f.reason,
|
|
1283
|
+
});
|
|
1284
|
+
}
|
|
1285
|
+
}
|
|
1286
|
+
// Contributor compass exists at project root. AGENTS.md is the canonical
|
|
1287
|
+
// vendor-neutral convention (agents.md); CLAUDE.md is accepted as a fallback
|
|
1288
|
+
// for repos still on the Claude Code-specific naming.
|
|
1289
|
+
const agentsmd = join(process.cwd(), "AGENTS.md");
|
|
1290
|
+
const claudemd = join(process.cwd(), "CLAUDE.md");
|
|
1291
|
+
const hasContributorCompass = existsSync(agentsmd) || existsSync(claudemd);
|
|
1292
|
+
findings.push({
|
|
1293
|
+
label: "Contributor doc — AGENTS.md (or CLAUDE.md) at project root",
|
|
1294
|
+
pass: hasContributorCompass,
|
|
1295
|
+
reason: hasContributorCompass ? undefined : "missing — contributors will drift",
|
|
1296
|
+
});
|
|
1297
|
+
for (const f of findings) {
|
|
1298
|
+
if (f.warn && f.pass) {
|
|
1299
|
+
process.stdout.write(`~ ${f.label}: ${f.reason ?? "skipped"}\n`);
|
|
1300
|
+
}
|
|
1301
|
+
else if (f.pass) {
|
|
1302
|
+
process.stdout.write(`✓ ${f.label}\n`);
|
|
1303
|
+
}
|
|
1304
|
+
else {
|
|
1305
|
+
process.stdout.write(`✗ ${f.label}: ${f.reason ?? "failed"}\n`);
|
|
1306
|
+
}
|
|
1307
|
+
}
|
|
1308
|
+
const allPass = findings.every((f) => f.pass);
|
|
1309
|
+
process.stdout.write(allPass
|
|
1310
|
+
? "\nphilosophy alignment: green. All three pillars intact.\n"
|
|
1311
|
+
: `\nphilosophy alignment: ${findings.filter((f) => !f.pass).length} finding(s). See [/AGENTS.md](AGENTS.md) for invariants.\n`);
|
|
1312
|
+
process.exit(allPass ? 0 : 1);
|
|
1313
|
+
}
|
|
1314
|
+
/**
|
|
1315
|
+
* Workstream B — `crewhaus optimize <spec> --dataset <data> --graders
|
|
1316
|
+
* <graders.yaml> [--mutator rule-based|claude] [--iterations N]
|
|
1317
|
+
* [--budget-usd N] [--write-back] [-o <out-dir>]`. Closes the
|
|
1318
|
+
* active-optimisation loop.
|
|
1319
|
+
*
|
|
1320
|
+
* Loads the spec + dataset + graders, builds a fitness function that
|
|
1321
|
+
* re-runs `eval-runner` for every candidate prompt, delegates to
|
|
1322
|
+
* `optimizeSpec`, and prints the resulting score delta + spend summary +
|
|
1323
|
+
* patch path. FR-003: `--budget-usd N` bounds a model-driven run by a
|
|
1324
|
+
* dollar ceiling (the orchestrator stops before a mutation call that
|
|
1325
|
+
* would exceed it); it composes with `--iterations` (first bound wins)
|
|
1326
|
+
* and is inert on rule-based runs (which make no model calls → $0). A
|
|
1327
|
+
* `TraceEventBus` is threaded into `optimizeSpec` so each call's
|
|
1328
|
+
* `cost_accrual` and the terminal aggregate spend summary land on the
|
|
1329
|
+
* standard observability bus on this real path (CREWHAUS_TRACE_COST=1
|
|
1330
|
+
* echoes them to stderr); the run-total $ also prints to stdout.
|
|
1331
|
+
*/
|
|
1332
|
+
async function runOptimize(args) {
|
|
1333
|
+
if (args.flags["help"]) {
|
|
1334
|
+
process.stdout.write("usage: crewhaus optimize <spec.yaml> --dataset <data> --graders <graders.yaml> " +
|
|
1335
|
+
"[--mutator rule-based|claude] [--iterations N] [--seed N] " +
|
|
1336
|
+
"[--improvement-threshold F] [--budget-usd N] [--write-back] [-o <out-dir>]\n");
|
|
1337
|
+
return;
|
|
1338
|
+
}
|
|
1339
|
+
const specPath = args.positional[0];
|
|
1340
|
+
if (typeof specPath !== "string")
|
|
1341
|
+
die("missing <spec.yaml>");
|
|
1342
|
+
const datasetPath = args.flags["dataset"];
|
|
1343
|
+
const gradersPath = args.flags["graders"];
|
|
1344
|
+
if (typeof datasetPath !== "string")
|
|
1345
|
+
die("missing --dataset <data>");
|
|
1346
|
+
if (typeof gradersPath !== "string")
|
|
1347
|
+
die("missing --graders <graders.yaml>");
|
|
1348
|
+
const iterationsFlag = args.flags["iterations"];
|
|
1349
|
+
const seedFlag = args.flags["seed"];
|
|
1350
|
+
const thresholdFlag = args.flags["improvement-threshold"];
|
|
1351
|
+
const iterations = typeof iterationsFlag === "string" ? Number.parseInt(iterationsFlag, 10) : 10;
|
|
1352
|
+
const seed = typeof seedFlag === "string" ? Number.parseInt(seedFlag, 10) : 0xcafe;
|
|
1353
|
+
const improvementThreshold = typeof thresholdFlag === "string" ? Number.parseFloat(thresholdFlag) : 0.01;
|
|
1354
|
+
if (Number.isNaN(iterations) || iterations < 1) {
|
|
1355
|
+
die(`invalid --iterations "${iterationsFlag}" — must be positive integer`);
|
|
1356
|
+
}
|
|
1357
|
+
// FR-003 — optional dollar budget for model-driven runs. Omit → today's
|
|
1358
|
+
// behaviour (iterations cap only). On a rule-based run the gate is inert
|
|
1359
|
+
// (no model calls → $0), so passing it is harmless.
|
|
1360
|
+
const budgetFlag = args.flags["budget-usd"];
|
|
1361
|
+
let budgetUsd;
|
|
1362
|
+
if (typeof budgetFlag === "string") {
|
|
1363
|
+
budgetUsd = Number.parseFloat(budgetFlag);
|
|
1364
|
+
if (Number.isNaN(budgetUsd) || budgetUsd <= 0) {
|
|
1365
|
+
die(`invalid --budget-usd "${budgetFlag}" — must be a positive number`);
|
|
1366
|
+
}
|
|
1367
|
+
}
|
|
1368
|
+
const writeBack = args.flags["write-back"] === true;
|
|
1369
|
+
const outDirArg = args.flags["out"];
|
|
1370
|
+
const absSpec = resolve(specPath);
|
|
1371
|
+
const runId = `opt_${Date.now().toString(16)}`;
|
|
1372
|
+
const outDir = typeof outDirArg === "string"
|
|
1373
|
+
? resolve(outDirArg)
|
|
1374
|
+
: resolve(join(".crewhaus", "optimize", runId));
|
|
1375
|
+
let gradersYaml;
|
|
1376
|
+
try {
|
|
1377
|
+
gradersYaml = readFileSync(resolve(gradersPath), "utf-8");
|
|
1378
|
+
}
|
|
1379
|
+
catch (err) {
|
|
1380
|
+
die(`could not read ${gradersPath}: ${err.message}`);
|
|
1381
|
+
}
|
|
1382
|
+
const { compiled } = parseGradersConfig(gradersYaml);
|
|
1383
|
+
const dataset = await loadDataset(resolve(datasetPath));
|
|
1384
|
+
// Materialize the dataset once — we'll re-iterate per fitness call.
|
|
1385
|
+
const samples = [];
|
|
1386
|
+
for await (const s of dataset.samples) {
|
|
1387
|
+
samples.push({
|
|
1388
|
+
id: s.id,
|
|
1389
|
+
input: s.input,
|
|
1390
|
+
...(s.expected_output !== undefined ? { expected_output: s.expected_output } : {}),
|
|
1391
|
+
});
|
|
1392
|
+
}
|
|
1393
|
+
if (samples.length === 0)
|
|
1394
|
+
die(`dataset "${dataset.name}" yielded zero samples`);
|
|
1395
|
+
// Train/dev split: 70/30 deterministic split by sample id ordering.
|
|
1396
|
+
const splitIdx = Math.max(1, Math.floor(samples.length * 0.7));
|
|
1397
|
+
const trainSet = samples.slice(0, splitIdx);
|
|
1398
|
+
const devSet = samples.slice(splitIdx);
|
|
1399
|
+
if (devSet.length === 0) {
|
|
1400
|
+
die(`dataset has ${samples.length} samples — need at least 2 (70/30 split needs a dev split)`);
|
|
1401
|
+
}
|
|
1402
|
+
// Fitness fn: patch the spec with the candidate prompt, lower to IR,
|
|
1403
|
+
// run eval-runner, return passRate. Each call is one full eval pass.
|
|
1404
|
+
const fitness = async (prompt) => {
|
|
1405
|
+
const yamlText = readFileSync(absSpec, "utf-8");
|
|
1406
|
+
// Re-parse to capture spec.target without depending on the
|
|
1407
|
+
// orchestrator's extractCurrentPrompt internals.
|
|
1408
|
+
const parsedTarget = parseSpec(yamlText).target;
|
|
1409
|
+
// Build a patch and apply it in-memory (no disk write — fitness is
|
|
1410
|
+
// pure with respect to the source file).
|
|
1411
|
+
const { applySpecPatch } = await import("@crewhaus/spec-patch");
|
|
1412
|
+
const { yaml: patchedYaml } = applySpecPatch(yamlText, {
|
|
1413
|
+
target: parsedTarget,
|
|
1414
|
+
path: ["agent", "instructions"],
|
|
1415
|
+
op: "replace",
|
|
1416
|
+
value: prompt,
|
|
1417
|
+
});
|
|
1418
|
+
let ir;
|
|
1419
|
+
try {
|
|
1420
|
+
ir = lower(parseSpec(patchedYaml));
|
|
1421
|
+
}
|
|
1422
|
+
catch (err) {
|
|
1423
|
+
if (err instanceof SpecParseError) {
|
|
1424
|
+
process.stderr.write("[optimize] candidate compiled invalid spec, skipping\n");
|
|
1425
|
+
return 0;
|
|
1426
|
+
}
|
|
1427
|
+
throw err;
|
|
1428
|
+
}
|
|
1429
|
+
if (ir.target !== "cli") {
|
|
1430
|
+
die(`crewhaus optimize v0 only supports target: cli (got "${ir.target}")`);
|
|
1431
|
+
}
|
|
1432
|
+
const summary = await runEvalLib({
|
|
1433
|
+
ir,
|
|
1434
|
+
dataset: { name: dataset.name, samples: makeAsyncIterable(devSet) },
|
|
1435
|
+
compiledGraders: compiled,
|
|
1436
|
+
opts: {
|
|
1437
|
+
outDir: join(outDir, "evals", `${prompt.length}_${ir.agent.instructions.length}`),
|
|
1438
|
+
concurrency: 4,
|
|
1439
|
+
seed,
|
|
1440
|
+
},
|
|
1441
|
+
});
|
|
1442
|
+
return summary.aggregates.passRate;
|
|
1443
|
+
};
|
|
1444
|
+
const mutator = args.flags["mutator"];
|
|
1445
|
+
let mutatorImpl;
|
|
1446
|
+
if (mutator === "claude") {
|
|
1447
|
+
const { createClaudeMutationProvider } = await import("@crewhaus/prompt-optimizer-claude");
|
|
1448
|
+
const { resolveModel } = await import("@crewhaus/model-router");
|
|
1449
|
+
const ir = lower(parseSpec(readFileSync(absSpec, "utf-8")));
|
|
1450
|
+
const mutatorModel = ir.target === "cli" ? ir.agent.model : "claude-sonnet-4-5";
|
|
1451
|
+
// Resolve via the model-router so non-Anthropic specs drive their own
|
|
1452
|
+
// provider: the resolved adapter + STRIPPED wire modelId replace the old
|
|
1453
|
+
// hardcoded createAnthropicAdapter() + verbatim prefixed string (which
|
|
1454
|
+
// made `--mutator claude` a silent no-op for openai/gemini/bedrock/local
|
|
1455
|
+
// specs — every mutation call failed and the provider fell back).
|
|
1456
|
+
const resolution = await resolveModel(mutatorModel);
|
|
1457
|
+
mutatorImpl = createClaudeMutationProvider({
|
|
1458
|
+
adapter: resolution.adapter,
|
|
1459
|
+
model: resolution.modelId,
|
|
1460
|
+
});
|
|
1461
|
+
}
|
|
1462
|
+
else if (mutator !== undefined && mutator !== "rule-based") {
|
|
1463
|
+
die(`unknown --mutator "${mutator}" — supported: rule-based, claude`);
|
|
1464
|
+
}
|
|
1465
|
+
process.stdout.write(`[optimize] runId=${runId} spec=${specPath} dataset=${dataset.name} ` +
|
|
1466
|
+
`(${trainSet.length} train / ${devSet.length} dev) iterations=${iterations} ` +
|
|
1467
|
+
`mutator=${mutator ?? "rule-based"}\n`);
|
|
1468
|
+
// FR-003 — thread a real trace bus into the optimize run so the per-call
|
|
1469
|
+
// `cost_accrual` events AND the terminal aggregate summary land on the
|
|
1470
|
+
// standard observability bus on the actual `crewhaus optimize` path — not
|
|
1471
|
+
// only when a unit test injects a bus. The spend total still prints to
|
|
1472
|
+
// stdout below (unchanged default UX); set CREWHAUS_TRACE_COST=1 to also
|
|
1473
|
+
// echo each bus cost event to stderr for live observability.
|
|
1474
|
+
const traceBus = new TraceEventBus({ runId, sessionId: runId });
|
|
1475
|
+
if (process.env["CREWHAUS_TRACE_COST"] === "1") {
|
|
1476
|
+
traceBus.subscribe((e) => {
|
|
1477
|
+
if (e.kind !== "cost_accrual")
|
|
1478
|
+
return;
|
|
1479
|
+
const ev = e;
|
|
1480
|
+
const label = ev.summary === true ? "cost-total" : "cost-call";
|
|
1481
|
+
process.stderr.write(`[optimize] ${label} provider=${ev.provider} model=${ev.modelId} ` +
|
|
1482
|
+
`in=${ev.inputTokens} out=${ev.outputTokens} micros=${ev.costUsdMicros}\n`);
|
|
1483
|
+
});
|
|
1484
|
+
}
|
|
1485
|
+
const result = await optimizeSpec({
|
|
1486
|
+
specPath: absSpec,
|
|
1487
|
+
fitness,
|
|
1488
|
+
trainSet,
|
|
1489
|
+
devSet,
|
|
1490
|
+
iterations,
|
|
1491
|
+
seed,
|
|
1492
|
+
improvementThreshold,
|
|
1493
|
+
outDir,
|
|
1494
|
+
writeBack,
|
|
1495
|
+
runId,
|
|
1496
|
+
traceBus,
|
|
1497
|
+
...(mutatorImpl !== undefined ? { mutator: mutatorImpl } : {}),
|
|
1498
|
+
...(budgetUsd !== undefined ? { budgetUsd } : {}),
|
|
1499
|
+
});
|
|
1500
|
+
process.stdout.write(`[optimize] score: ${result.scoreBefore.toFixed(3)} → ${result.scoreAfter.toFixed(3)} ` +
|
|
1501
|
+
`(Δ ${result.improvement >= 0 ? "+" : ""}${result.improvement.toFixed(3)})\n`);
|
|
1502
|
+
// FR-003 — spend summary: total $ + model-call count, and whether the
|
|
1503
|
+
// dollar budget (not the iterations cap) ended the run.
|
|
1504
|
+
const spendStop = result.stoppedReason === "budget-reached"
|
|
1505
|
+
? ` (stopped: budget reached, $${budgetUsd?.toFixed(2)} cap)\n`
|
|
1506
|
+
: "\n";
|
|
1507
|
+
process.stdout.write(`[optimize] spend: ${result.spend.totalUsd} over ${result.spend.perIteration.length} model call(s)${spendStop}`);
|
|
1508
|
+
process.stdout.write(`[optimize] patch: ${join(result.outDir, "patch.json")}\n`);
|
|
1509
|
+
if (result.applied) {
|
|
1510
|
+
if (result.writtenTo) {
|
|
1511
|
+
process.stdout.write(`[optimize] wrote patched YAML to ${result.writtenTo}\n`);
|
|
1512
|
+
}
|
|
1513
|
+
else {
|
|
1514
|
+
process.stdout.write(`[optimize] patch ready (improvement ≥ ${improvementThreshold}). Re-run with --write-back to apply.\n`);
|
|
1515
|
+
}
|
|
1516
|
+
}
|
|
1517
|
+
else {
|
|
1518
|
+
process.stdout.write(`[optimize] no improvement above threshold ${improvementThreshold}; source untouched.\n`);
|
|
1519
|
+
}
|
|
1520
|
+
}
|
|
1521
|
+
function makeAsyncIterable(items) {
|
|
1522
|
+
return {
|
|
1523
|
+
async *[Symbol.asyncIterator]() {
|
|
1524
|
+
for (const item of items)
|
|
1525
|
+
yield item;
|
|
1526
|
+
},
|
|
1527
|
+
};
|
|
1528
|
+
}
|
|
1529
|
+
async function runEvalSubcommand(args) {
|
|
1530
|
+
if (args.flags["help"]) {
|
|
1531
|
+
process.stdout.write("usage: crewhaus eval <spec.yaml> --dataset <data> --graders <graders.yaml> " +
|
|
1532
|
+
"[--judge-model <model>] [--concurrency N] [--seed N] -o <out-dir>\n" +
|
|
1533
|
+
" --judge-model accepts the full router grammar (claude-*, openai/<m>, gemini/<m>,\n" +
|
|
1534
|
+
" bedrock/<id>, local/<m>@<url>); the default judge claude-sonnet-4-5 requires\n" +
|
|
1535
|
+
" Anthropic credentials (ANTHROPIC_AUTH_TOKEN or ANTHROPIC_API_KEY)\n");
|
|
1536
|
+
return;
|
|
1537
|
+
}
|
|
1538
|
+
const specPath = args.positional[0];
|
|
1539
|
+
if (typeof specPath !== "string")
|
|
1540
|
+
die("missing <spec.yaml>");
|
|
1541
|
+
const datasetPath = args.flags["dataset"];
|
|
1542
|
+
const gradersPath = args.flags["graders"];
|
|
1543
|
+
const outDirArg = args.flags["out"];
|
|
1544
|
+
if (typeof datasetPath !== "string")
|
|
1545
|
+
die("missing --dataset <data>");
|
|
1546
|
+
if (typeof gradersPath !== "string")
|
|
1547
|
+
die("missing --graders <graders.yaml>");
|
|
1548
|
+
if (typeof outDirArg !== "string")
|
|
1549
|
+
die("missing -o <out-dir>");
|
|
1550
|
+
const concurrencyFlag = args.flags["concurrency"];
|
|
1551
|
+
const seedFlag = args.flags["seed"];
|
|
1552
|
+
const judgeModelFlag = args.flags["judge-model"];
|
|
1553
|
+
const concurrency = typeof concurrencyFlag === "string" ? Number.parseInt(concurrencyFlag, 10) : undefined;
|
|
1554
|
+
const seed = typeof seedFlag === "string" ? Number.parseInt(seedFlag, 10) : undefined;
|
|
1555
|
+
if (concurrency !== undefined && (Number.isNaN(concurrency) || concurrency < 1)) {
|
|
1556
|
+
die(`invalid --concurrency "${concurrencyFlag}" — must be positive integer`);
|
|
1557
|
+
}
|
|
1558
|
+
if (seed !== undefined && Number.isNaN(seed)) {
|
|
1559
|
+
die(`invalid --seed "${seedFlag}" — must be integer`);
|
|
1560
|
+
}
|
|
1561
|
+
const absSpec = resolve(specPath);
|
|
1562
|
+
let yamlText;
|
|
1563
|
+
try {
|
|
1564
|
+
yamlText = readFileSync(absSpec, "utf-8");
|
|
1565
|
+
}
|
|
1566
|
+
catch (err) {
|
|
1567
|
+
die(`could not read ${absSpec}: ${err.message}`);
|
|
1568
|
+
}
|
|
1569
|
+
let ir;
|
|
1570
|
+
try {
|
|
1571
|
+
ir = lower(parseSpec(yamlText));
|
|
1572
|
+
}
|
|
1573
|
+
catch (err) {
|
|
1574
|
+
if (err instanceof SpecParseError)
|
|
1575
|
+
die(err.message);
|
|
1576
|
+
throw err;
|
|
1577
|
+
}
|
|
1578
|
+
if (ir.target !== "cli") {
|
|
1579
|
+
die(`crewhaus eval only supports target: cli (got "${ir.target}")`);
|
|
1580
|
+
}
|
|
1581
|
+
let gradersYaml;
|
|
1582
|
+
try {
|
|
1583
|
+
gradersYaml = readFileSync(resolve(gradersPath), "utf-8");
|
|
1584
|
+
}
|
|
1585
|
+
catch (err) {
|
|
1586
|
+
die(`could not read ${gradersPath}: ${err.message}`);
|
|
1587
|
+
}
|
|
1588
|
+
const { compiled } = parseGradersConfig(gradersYaml);
|
|
1589
|
+
const dataset = await loadDataset(resolve(datasetPath));
|
|
1590
|
+
const absOut = resolve(outDirArg);
|
|
1591
|
+
process.stdout.write(`[eval] running ${dataset.name}: ${compiled.length} graders → ${absOut}\n`);
|
|
1592
|
+
const summary = await runEvalLib({
|
|
1593
|
+
ir,
|
|
1594
|
+
dataset,
|
|
1595
|
+
compiledGraders: compiled,
|
|
1596
|
+
opts: {
|
|
1597
|
+
outDir: absOut,
|
|
1598
|
+
...(concurrency !== undefined ? { concurrency } : {}),
|
|
1599
|
+
...(seed !== undefined ? { seed } : {}),
|
|
1600
|
+
...(typeof judgeModelFlag === "string" ? { judgeModel: judgeModelFlag } : {}),
|
|
1601
|
+
},
|
|
1602
|
+
});
|
|
1603
|
+
// Render report
|
|
1604
|
+
const loaded = await loadRun(absOut);
|
|
1605
|
+
const rendered = renderReport(loaded);
|
|
1606
|
+
writeFileSync(join(absOut, "index.html"), rendered.html);
|
|
1607
|
+
process.stdout.write(`[eval] runId=${summary.runId} pass_rate=${(summary.aggregates.passRate * 100).toFixed(1)}% ` +
|
|
1608
|
+
`mean_score=${summary.aggregates.meanScore.toFixed(3)} ` +
|
|
1609
|
+
`errors=${summary.aggregates.errorCount} ` +
|
|
1610
|
+
`tokens=${summary.aggregates.totalTokens.input}/${summary.aggregates.totalTokens.output}\n`);
|
|
1611
|
+
process.stdout.write(`[eval] report: ${join(absOut, "index.html")}\n`);
|
|
1612
|
+
}
|
|
1613
|
+
async function runEvalReport(args) {
|
|
1614
|
+
if (args.flags["help"]) {
|
|
1615
|
+
process.stdout.write("usage: crewhaus eval-report diff <prev> <new> [-o <out-dir>]\n");
|
|
1616
|
+
return;
|
|
1617
|
+
}
|
|
1618
|
+
const action = args.positional[0];
|
|
1619
|
+
if (action !== "diff")
|
|
1620
|
+
die(`eval-report: unknown action "${action ?? ""}" — supported: diff`);
|
|
1621
|
+
const prev = args.positional[1];
|
|
1622
|
+
const next = args.positional[2];
|
|
1623
|
+
if (typeof prev !== "string" || typeof next !== "string") {
|
|
1624
|
+
die("eval-report diff: missing <prev> <new>");
|
|
1625
|
+
}
|
|
1626
|
+
const outArg = args.flags["out"];
|
|
1627
|
+
const prevLoaded = await loadRun(prev);
|
|
1628
|
+
const nextLoaded = await loadRun(next);
|
|
1629
|
+
const result = diffReports(prevLoaded, nextLoaded);
|
|
1630
|
+
if (typeof outArg === "string") {
|
|
1631
|
+
const absOut = resolve(outArg);
|
|
1632
|
+
mkdirSync(absOut, { recursive: true });
|
|
1633
|
+
writeFileSync(join(absOut, "index.html"), result.html);
|
|
1634
|
+
writeFileSync(join(absOut, "diff.json"), result.json);
|
|
1635
|
+
process.stdout.write(`[eval-report] diff: ${join(absOut, "index.html")}\n`);
|
|
1636
|
+
}
|
|
1637
|
+
else {
|
|
1638
|
+
process.stdout.write(result.html);
|
|
1639
|
+
}
|
|
1640
|
+
process.stdout.write(`[eval-report] regressions=${result.diff.regressions.length} ` +
|
|
1641
|
+
`recoveries=${result.diff.recoveries.length} ` +
|
|
1642
|
+
`score_shifts=${result.diff.scoreShifts.length} ` +
|
|
1643
|
+
`unchanged=${result.diff.unchanged}\n`);
|
|
1644
|
+
}
|
|
1645
|
+
/**
|
|
1646
|
+
* Section 27 — `crewhaus cost-summary [--session <id>] [--tenant <id>]
|
|
1647
|
+
* [--format json|text]`. Reads `cost_accrual` events out of an `event-log`
|
|
1648
|
+
* (or aggregates the per-day audit-log records) and prints a USD summary.
|
|
1649
|
+
*
|
|
1650
|
+
* v0 ships with the per-session readout — pass `--session <id>` to read
|
|
1651
|
+
* the JSONL transcript at `.crewhaus/sessions/<id>.jsonl` and aggregate
|
|
1652
|
+
* the cost_accrual events embedded in there. Tenant aggregation lands in
|
|
1653
|
+
* §31's studio-server cost dashboard.
|
|
1654
|
+
*/
|
|
1655
|
+
async function runCostSummary(args) {
|
|
1656
|
+
if (args.flags["help"]) {
|
|
1657
|
+
process.stdout.write("usage: crewhaus cost-summary --session <id> [--format json|text]\n");
|
|
1658
|
+
return;
|
|
1659
|
+
}
|
|
1660
|
+
const session = args.flags["session"];
|
|
1661
|
+
const format = args.flags["format"] ?? "text";
|
|
1662
|
+
if (typeof session !== "string")
|
|
1663
|
+
die("missing --session <id>");
|
|
1664
|
+
const sessionFile = join(process.cwd(), ".crewhaus", "sessions", `${session}.jsonl`);
|
|
1665
|
+
if (!existsSync(sessionFile)) {
|
|
1666
|
+
die(`session log not found at ${sessionFile}`);
|
|
1667
|
+
}
|
|
1668
|
+
const lines = readFileSync(sessionFile, "utf-8")
|
|
1669
|
+
.split("\n")
|
|
1670
|
+
.filter((l) => l !== "");
|
|
1671
|
+
let totalMicros = 0;
|
|
1672
|
+
const byProvider = {};
|
|
1673
|
+
let count = 0;
|
|
1674
|
+
for (const raw of lines) {
|
|
1675
|
+
let parsed;
|
|
1676
|
+
try {
|
|
1677
|
+
parsed = JSON.parse(raw);
|
|
1678
|
+
}
|
|
1679
|
+
catch {
|
|
1680
|
+
continue;
|
|
1681
|
+
}
|
|
1682
|
+
if (parsed &&
|
|
1683
|
+
typeof parsed === "object" &&
|
|
1684
|
+
"kind" in parsed &&
|
|
1685
|
+
parsed.kind === "cost_accrual") {
|
|
1686
|
+
// event-log writes a `{ ts, version, kind, payload }` envelope, so the
|
|
1687
|
+
// cost fields live under `payload`. Fall back to top-level fields so a
|
|
1688
|
+
// hand-written/flat cost_accrual line still aggregates.
|
|
1689
|
+
const e = parsed;
|
|
1690
|
+
const provider = e.payload?.provider ?? e.provider;
|
|
1691
|
+
const micros = e.payload?.costUsdMicros ?? e.costUsdMicros;
|
|
1692
|
+
if (typeof provider === "string" && typeof micros === "number") {
|
|
1693
|
+
totalMicros += micros;
|
|
1694
|
+
byProvider[provider] = (byProvider[provider] ?? 0) + micros;
|
|
1695
|
+
count++;
|
|
1696
|
+
}
|
|
1697
|
+
}
|
|
1698
|
+
}
|
|
1699
|
+
const totalDollars = totalMicros / 1_000_000;
|
|
1700
|
+
if (format === "json") {
|
|
1701
|
+
process.stdout.write(`${JSON.stringify({ session, count, totalUsdMicros: totalMicros, byProvider })}\n`);
|
|
1702
|
+
}
|
|
1703
|
+
else {
|
|
1704
|
+
process.stdout.write(`session: ${session}\n`);
|
|
1705
|
+
process.stdout.write(`accrual events: ${count}\n`);
|
|
1706
|
+
process.stdout.write(`total: $${totalDollars.toFixed(4)}\n`);
|
|
1707
|
+
for (const [p, m] of Object.entries(byProvider)) {
|
|
1708
|
+
process.stdout.write(` ${p}: $${(m / 1_000_000).toFixed(4)}\n`);
|
|
1709
|
+
}
|
|
1710
|
+
}
|
|
1711
|
+
}
|
|
1712
|
+
/**
|
|
1713
|
+
* Section 27 — `crewhaus secrets <action> <name> [opts]`. Two actions:
|
|
1714
|
+
* doctor list configured secrets and report missing
|
|
1715
|
+
* rotate <name> rotate the named secret (file backend)
|
|
1716
|
+
*/
|
|
1717
|
+
async function runSecrets(args, action) {
|
|
1718
|
+
if (args.flags["help"]) {
|
|
1719
|
+
process.stdout.write("usage:\n" +
|
|
1720
|
+
" crewhaus secrets doctor [--backend env-var|file] [--root-dir <dir>]\n" +
|
|
1721
|
+
" crewhaus secrets rotate <name> [--value <new-value>] [--backend ...]\n");
|
|
1722
|
+
return;
|
|
1723
|
+
}
|
|
1724
|
+
const backendIdFlag = args.flags["backend"];
|
|
1725
|
+
const backendId = typeof backendIdFlag === "string" ? backendIdFlag : "env-var";
|
|
1726
|
+
const rootDirFlag = args.flags["root-dir"];
|
|
1727
|
+
const rootDir = typeof rootDirFlag === "string" ? rootDirFlag : join(process.cwd(), ".crewhaus", "secrets");
|
|
1728
|
+
const { createSecrets, createEnvVarBackend, createFileBackend } = await import("@crewhaus/secrets-manager");
|
|
1729
|
+
// The `vault` backend exists in @crewhaus/secrets-manager but is not wired
|
|
1730
|
+
// into the CLI (it needs an address/auth story the CLI does not yet expose).
|
|
1731
|
+
// Fail loudly rather than silently degrading a security-sensitive flag to the
|
|
1732
|
+
// env-var backend — a silent fallback would, e.g., make `secrets doctor`
|
|
1733
|
+
// cheerfully list process env var names as if they were Vault secrets.
|
|
1734
|
+
let backend;
|
|
1735
|
+
if (backendId === "env-var") {
|
|
1736
|
+
backend = createEnvVarBackend();
|
|
1737
|
+
}
|
|
1738
|
+
else if (backendId === "file") {
|
|
1739
|
+
backend = createFileBackend({ rootDir });
|
|
1740
|
+
}
|
|
1741
|
+
else if (backendId === "vault") {
|
|
1742
|
+
die("vault backend is not wired into the CLI in this build — construct it programmatically via createVaultBackend(), or use --backend env-var|file");
|
|
1743
|
+
}
|
|
1744
|
+
else {
|
|
1745
|
+
die(`unknown secrets backend "${backendId}" (expected: env-var | file)`);
|
|
1746
|
+
}
|
|
1747
|
+
const secrets = createSecrets({ backend });
|
|
1748
|
+
if (action === "doctor") {
|
|
1749
|
+
const known = (await backend.list?.()) ?? [];
|
|
1750
|
+
process.stdout.write(`backend: ${backend.id}\n`);
|
|
1751
|
+
process.stdout.write(`known: ${known.length} secret(s)\n`);
|
|
1752
|
+
for (const n of known)
|
|
1753
|
+
process.stdout.write(` - ${n}\n`);
|
|
1754
|
+
return;
|
|
1755
|
+
}
|
|
1756
|
+
if (action === "rotate") {
|
|
1757
|
+
const name = args.positional[0];
|
|
1758
|
+
if (typeof name !== "string")
|
|
1759
|
+
die("missing <name>");
|
|
1760
|
+
const value = args.flags["value"];
|
|
1761
|
+
const newValue = await secrets.rotate(name, {
|
|
1762
|
+
...(typeof value === "string" ? { newValue: value } : {}),
|
|
1763
|
+
});
|
|
1764
|
+
process.stdout.write(`rotated ${name} (${newValue.length} chars) via ${backend.id} backend\n`);
|
|
1765
|
+
return;
|
|
1766
|
+
}
|
|
1767
|
+
die(`unknown secrets action "${action}" (expected: doctor | rotate)`);
|
|
1768
|
+
}
|
|
1769
|
+
/**
|
|
1770
|
+
* Section 28 — `crewhaus spec <action> ...` subcommands wrap the
|
|
1771
|
+
* spec-registry. Actions: put / get / list / pin / alias.
|
|
1772
|
+
*/
|
|
1773
|
+
async function runSpec(args, action) {
|
|
1774
|
+
if (args.flags["help"]) {
|
|
1775
|
+
process.stdout.write("usage:\n" +
|
|
1776
|
+
" crewhaus spec put <name> <version> <spec.yaml> [--root-dir <dir>]\n" +
|
|
1777
|
+
" crewhaus spec list <name> list versions\n" +
|
|
1778
|
+
" crewhaus spec get <name> <version> print yaml\n" +
|
|
1779
|
+
" crewhaus spec pin <name> <env> <version> [--tenant <id>] pin env → version\n" +
|
|
1780
|
+
" crewhaus spec alias <name> <env> [--tenant <id>] resolve env → version\n");
|
|
1781
|
+
return;
|
|
1782
|
+
}
|
|
1783
|
+
const rootDirFlag = args.flags["root-dir"];
|
|
1784
|
+
const rootDir = typeof rootDirFlag === "string" ? rootDirFlag : join(process.cwd(), ".crewhaus", "specs");
|
|
1785
|
+
const { createFileBackedRegistry } = await import("@crewhaus/spec-registry");
|
|
1786
|
+
const reg = createFileBackedRegistry({ rootDir });
|
|
1787
|
+
const tenantFlag = args.flags["tenant"];
|
|
1788
|
+
const tenantId = typeof tenantFlag === "string" ? tenantFlag : undefined;
|
|
1789
|
+
if (action === "put") {
|
|
1790
|
+
const name = args.positional[0];
|
|
1791
|
+
const version = args.positional[1];
|
|
1792
|
+
const filePath = args.positional[2];
|
|
1793
|
+
if (typeof name !== "string")
|
|
1794
|
+
die("missing <name>");
|
|
1795
|
+
if (typeof version !== "string")
|
|
1796
|
+
die("missing <version>");
|
|
1797
|
+
if (typeof filePath !== "string")
|
|
1798
|
+
die("missing <spec.yaml>");
|
|
1799
|
+
const yaml = readFileSync(resolve(filePath), "utf-8");
|
|
1800
|
+
await reg.put(name, version, yaml);
|
|
1801
|
+
process.stdout.write(`stored ${name}@${version} (${yaml.length} bytes)\n`);
|
|
1802
|
+
return;
|
|
1803
|
+
}
|
|
1804
|
+
if (action === "list") {
|
|
1805
|
+
const name = args.positional[0];
|
|
1806
|
+
if (typeof name !== "string")
|
|
1807
|
+
die("missing <name>");
|
|
1808
|
+
const versions = await reg.list(name);
|
|
1809
|
+
for (const v of versions)
|
|
1810
|
+
process.stdout.write(`${v}\n`);
|
|
1811
|
+
return;
|
|
1812
|
+
}
|
|
1813
|
+
if (action === "get") {
|
|
1814
|
+
const name = args.positional[0];
|
|
1815
|
+
const version = args.positional[1];
|
|
1816
|
+
if (typeof name !== "string")
|
|
1817
|
+
die("missing <name>");
|
|
1818
|
+
if (typeof version !== "string")
|
|
1819
|
+
die("missing <version>");
|
|
1820
|
+
process.stdout.write(await reg.get(name, version));
|
|
1821
|
+
return;
|
|
1822
|
+
}
|
|
1823
|
+
if (action === "pin") {
|
|
1824
|
+
const name = args.positional[0];
|
|
1825
|
+
const env = args.positional[1];
|
|
1826
|
+
const version = args.positional[2];
|
|
1827
|
+
if (typeof name !== "string")
|
|
1828
|
+
die("missing <name>");
|
|
1829
|
+
if (typeof env !== "string")
|
|
1830
|
+
die("missing <env>");
|
|
1831
|
+
if (typeof version !== "string")
|
|
1832
|
+
die("missing <version>");
|
|
1833
|
+
if (tenantId !== undefined) {
|
|
1834
|
+
await reg.pinForTenant(tenantId, name, env, version);
|
|
1835
|
+
process.stdout.write(`pinned tenant=${tenantId} ${name} ${env} → ${version}\n`);
|
|
1836
|
+
}
|
|
1837
|
+
else {
|
|
1838
|
+
await reg.pin(name, env, version);
|
|
1839
|
+
process.stdout.write(`pinned ${name} ${env} → ${version}\n`);
|
|
1840
|
+
}
|
|
1841
|
+
return;
|
|
1842
|
+
}
|
|
1843
|
+
if (action === "alias") {
|
|
1844
|
+
const name = args.positional[0];
|
|
1845
|
+
const env = args.positional[1];
|
|
1846
|
+
if (typeof name !== "string")
|
|
1847
|
+
die("missing <name>");
|
|
1848
|
+
if (typeof env !== "string")
|
|
1849
|
+
die("missing <env>");
|
|
1850
|
+
const v = tenantId !== undefined
|
|
1851
|
+
? await reg.aliasForTenant(tenantId, name, env)
|
|
1852
|
+
: await reg.aliasFor(name, env);
|
|
1853
|
+
if (!v)
|
|
1854
|
+
die(`no pin for ${name} ${env}`);
|
|
1855
|
+
process.stdout.write(`${v}\n`);
|
|
1856
|
+
return;
|
|
1857
|
+
}
|
|
1858
|
+
die(`unknown spec action "${action}" (expected: put | list | get | pin | alias)`);
|
|
1859
|
+
}
|
|
1860
|
+
/**
|
|
1861
|
+
* Section 28 — `crewhaus deploy <action> ...` subcommands wrap the
|
|
1862
|
+
* deployment-controller. Actions: promote / rollback.
|
|
1863
|
+
*/
|
|
1864
|
+
async function runDeploy(args, action) {
|
|
1865
|
+
if (args.flags["help"]) {
|
|
1866
|
+
process.stdout.write("usage:\n" +
|
|
1867
|
+
" crewhaus deploy promote <name> <fromEnv> <toEnv> copy env pin\n" +
|
|
1868
|
+
" crewhaus deploy rollback <name> <env> <version> re-pin env to version\n");
|
|
1869
|
+
return;
|
|
1870
|
+
}
|
|
1871
|
+
const rootDirFlag = args.flags["root-dir"];
|
|
1872
|
+
const rootDir = typeof rootDirFlag === "string" ? rootDirFlag : join(process.cwd(), ".crewhaus", "specs");
|
|
1873
|
+
const auditDir = join(process.cwd(), ".crewhaus", "audit");
|
|
1874
|
+
const { createFileBackedRegistry } = await import("@crewhaus/spec-registry");
|
|
1875
|
+
const { openAuditLog } = await import("@crewhaus/audit-log");
|
|
1876
|
+
const { createDeploymentController } = await import("@crewhaus/deployment-controller");
|
|
1877
|
+
const reg = createFileBackedRegistry({ rootDir });
|
|
1878
|
+
const audit = await openAuditLog({ rootDir: auditDir });
|
|
1879
|
+
const tenantFlag = args.flags["tenant"];
|
|
1880
|
+
const actorFlag = args.flags["actor"];
|
|
1881
|
+
const tenantId = typeof tenantFlag === "string" ? tenantFlag : undefined;
|
|
1882
|
+
const actor = typeof actorFlag === "string" ? actorFlag : undefined;
|
|
1883
|
+
const ctrl = createDeploymentController({
|
|
1884
|
+
registry: reg,
|
|
1885
|
+
auditLog: audit,
|
|
1886
|
+
...(tenantId !== undefined ? { tenantId } : {}),
|
|
1887
|
+
...(actor !== undefined ? { actor } : {}),
|
|
1888
|
+
});
|
|
1889
|
+
if (action === "promote") {
|
|
1890
|
+
const name = args.positional[0];
|
|
1891
|
+
const fromEnv = args.positional[1];
|
|
1892
|
+
const toEnv = args.positional[2];
|
|
1893
|
+
if (typeof name !== "string")
|
|
1894
|
+
die("missing <name>");
|
|
1895
|
+
if (typeof fromEnv !== "string")
|
|
1896
|
+
die("missing <fromEnv>");
|
|
1897
|
+
if (typeof toEnv !== "string")
|
|
1898
|
+
die("missing <toEnv>");
|
|
1899
|
+
const rec = await ctrl.promote(name, fromEnv, toEnv);
|
|
1900
|
+
process.stdout.write(`promoted ${name} ${fromEnv} → ${toEnv} (now pinned to ${rec.toVersion})\n`);
|
|
1901
|
+
return;
|
|
1902
|
+
}
|
|
1903
|
+
if (action === "rollback") {
|
|
1904
|
+
const name = args.positional[0];
|
|
1905
|
+
const env = args.positional[1];
|
|
1906
|
+
const version = args.positional[2];
|
|
1907
|
+
if (typeof name !== "string")
|
|
1908
|
+
die("missing <name>");
|
|
1909
|
+
if (typeof env !== "string")
|
|
1910
|
+
die("missing <env>");
|
|
1911
|
+
if (typeof version !== "string")
|
|
1912
|
+
die("missing <version>");
|
|
1913
|
+
const rec = await ctrl.rollback(name, env, version);
|
|
1914
|
+
process.stdout.write(`rolled back ${name} ${env} → ${version} (was ${rec.fromVersion ?? "unset"})\n`);
|
|
1915
|
+
return;
|
|
1916
|
+
}
|
|
1917
|
+
die(`unknown deploy action "${action}" (expected: promote | rollback)`);
|
|
1918
|
+
}
|
|
1919
|
+
/**
|
|
1920
|
+
* Section 28 — `crewhaus migrate-all --from <ver> --to <ver> [--dry-run]`.
|
|
1921
|
+
* Walks every spec in the registry and applies the migration chain.
|
|
1922
|
+
*/
|
|
1923
|
+
async function runMigrateAll(args) {
|
|
1924
|
+
if (args.flags["help"]) {
|
|
1925
|
+
process.stdout.write("usage: crewhaus migrate-all --from <ver> --to <ver> [--dry-run] [--root-dir <dir>]\n");
|
|
1926
|
+
return;
|
|
1927
|
+
}
|
|
1928
|
+
const fromFlag = args.flags["from"];
|
|
1929
|
+
const toFlag = args.flags["to"];
|
|
1930
|
+
if (typeof fromFlag !== "string")
|
|
1931
|
+
die("missing --from <ver>");
|
|
1932
|
+
if (typeof toFlag !== "string")
|
|
1933
|
+
die("missing --to <ver>");
|
|
1934
|
+
const fromVersion = Number.parseInt(fromFlag, 10);
|
|
1935
|
+
const toVersion = Number.parseInt(toFlag, 10);
|
|
1936
|
+
if (Number.isNaN(fromVersion) || Number.isNaN(toVersion)) {
|
|
1937
|
+
die("--from / --to must be integers");
|
|
1938
|
+
}
|
|
1939
|
+
const rootDirFlag = args.flags["root-dir"];
|
|
1940
|
+
const rootDir = typeof rootDirFlag === "string" ? rootDirFlag : join(process.cwd(), ".crewhaus", "specs");
|
|
1941
|
+
const dryRun = args.flags["dry-run"] === true;
|
|
1942
|
+
const { createFileBackedRegistry } = await import("@crewhaus/spec-registry");
|
|
1943
|
+
const { createDefaultEngine } = await import("@crewhaus/migration-engine");
|
|
1944
|
+
const { migrateAll } = await import("@crewhaus/migration-runner");
|
|
1945
|
+
const result = await migrateAll({
|
|
1946
|
+
registry: createFileBackedRegistry({ rootDir }),
|
|
1947
|
+
engine: createDefaultEngine(),
|
|
1948
|
+
fromVersion,
|
|
1949
|
+
toVersion,
|
|
1950
|
+
dryRun,
|
|
1951
|
+
});
|
|
1952
|
+
for (const item of result.plan) {
|
|
1953
|
+
const arrow = item.newVersion ? ` → ${item.newVersion}` : "";
|
|
1954
|
+
const err = item.error ? ` ERROR: ${item.error}` : "";
|
|
1955
|
+
process.stdout.write(`${item.action.padEnd(15)} ${item.name}@${item.latestVersion}${arrow}${err}\n`);
|
|
1956
|
+
}
|
|
1957
|
+
const dryNote = dryRun ? " (dry-run)" : "";
|
|
1958
|
+
process.stdout.write(`[migrate-all] migrated=${result.migrated} skipped=${result.skipped} failed=${result.failed}${dryNote}\n`);
|
|
1959
|
+
if (result.failed > 0)
|
|
1960
|
+
process.exit(1);
|
|
1961
|
+
}
|
|
1962
|
+
/**
|
|
1963
|
+
* Section 32 — `crewhaus build-image <target> --tag <tag> [--platform <p>] [--push]`.
|
|
1964
|
+
* Wraps `docker buildx build` for the per-target Dockerfiles in
|
|
1965
|
+
* @crewhaus/docker-images.
|
|
1966
|
+
*/
|
|
1967
|
+
async function runBuildImage(rest) {
|
|
1968
|
+
const positional = [];
|
|
1969
|
+
const flags = new Map();
|
|
1970
|
+
for (let i = 0; i < rest.length; i++) {
|
|
1971
|
+
const a = rest[i];
|
|
1972
|
+
if (a === undefined)
|
|
1973
|
+
continue;
|
|
1974
|
+
if (a === "--help" || a === "-h") {
|
|
1975
|
+
process.stdout.write("usage: crewhaus build-image <target> --tag <tag> [--platform <p>] [--push]\n");
|
|
1976
|
+
return;
|
|
1977
|
+
}
|
|
1978
|
+
if (a === "--push") {
|
|
1979
|
+
flags.set("push", true);
|
|
1980
|
+
continue;
|
|
1981
|
+
}
|
|
1982
|
+
if (a === "--tag" || a === "--platform") {
|
|
1983
|
+
const v = rest[i + 1];
|
|
1984
|
+
if (typeof v !== "string")
|
|
1985
|
+
die(`${a} requires a value`);
|
|
1986
|
+
flags.set(a.slice(2), v);
|
|
1987
|
+
i++;
|
|
1988
|
+
continue;
|
|
1989
|
+
}
|
|
1990
|
+
positional.push(a);
|
|
1991
|
+
}
|
|
1992
|
+
const target = positional[0];
|
|
1993
|
+
if (typeof target !== "string")
|
|
1994
|
+
die("missing <target> (one of cli, workflow, channel, ...)");
|
|
1995
|
+
const tag = flags.get("tag");
|
|
1996
|
+
if (typeof tag !== "string")
|
|
1997
|
+
die("missing --tag <tag>");
|
|
1998
|
+
const platform = flags.get("platform");
|
|
1999
|
+
const push = flags.get("push") === true;
|
|
2000
|
+
const { buildImage, isTargetShape } = await import("@crewhaus/docker-images");
|
|
2001
|
+
if (!isTargetShape(target)) {
|
|
2002
|
+
die(`unknown target shape: ${target}`);
|
|
2003
|
+
}
|
|
2004
|
+
try {
|
|
2005
|
+
const result = await buildImage({
|
|
2006
|
+
target,
|
|
2007
|
+
tag,
|
|
2008
|
+
platform: typeof platform === "string" ? platform : undefined,
|
|
2009
|
+
push,
|
|
2010
|
+
});
|
|
2011
|
+
process.stdout.write(`built crewhaus/${result.target}:${result.tag}\n`);
|
|
2012
|
+
}
|
|
2013
|
+
catch (err) {
|
|
2014
|
+
die(`build-image: ${err.message}`);
|
|
2015
|
+
}
|
|
2016
|
+
}
|
|
2017
|
+
/**
|
|
2018
|
+
* Section 32 — `crewhaus cloud deploy|teardown --provider <p> --region <r>`.
|
|
2019
|
+
* Composite recipe: terraform-up + helm-chart + kustomize overlay.
|
|
2020
|
+
*/
|
|
2021
|
+
async function runCloud(args, action) {
|
|
2022
|
+
if (args.flags["help"]) {
|
|
2023
|
+
process.stdout.write(`usage: crewhaus cloud ${action} --provider <aws|gcp|azure|aws-localstack> --region <r> [--tier <dev|default|production>] [--image-tag <tag>]\n`);
|
|
2024
|
+
return;
|
|
2025
|
+
}
|
|
2026
|
+
const provider = args.flags["provider"];
|
|
2027
|
+
const region = args.flags["region"];
|
|
2028
|
+
if (typeof provider !== "string")
|
|
2029
|
+
die("missing --provider");
|
|
2030
|
+
if (typeof region !== "string")
|
|
2031
|
+
die("missing --region");
|
|
2032
|
+
const tierFlag = args.flags["tier"];
|
|
2033
|
+
const imageTagFlag = args.flags["image-tag"];
|
|
2034
|
+
const workingDirFlag = args.flags["working-dir"];
|
|
2035
|
+
const cloudMod = await import("@crewhaus/crewhaus-cloud");
|
|
2036
|
+
if (!cloudMod.isCloudProvider(provider)) {
|
|
2037
|
+
die(`unknown provider: ${provider} (allowed: ${cloudMod.listProviders().join(", ")})`);
|
|
2038
|
+
}
|
|
2039
|
+
const config = {
|
|
2040
|
+
...cloudMod.defaultCloudConfig(provider, region),
|
|
2041
|
+
...(typeof tierFlag === "string" ? { tier: tierFlag } : {}),
|
|
2042
|
+
...(typeof imageTagFlag === "string" ? { imageTag: imageTagFlag } : {}),
|
|
2043
|
+
};
|
|
2044
|
+
if (action === "deploy") {
|
|
2045
|
+
const result = await cloudMod.deployCloud({
|
|
2046
|
+
config,
|
|
2047
|
+
workingDir: typeof workingDirFlag === "string" ? workingDirFlag : undefined,
|
|
2048
|
+
});
|
|
2049
|
+
process.stdout.write(`${cloudMod.summariseDeploy(result)}\n`);
|
|
2050
|
+
return;
|
|
2051
|
+
}
|
|
2052
|
+
if (action === "teardown") {
|
|
2053
|
+
await cloudMod.teardownCloud({
|
|
2054
|
+
config,
|
|
2055
|
+
workingDir: typeof workingDirFlag === "string" ? workingDirFlag : undefined,
|
|
2056
|
+
});
|
|
2057
|
+
process.stdout.write(`teardown complete for ${config.clusterName}\n`);
|
|
2058
|
+
return;
|
|
2059
|
+
}
|
|
2060
|
+
die(`unknown cloud action "${action}" (expected: deploy | teardown)`);
|
|
2061
|
+
}
|
|
2062
|
+
/**
|
|
2063
|
+
* Section 34 — `crewhaus federation discover <deployment> [--srv-domain <d>] [--format json|yaml]`.
|
|
2064
|
+
* Resolves a peer's endpoint + supportedShapes + publicKeyFingerprint via
|
|
2065
|
+
* .well-known/crewhaus.json, optionally seeded by a DNS SRV lookup.
|
|
2066
|
+
*/
|
|
2067
|
+
async function runFederation(rest) {
|
|
2068
|
+
const positional = [];
|
|
2069
|
+
const flags = new Map();
|
|
2070
|
+
for (let i = 0; i < rest.length; i++) {
|
|
2071
|
+
const a = rest[i];
|
|
2072
|
+
if (a === undefined)
|
|
2073
|
+
continue;
|
|
2074
|
+
if (a === "--help" || a === "-h") {
|
|
2075
|
+
process.stdout.write("usage: crewhaus federation discover <deployment> [--srv-domain <d>] [--format json|yaml]\n");
|
|
2076
|
+
return;
|
|
2077
|
+
}
|
|
2078
|
+
if (a === "--srv-domain" || a === "--format") {
|
|
2079
|
+
const v = rest[i + 1];
|
|
2080
|
+
if (typeof v !== "string")
|
|
2081
|
+
die(`${a} requires a value`);
|
|
2082
|
+
flags.set(a.slice(2), v);
|
|
2083
|
+
i++;
|
|
2084
|
+
continue;
|
|
2085
|
+
}
|
|
2086
|
+
positional.push(a);
|
|
2087
|
+
}
|
|
2088
|
+
const deployment = positional[0];
|
|
2089
|
+
if (typeof deployment !== "string")
|
|
2090
|
+
die("missing <deployment>");
|
|
2091
|
+
const { discoverDeployment } = await import("@crewhaus/federation-discovery");
|
|
2092
|
+
try {
|
|
2093
|
+
const config = {};
|
|
2094
|
+
const srv = flags.get("srv-domain");
|
|
2095
|
+
if (typeof srv === "string")
|
|
2096
|
+
config.srvDomain = srv;
|
|
2097
|
+
const record = await discoverDeployment(deployment, config);
|
|
2098
|
+
const format = flags.get("format") ?? "json";
|
|
2099
|
+
if (format === "yaml") {
|
|
2100
|
+
const yaml = [
|
|
2101
|
+
`endpoint: ${record.endpoint}`,
|
|
2102
|
+
`version: ${record.version}`,
|
|
2103
|
+
"supportedShapes:",
|
|
2104
|
+
...record.supportedShapes.map((s) => ` - ${s}`),
|
|
2105
|
+
`publicKeyFingerprint: ${record.publicKeyFingerprint}`,
|
|
2106
|
+
].join("\n");
|
|
2107
|
+
process.stdout.write(`${yaml}\n`);
|
|
2108
|
+
}
|
|
2109
|
+
else {
|
|
2110
|
+
process.stdout.write(`${JSON.stringify(record, null, 2)}\n`);
|
|
2111
|
+
}
|
|
2112
|
+
}
|
|
2113
|
+
catch (err) {
|
|
2114
|
+
die(`federation discover: ${err.message}`);
|
|
2115
|
+
}
|
|
2116
|
+
}
|
|
2117
|
+
/**
|
|
2118
|
+
* Section 39 — `crewhaus compliance evidence` collects SOC 2 / ISO 27001 /
|
|
2119
|
+
* HIPAA evidence bundles by walking an audit-log root and writing signed
|
|
2120
|
+
* bundles to `.crewhaus/compliance/<framework>/<controlId>/<period>.json`.
|
|
2121
|
+
*
|
|
2122
|
+
* Usage:
|
|
2123
|
+
* crewhaus compliance evidence --framework soc2 --period 2026-Q2 \
|
|
2124
|
+
* [--control CC6.1] [--audit-dir <d>] [--out-dir <d>] \
|
|
2125
|
+
* [--signing-key-env CREWHAUS_COMPLIANCE_SIGNING_KEY]
|
|
2126
|
+
*/
|
|
2127
|
+
async function runCompliance(args, action) {
|
|
2128
|
+
if (args.flags["help"]) {
|
|
2129
|
+
process.stdout.write("usage:\n" +
|
|
2130
|
+
" crewhaus compliance evidence --framework <id> [--control <id>] --period <p>\n" +
|
|
2131
|
+
" [--audit-dir <d>] [--out-dir <d>] [--signing-key-env <ENV>]\n");
|
|
2132
|
+
return;
|
|
2133
|
+
}
|
|
2134
|
+
if (action !== "evidence") {
|
|
2135
|
+
die(`unknown compliance action "${action}" (expected: evidence)`);
|
|
2136
|
+
}
|
|
2137
|
+
const frameworkFlag = args.flags["framework"];
|
|
2138
|
+
const periodFlag = args.flags["period"];
|
|
2139
|
+
if (typeof frameworkFlag !== "string")
|
|
2140
|
+
die("--framework <id> is required");
|
|
2141
|
+
if (typeof periodFlag !== "string")
|
|
2142
|
+
die("--period <p> is required");
|
|
2143
|
+
const auditDirFlag = args.flags["audit-dir"];
|
|
2144
|
+
const outDirFlag = args.flags["out-dir"];
|
|
2145
|
+
const auditDir = typeof auditDirFlag === "string" ? auditDirFlag : join(process.cwd(), ".crewhaus", "audit");
|
|
2146
|
+
const outDir = typeof outDirFlag === "string" ? outDirFlag : join(process.cwd(), ".crewhaus", "compliance");
|
|
2147
|
+
const signingKeyEnv = args.flags["signing-key-env"];
|
|
2148
|
+
const signingKey = typeof signingKeyEnv === "string" ? (process.env[signingKeyEnv] ?? undefined) : undefined;
|
|
2149
|
+
if (typeof signingKeyEnv === "string" && signingKey === undefined) {
|
|
2150
|
+
die(`signing key env var "${signingKeyEnv}" is not set`);
|
|
2151
|
+
}
|
|
2152
|
+
const { createComplianceCollector } = await import("@crewhaus/compliance-controls");
|
|
2153
|
+
const auditLog = await import("@crewhaus/audit-log");
|
|
2154
|
+
const auditSource = {
|
|
2155
|
+
async *read() {
|
|
2156
|
+
// Use the verify shape to walk every record without re-running hash chain
|
|
2157
|
+
// logic — auditLog.openAuditLog().read() is the supported public API.
|
|
2158
|
+
const log = await auditLog.openAuditLog({ rootDir: auditDir });
|
|
2159
|
+
for await (const r of log.read()) {
|
|
2160
|
+
yield r;
|
|
2161
|
+
}
|
|
2162
|
+
},
|
|
2163
|
+
};
|
|
2164
|
+
const collector = createComplianceCollector({ auditSource, outputDir: outDir });
|
|
2165
|
+
const controlFlag = args.flags["control"];
|
|
2166
|
+
let bundles;
|
|
2167
|
+
if (typeof controlFlag === "string") {
|
|
2168
|
+
const single = await collector.collect(frameworkFlag, controlFlag, {
|
|
2169
|
+
period: periodFlag,
|
|
2170
|
+
...(signingKey !== undefined ? { signingKey } : {}),
|
|
2171
|
+
});
|
|
2172
|
+
bundles = [single];
|
|
2173
|
+
}
|
|
2174
|
+
else {
|
|
2175
|
+
bundles = await collector.collectAll(frameworkFlag, {
|
|
2176
|
+
period: periodFlag,
|
|
2177
|
+
...(signingKey !== undefined ? { signingKey } : {}),
|
|
2178
|
+
});
|
|
2179
|
+
}
|
|
2180
|
+
for (const b of bundles) {
|
|
2181
|
+
const path = collector.writeBundle(b);
|
|
2182
|
+
process.stdout.write(`${b.frameworkId}/${b.controlId} ${periodFlag}: ${b.recordCount} records → ${path}\n`);
|
|
2183
|
+
}
|
|
2184
|
+
}
|
|
2185
|
+
/**
|
|
2186
|
+
* Section 36 — `crewhaus sandbox <action>`. Single action today: `doctor`.
|
|
2187
|
+
* doctor list registered sandbox images + healthcheck status
|
|
2188
|
+
*
|
|
2189
|
+
* `--probe` runs each registered image's healthcheck argv via the configured
|
|
2190
|
+
* sandbox backend (docker / podman / noop). Without `--probe` the command
|
|
2191
|
+
* just snapshots the in-process registry — useful for verifying that the
|
|
2192
|
+
* polyglot images are wired up after a fresh `crewhaus install`.
|
|
2193
|
+
*/
|
|
2194
|
+
async function runSandbox(args, action) {
|
|
2195
|
+
if (args.flags["help"]) {
|
|
2196
|
+
process.stdout.write("usage:\n" + " crewhaus sandbox doctor [--probe] [--format json|table]\n");
|
|
2197
|
+
return;
|
|
2198
|
+
}
|
|
2199
|
+
if (action !== "doctor")
|
|
2200
|
+
die(`unknown sandbox action "${action}" (expected: doctor)`);
|
|
2201
|
+
const formatFlag = args.flags["format"];
|
|
2202
|
+
const format = typeof formatFlag === "string" ? formatFlag : "table";
|
|
2203
|
+
if (format !== "json" && format !== "table") {
|
|
2204
|
+
die(`--format must be "json" or "table" (got "${format}")`);
|
|
2205
|
+
}
|
|
2206
|
+
const wantProbe = args.flags["probe"] === true;
|
|
2207
|
+
const reg = await import("@crewhaus/sandbox-image-registry");
|
|
2208
|
+
// Touch the registry so the §18 trio bootstraps.
|
|
2209
|
+
reg.listSandboxImages();
|
|
2210
|
+
let statuses = reg.snapshotImageStatuses();
|
|
2211
|
+
if (wantProbe) {
|
|
2212
|
+
const sandboxMod = await import("@crewhaus/sandbox");
|
|
2213
|
+
const sandbox = sandboxMod.createSandbox({
|
|
2214
|
+
allowedImages: reg.listAllowedImageRefs(),
|
|
2215
|
+
});
|
|
2216
|
+
statuses = await reg.runHealthchecks(async (entry) => {
|
|
2217
|
+
const result = await sandbox.exec({
|
|
2218
|
+
image: entry.image,
|
|
2219
|
+
argv: [...entry.healthcheck.command],
|
|
2220
|
+
timeoutMs: entry.healthcheck.timeoutMs ?? 5_000,
|
|
2221
|
+
});
|
|
2222
|
+
return { exitCode: result.exitCode, stderr: result.stderr };
|
|
2223
|
+
});
|
|
2224
|
+
await sandbox.close();
|
|
2225
|
+
}
|
|
2226
|
+
if (format === "json") {
|
|
2227
|
+
process.stdout.write(`${JSON.stringify(statuses, null, 2)}\n`);
|
|
2228
|
+
return;
|
|
2229
|
+
}
|
|
2230
|
+
process.stdout.write("registered sandbox images:\n");
|
|
2231
|
+
const summaries = reg.listSandboxImages();
|
|
2232
|
+
const byId = new Map(statuses.map((s) => [s.id, s]));
|
|
2233
|
+
for (const e of summaries) {
|
|
2234
|
+
const s = byId.get(e.id);
|
|
2235
|
+
const mark = s?.healthy ? "✓" : wantProbe ? "✗" : "•";
|
|
2236
|
+
const tail = s?.healthy
|
|
2237
|
+
? `last healthy ${s.lastHealthyAt}`
|
|
2238
|
+
: (s?.lastError ?? (wantProbe ? "no probe result" : "not yet probed"));
|
|
2239
|
+
process.stdout.write(` ${mark} ${e.id.padEnd(10)} ${e.image.padEnd(28)} ${tail}\n`);
|
|
2240
|
+
}
|
|
2241
|
+
}
|
|
2242
|
+
const argv = process.argv.slice(2);
|
|
2243
|
+
const subcommand = argv[0] ?? "";
|
|
2244
|
+
const rest = argv.slice(1);
|
|
2245
|
+
switch (subcommand) {
|
|
2246
|
+
case "compile":
|
|
2247
|
+
await runCompile(parseFor(rest, COMPILE_SCHEMA));
|
|
2248
|
+
break;
|
|
2249
|
+
case "init":
|
|
2250
|
+
runInit(parseFor(rest, INIT_SCHEMA));
|
|
2251
|
+
break;
|
|
2252
|
+
case "run":
|
|
2253
|
+
// Mirror runCompile's policy: every structured failure in the run
|
|
2254
|
+
// pipeline (ConfigError from the model-router, ProviderAuthError from
|
|
2255
|
+
// an adapter, RuntimeError, …) extends CrewhausError — route the
|
|
2256
|
+
// family through die() for a clean one-line error + exit 1 instead of
|
|
2257
|
+
// a raw stack trace. A non-CrewhausError (a genuine bug) still
|
|
2258
|
+
// propagates with its full stack for debugging.
|
|
2259
|
+
try {
|
|
2260
|
+
await runRun(parseFor(rest, RUN_SCHEMA));
|
|
2261
|
+
}
|
|
2262
|
+
catch (err) {
|
|
2263
|
+
if (err instanceof CrewhausError)
|
|
2264
|
+
die(err.message);
|
|
2265
|
+
throw err;
|
|
2266
|
+
}
|
|
2267
|
+
break;
|
|
2268
|
+
case "eval":
|
|
2269
|
+
await runEvalSubcommand(parseFor(rest, EVAL_SCHEMA));
|
|
2270
|
+
break;
|
|
2271
|
+
case "eval-report":
|
|
2272
|
+
await runEvalReport(parseFor(rest, EVAL_REPORT_SCHEMA));
|
|
2273
|
+
break;
|
|
2274
|
+
case "optimize":
|
|
2275
|
+
await runOptimize(parseFor(rest, OPTIMIZE_SCHEMA));
|
|
2276
|
+
break;
|
|
2277
|
+
case "doctor":
|
|
2278
|
+
await runDoctor(parseFor(rest, DOCTOR_SCHEMA));
|
|
2279
|
+
break;
|
|
2280
|
+
case "context":
|
|
2281
|
+
runContext(parseFor(rest, CONTEXT_SCHEMA));
|
|
2282
|
+
break;
|
|
2283
|
+
case "cost-summary":
|
|
2284
|
+
await runCostSummary(parseFor(rest, COST_SUMMARY_SCHEMA));
|
|
2285
|
+
break;
|
|
2286
|
+
case "secrets": {
|
|
2287
|
+
const action = rest[0] ?? "";
|
|
2288
|
+
if (action !== "doctor" && action !== "rotate") {
|
|
2289
|
+
die(`secrets action must be "doctor" or "rotate" (got "${action}")`);
|
|
2290
|
+
}
|
|
2291
|
+
await runSecrets(parseFor(rest.slice(1), SECRETS_SCHEMA), action);
|
|
2292
|
+
break;
|
|
2293
|
+
}
|
|
2294
|
+
case "spec": {
|
|
2295
|
+
const action = rest[0] ?? "";
|
|
2296
|
+
if (!["put", "list", "get", "pin", "alias"].includes(action)) {
|
|
2297
|
+
die(`spec action must be one of: put, list, get, pin, alias (got "${action}")`);
|
|
2298
|
+
}
|
|
2299
|
+
await runSpec(parseFor(rest.slice(1), SPEC_SCHEMA), action);
|
|
2300
|
+
break;
|
|
2301
|
+
}
|
|
2302
|
+
case "deploy": {
|
|
2303
|
+
const action = rest[0] ?? "";
|
|
2304
|
+
if (action !== "promote" && action !== "rollback") {
|
|
2305
|
+
die(`deploy action must be "promote" or "rollback" (got "${action}")`);
|
|
2306
|
+
}
|
|
2307
|
+
await runDeploy(parseFor(rest.slice(1), DEPLOY_SCHEMA), action);
|
|
2308
|
+
break;
|
|
2309
|
+
}
|
|
2310
|
+
case "migrate-all":
|
|
2311
|
+
await runMigrateAll(parseFor(rest, MIGRATE_SCHEMA));
|
|
2312
|
+
break;
|
|
2313
|
+
case "build-image":
|
|
2314
|
+
await runBuildImage(rest);
|
|
2315
|
+
break;
|
|
2316
|
+
case "cloud": {
|
|
2317
|
+
const action = rest[0] ?? "";
|
|
2318
|
+
if (action !== "deploy" && action !== "teardown") {
|
|
2319
|
+
die(`cloud action must be "deploy" or "teardown" (got "${action}")`);
|
|
2320
|
+
}
|
|
2321
|
+
await runCloud(parseFor(rest.slice(1), CLOUD_SCHEMA), action);
|
|
2322
|
+
break;
|
|
2323
|
+
}
|
|
2324
|
+
case "federation": {
|
|
2325
|
+
const action = rest[0] ?? "";
|
|
2326
|
+
if (action !== "discover") {
|
|
2327
|
+
die(`federation action must be "discover" (got "${action}")`);
|
|
2328
|
+
}
|
|
2329
|
+
await runFederation(rest.slice(1));
|
|
2330
|
+
break;
|
|
2331
|
+
}
|
|
2332
|
+
case "sandbox": {
|
|
2333
|
+
const action = rest[0] ?? "";
|
|
2334
|
+
if (action !== "doctor") {
|
|
2335
|
+
die(`sandbox action must be "doctor" (got "${action}")`);
|
|
2336
|
+
}
|
|
2337
|
+
await runSandbox(parseFor(rest.slice(1), SANDBOX_SCHEMA), action);
|
|
2338
|
+
break;
|
|
2339
|
+
}
|
|
2340
|
+
case "compliance": {
|
|
2341
|
+
const action = rest[0] ?? "";
|
|
2342
|
+
if (action !== "evidence") {
|
|
2343
|
+
die(`compliance action must be "evidence" (got "${action}")`);
|
|
2344
|
+
}
|
|
2345
|
+
await runCompliance(parseFor(rest.slice(1), COMPLIANCE_SCHEMA), action);
|
|
2346
|
+
break;
|
|
2347
|
+
}
|
|
2348
|
+
case "version":
|
|
2349
|
+
case "-v":
|
|
2350
|
+
case "--version":
|
|
2351
|
+
printVersion();
|
|
2352
|
+
break;
|
|
2353
|
+
case "-h":
|
|
2354
|
+
case "--help":
|
|
2355
|
+
help();
|
|
2356
|
+
break;
|
|
2357
|
+
case "":
|
|
2358
|
+
usage();
|
|
2359
|
+
break;
|
|
2360
|
+
default:
|
|
2361
|
+
die(`unknown subcommand: ${subcommand}`);
|
|
2362
|
+
}
|