crewhaus 0.1.3 → 0.1.5

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