@crewhaus/spec 0.1.4 → 0.1.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/index.ts DELETED
@@ -1,1242 +0,0 @@
1
- import { SpecParseError } from "@crewhaus/errors";
2
- import { parse as parseYaml } from "yaml";
3
- import { z } from "zod";
4
-
5
- // SECURITY (codegen-injection backstop, #147/#148): spec/role/node/step names
6
- // flow verbatim into generated source across ~14 emitters — `//` and `/* */`
7
- // comments, template literals, JSON `package.json` manifests, YAML frontmatter,
8
- // and on-disk file paths (`skills/<name>/SKILL.md`). A raw newline, `*/`, quote,
9
- // backtick or `/` lets a crafted name break out of those contexts (RCE on
10
- // build/run, dependency injection, path traversal). The emitters escape per-site
11
- // as defense-in-depth, but this is the systemic floor: restrict names to a
12
- // single-line safe charset so the breakout characters can never enter the IR.
13
- const safeName = z
14
- .string()
15
- .min(1)
16
- .regex(
17
- /^[\w .:-]+$/,
18
- "name may contain only letters, digits, spaces, and '_ . - :' (no newlines, quotes, slashes, or comment/template delimiters)",
19
- );
20
-
21
- /**
22
- * v0 spec schema — a discriminated union over `target`.
23
- *
24
- * - `cli`: a single streaming-chat agent (Section 1–5).
25
- * - `workflow`: a sequence of named steps run in order, threading the prior
26
- * step's final assistant text into the next step's user message (Section 6).
27
- * - `channel`: a long-running daemon that listens for inbound channel events
28
- * (Slack today, more channels later) and runs one agent turn per inbound
29
- * message, threaded by the routing key. Section 12.
30
- *
31
- * Will grow into the full catalog spec (eval, deploy) — see
32
- * docs/MODULE-CATALOG.md PART A Layer F1.
33
- */
34
-
35
- // Permissions block (Section 7). SECURITY: `mode: "bypass"` is intentionally
36
- // absent from the enum — bypass can only enter the system via the CLI flag.
37
- // Defense in depth: parse-time and runtime checks both reject it.
38
- const permissionRuleSchema = z
39
- .object({
40
- type: z.enum(["alwaysAllow", "alwaysDeny", "alwaysAsk"]),
41
- pattern: z.string().min(1),
42
- })
43
- .strict();
44
-
45
- const permissionsBlock = z
46
- .object({
47
- mode: z.enum(["default", "plan", "auto"]).optional(),
48
- rules: z.array(permissionRuleSchema).optional(),
49
- })
50
- .strict()
51
- .optional();
52
-
53
- // MCP servers block (Section 9). Discriminated on `transport` so unknown
54
- // configs surface as a clear "Invalid literal value" error rather than a
55
- // confusing union-of-rejections.
56
- const stdioMcpConfig = z
57
- .object({
58
- transport: z.literal("stdio"),
59
- command: z.string().min(1),
60
- args: z.array(z.string()).optional(),
61
- env: z.record(z.string()).optional(),
62
- })
63
- .strict();
64
-
65
- const sseMcpConfig = z
66
- .object({
67
- transport: z.literal("sse"),
68
- url: z.string().url(),
69
- headers: z.record(z.string()).optional(),
70
- })
71
- .strict();
72
-
73
- const mcpServerConfigSchema = z.discriminatedUnion("transport", [stdioMcpConfig, sseMcpConfig]);
74
-
75
- const mcpServersBlock = z.record(z.string().min(1), mcpServerConfigSchema).optional();
76
-
77
- // Section 13 — sub-agent definitions. Inline on the agent block (cli +
78
- // channel today; workflow has no agent block). The map's key is the
79
- // `subagent_type` users pass to the Task tool. Permissions field mirrors
80
- // the runtime's resolution shape.
81
- const subAgentDefinitionSchema = z
82
- .object({
83
- description: z.string().min(1),
84
- instructions: z.string().min(1),
85
- tools: z.array(z.string().min(1)).optional(),
86
- model: z.string().min(1).optional(),
87
- permissions: z
88
- .union([
89
- z.enum(["inherit", "scoped"]),
90
- z
91
- .object({
92
- allow: z.array(z.string().min(1)),
93
- deny: z.array(z.string().min(1)),
94
- })
95
- .strict(),
96
- ])
97
- .optional(),
98
- inherit_bypass: z.boolean().optional(),
99
- })
100
- .strict();
101
-
102
- const subAgentsBlock = z.record(safeName, subAgentDefinitionSchema).optional();
103
-
104
- /**
105
- * Section 14 — per-tool runtime config map. Tool-specific schemas live
106
- * inside each tool package; the spec layer treats every value as opaque
107
- * `unknown` and forwards it verbatim to the IR. The codegen layer emits
108
- * an init call (e.g. `registerFetchConfig({ ... })`) for tools whose
109
- * BUILTIN_TOOL_MAP entry declares an `initSymbol`.
110
- *
111
- * SECURITY (sandbox-override hardening): the code-execution config is the
112
- * one exception to "opaque `unknown`". Its blob is compiled verbatim into
113
- * `registerCodeExecutionConfig(...)` in the generated bundle, and the
114
- * @crewhaus/sandbox boundary validates images/mounts against THIS same
115
- * blob's allowlist (`allowedImages`, `mountWhitelist`). If a spec could set
116
- * those — or `backend` (e.g. force `noop`, which is no isolation at all),
117
- * `images`, or `mounts` — an untrusted marketplace/template spec would be
118
- * supplying its own sandbox allowlist, making the controls self-defeating.
119
- * The sandbox boundary must come only from trusted operator config (the CLI
120
- * / `CREWHAUS_SANDBOX*` env vars), never from a spec file. So the
121
- * code-execution config is constrained to a strict allowlist of non-security
122
- * knobs; any sandbox-override key is rejected at parse time (defense in
123
- * depth, mirroring `permissions.mode: bypass`).
124
- *
125
- * The code-execution config can arrive under any of the keys whose
126
- * BUILTIN_TOOL_MAP entry maps to `registerCodeExecutionConfig` — the
127
- * `codeExecution`/`code_execution` aliases AND the per-tool keys
128
- * `python`/`javascript`/`shell` (target-cli `resolveTools` reads the
129
- * per-tool key first, then the aliases). All of them must be constrained,
130
- * or the guard is trivially bypassed by nesting the blob under `python`.
131
- */
132
- const SANDBOX_OVERRIDE_KEYS = [
133
- "sandbox",
134
- "backend",
135
- "allowedImages",
136
- "allowed_images",
137
- "mountWhitelist",
138
- "mount_whitelist",
139
- "images",
140
- "mounts",
141
- ] as const;
142
-
143
- const CODE_EXECUTION_CONFIG_KEYS = [
144
- "codeExecution",
145
- "code_execution",
146
- "python",
147
- "javascript",
148
- "shell",
149
- ] as const;
150
-
151
- const codeExecutionConfigSchema = z
152
- .object({
153
- // Non-security knobs only. The sandbox boundary (backend, image
154
- // allowlist, mount whitelist, per-language images, mounts) is owned by
155
- // trusted operator config and is intentionally NOT settable from a spec.
156
- defaultTimeoutMs: z.number().int().positive().optional(),
157
- default_timeout_ms: z.number().int().positive().optional(),
158
- warmPoolSize: z.number().int().nonnegative().optional(),
159
- warm_pool_size: z.number().int().nonnegative().optional(),
160
- })
161
- .strict(
162
- `code-execution config may only set non-security knobs (defaultTimeoutMs, warmPoolSize); sandbox-boundary keys (${SANDBOX_OVERRIDE_KEYS.join(", ")}) are owned by trusted operator config and rejected from specs`,
163
- );
164
-
165
- const toolConfigBlock = z
166
- .record(z.string().min(1), z.unknown())
167
- .superRefine((cfg, ctx) => {
168
- for (const key of CODE_EXECUTION_CONFIG_KEYS) {
169
- const value = cfg[key];
170
- if (value === undefined) continue;
171
- const parsed = codeExecutionConfigSchema.safeParse(value);
172
- if (!parsed.success) {
173
- for (const issue of parsed.error.issues) {
174
- ctx.addIssue({
175
- code: z.ZodIssueCode.custom,
176
- path: [key, ...issue.path],
177
- message: issue.message,
178
- });
179
- }
180
- }
181
- }
182
- })
183
- .optional();
184
-
185
- /**
186
- * Section 17 — optional override for the model used by
187
- * `compaction-autocompact` when summarising long conversations. Defaults
188
- * to the agent's primary model when omitted, but you can target a
189
- * cheaper/faster model (or a different provider) for compaction.
190
- */
191
- const compactionBlock = z
192
- .object({
193
- model: z.string().min(1).optional(),
194
- /** Pillar 2 — opt in to the pre-compaction curator pass. Defaults
195
- * to `false` when omitted; the IR carries the user's choice
196
- * verbatim so target emitters can wire `@crewhaus/compaction-curator`
197
- * on the runtime path. See docs/MODULE-CATALOG.md R6 + recipe 52. */
198
- curate: z.boolean().optional(),
199
- /** Cosine-similarity threshold above which two items are considered
200
- * duplicates by the curator. Defaults to 0.92 in the curator
201
- * itself; spec-level override targets per-corpus tuning. Must be
202
- * in (0, 1] — values outside that range can't be cosine outputs. */
203
- dedupeThreshold: z.number().gt(0).lte(1).optional(),
204
- /** Max items the curator keeps after the relevance reorder. When
205
- * omitted, the curator only reorders (no top-K trim). Spec-level
206
- * override is the natural knob for RAG pipelines that want a hard
207
- * cap on retrieved chunks per turn. */
208
- relevanceTopK: z.number().int().positive().optional(),
209
- })
210
- .strict()
211
- .optional();
212
-
213
- /**
214
- * Pillar 3 (FR-004) — per-target security fabric block. Today it carries
215
- * the intent-gate's judge selection; the optimizable path
216
- * `["security", "justification"]` is already registered in
217
- * `spec-patch`'s `OPTIMIZABLE_PATHS`, so this block MUST be named
218
- * `security` with a `justification` sub-field to honour it.
219
- *
220
- * `justification.judge` selects which `JustificationJudge` the cli run
221
- * path wires: `"rule-based"` (the deterministic default for tests/offline
222
- * runs) or `"claude"` (the model-backed `@crewhaus/justification-judge-claude`,
223
- * the documented production recommendation). `model` is the judge model
224
- * id for the claude judge; the consumer defaults it to a haiku-class
225
- * model when omitted.
226
- *
227
- * NOTE: `egressPolicy` is reserved — `OPTIMIZABLE_PATHS` also lists
228
- * `["security", "egressPolicy"]`, owned by the egress-fabric FRs
229
- * (FR-002/006). Do NOT add `egressPolicy` here; that would clobber their
230
- * sub-field. FR-004 added `justification`; FR-006 added `egressMatcher`
231
- * (the substring/semantic selector) alongside it — both are independent
232
- * optional sub-fields of this same block.
233
- */
234
- const securityBlock = z
235
- .object({
236
- justification: z
237
- .object({
238
- judge: z.enum(["rule-based", "claude"]).default("rule-based"),
239
- model: z.string().min(1).optional(),
240
- })
241
- .strict()
242
- .optional(),
243
- /**
244
- * Pillar 3 sink-side fabric (FR-006) — select the egress-matching
245
- * strategy. `"substring"` (the default when omitted) is the
246
- * behavior-preserving `SubstringEgressMatcher` with `MIN_MATCH_LENGTH`.
247
- * `"semantic"` selects the optional embedding-backed
248
- * `@crewhaus/egress-matcher-semantic`, which scores outbound payloads
249
- * against tagged data-lineage by cosine similarity. Switching the
250
- * matcher changes *how* lineage matches are detected; the per-origin/
251
- * per-sink policy and the three audit outcomes are unaffected.
252
- *
253
- * This field is lowered to `IrSecurity.egressMatcher` (FR-006) and
254
- * honoured by the `crewhaus run` path, which resolves the selector and
255
- * threads the matcher into `runChatLoop({ egressMatcher })` — exactly
256
- * how `security.justification.judge` selects the intent-gate judge on
257
- * the same path. `"semantic"` constructs the optional
258
- * `@crewhaus/egress-matcher-semantic` (with an injected embedder; see
259
- * `--egress-embedder`). The runtime SEAM
260
- * (`RunChatLoopOptions.egressMatcher`) underlies both.
261
- *
262
- * The *generated cli bundle* also honours this field: `@crewhaus/target-cli`
263
- * emits the matcher construction (the semantic one with an injected
264
- * `@crewhaus/embedder` embedder) into the bundle's
265
- * `runChatLoop({ egressMatcher })`, so a compiled standalone artifact uses
266
- * `semantic` WITHOUT the `crewhaus run` path. The substring default emits
267
- * nothing, keeping the bundle free of any embedding dependency.
268
- */
269
- egressMatcher: z.enum(["substring", "semantic"]).optional(),
270
- })
271
- .strict()
272
- .optional();
273
-
274
- /**
275
- * Section 55 (Track A) — named failure taxonomy. Cross-cutting block
276
- * available on every target shape. Each entry names a failure class and
277
- * tells the recovery engine which `RecoveryAction` to take when the
278
- * pattern matches an error's `message`. Optional `hint` is surfaced to
279
- * the model as a one-shot system message on `continue`/`retry` recovery.
280
- *
281
- * Source: Natural-Language Agent Harnesses (arxiv 2603.25723, Tsinghua,
282
- * March 2026) names failure_taxonomy as one of the six components a
283
- * portable harness must expose. Cited paper: NLAH (arxiv 2603.25723).
284
- */
285
- const failureTaxonomyEntrySchema = z
286
- .object({
287
- class: z.string().min(1),
288
- pattern: z.string().min(1),
289
- recovery: z.enum(["retry", "compact", "continue", "tombstone", "fail"]),
290
- hint: z.string().min(1).optional(),
291
- })
292
- .strict();
293
-
294
- const failureTaxonomyBlock = z.array(failureTaxonomyEntrySchema).optional();
295
-
296
- /**
297
- * Section 47 — blockchain subsystem blocks (cross-cutting). Any shape may
298
- * declare any subset of `chains` / `wallets` / `contracts` /
299
- * `transaction_policy`. Authoring rules:
300
- * - `chains[]`: at least one when other blocks are present.
301
- * - `wallets[]`: every entry references a declared `chains[].id`.
302
- * - `contracts[]`: every entry references a declared `chains[].id`.
303
- * - `transaction_policy`: enforced by §47 IR pass at compile time;
304
- * entries in `allowed_contracts` must reference declared `contracts[].id`.
305
- * Per-field semantics mirror the IR variants in `@crewhaus/ir`.
306
- */
307
- const chainFinalitySchema = z.discriminatedUnion("kind", [
308
- z
309
- .object({
310
- kind: z.literal("confirmations"),
311
- count: z.number().int().min(0).max(256),
312
- })
313
- .strict(),
314
- z.object({ kind: z.literal("finalized") }).strict(),
315
- z.object({ kind: z.literal("safe") }).strict(),
316
- ]);
317
-
318
- const chainBindingSchema = z
319
- .object({
320
- id: z.string().min(1),
321
- kind: z.literal("evm"),
322
- rpcUrls: z.array(z.string().min(1)).min(1),
323
- rpcPolicy: z.enum(["single", "quorum", "fallback"]).default("single"),
324
- finality: chainFinalitySchema,
325
- reorgTolerant: z.boolean().default(true),
326
- })
327
- .strict();
328
-
329
- const walletBindingSchema = z
330
- .object({
331
- id: z.string().min(1),
332
- chainId: z.string().min(1),
333
- custody: z.enum(["user-controlled", "kms", "hsm", "local"]),
334
- signingPolicy: z
335
- .enum(["explicit-user-approval", "policy-gated", "automated"])
336
- .default("explicit-user-approval"),
337
- keyRef: z.string().min(1).optional(),
338
- })
339
- .strict();
340
-
341
- const contractBindingSchema = z
342
- .object({
343
- id: z.string().min(1),
344
- chainId: z.string().min(1),
345
- address: z.string().min(1),
346
- abiRef: z.string().min(1),
347
- })
348
- .strict();
349
-
350
- const transactionPolicySchema = z
351
- .object({
352
- defaultWriteApproval: z.enum(["required", "policy", "none"]).default("required"),
353
- maxValueUsd: z.number().positive().optional(),
354
- // Oracle-free native-token spend ceiling (wei). This is the ONLY value cap
355
- // wallet-engine can actually enforce — maxValueUsd hard-throws without a
356
- // price oracle. Decimal or 0x-hex string (parsed via BigInt downstream).
357
- maxValueWei: z
358
- .string()
359
- .regex(
360
- /^(0x[0-9a-fA-F]+|[0-9]+)$/,
361
- "maxValueWei must be a wei amount as a decimal or 0x-hex string",
362
- )
363
- .optional(),
364
- allowedContracts: z.array(z.string().min(1)).default([]),
365
- simulationRequired: z.boolean().default(true),
366
- })
367
- .strict();
368
-
369
- const chainsBlock = z.array(chainBindingSchema).optional();
370
- const walletsBlock = z.array(walletBindingSchema).optional();
371
- const contractsBlock = z.array(contractBindingSchema).optional();
372
- const transactionPolicyBlock = transactionPolicySchema.optional();
373
-
374
- /**
375
- * Phase 3 §3.3 — CLI banner with optional tagline rotation. When set,
376
- * the compiled cli-target bundle prints this banner on cold start
377
- * (suppressed under `--resume` / `--continue` so resumed sessions
378
- * don't re-banner). Static mode picks the first tagline; random mode
379
- * picks one uniformly per startup.
380
- */
381
- const cliBannerBlock = z
382
- .object({
383
- taglineMode: z.enum(["static", "random"]).default("static"),
384
- taglines: z.array(z.string().min(1)).min(1),
385
- })
386
- .strict()
387
- .optional();
388
-
389
- const cliOptionsBlock = z
390
- .object({
391
- banner: cliBannerBlock,
392
- /**
393
- * Phase 2 M2.2 — TUI polish gate. "basic" is the current readline-
394
- * driven REPL; "rich" is reserved for future Ink-based output
395
- * (status line, multi-line input, ESC interrupt). Today both modes
396
- * compile identically; the field is forward-compatible.
397
- */
398
- tui: z.enum(["basic", "rich"]).default("basic"),
399
- })
400
- .strict()
401
- .optional();
402
-
403
- /**
404
- * Phase 3 §3.1 — heartbeat scheduled wake for channel daemons. When
405
- * present, target-channel-bot emits a setInterval loop that
406
- * synthesises a heartbeat turn at the configured interval. The
407
- * `every` field accepts a duration string (e.g. "2h", "30m", "60s").
408
- * `instructions` is what the runtime sends as the synthetic user
409
- * message at each tick; pair with HEARTBEAT.md in cwd for richer
410
- * playbook reads.
411
- */
412
- const HEARTBEAT_DURATION_REGEX = /^\d+(?:ms|s|m|h)$/;
413
-
414
- const heartbeatBlock = z
415
- .object({
416
- every: z
417
- .string()
418
- .regex(
419
- HEARTBEAT_DURATION_REGEX,
420
- 'heartbeat.every must be a duration like "2h", "30m", "60s", or "500ms"',
421
- ),
422
- instructions: z.string().min(1),
423
- })
424
- .strict()
425
- .optional();
426
-
427
- /**
428
- * Phase 3 §3.4 — channel daemon control-UI gateway. When set, the
429
- * compiled daemon spawns a second HTTP listener on `port` that serves
430
- * a status endpoint (and, when `ui: true`, a minimal dashboard).
431
- * Mirrors OpenClaw's Gateway control plane in concept; ours starts
432
- * minimal and is intended to host packaged Studio UI in a follow-up.
433
- */
434
- const channelGatewayBlock = z
435
- .object({
436
- port: z.number().int().min(1).max(65535),
437
- ui: z.boolean().default(false),
438
- })
439
- .strict()
440
- .optional();
441
-
442
- const cliSchema = z
443
- .object({
444
- name: safeName,
445
- target: z.literal("cli"),
446
- agent: z
447
- .object({
448
- model: z.string().min(1),
449
- instructions: z.string().min(1),
450
- sub_agents: subAgentsBlock,
451
- })
452
- .strict(),
453
- tools: z.array(z.string().min(1)).optional(),
454
- tool_config: toolConfigBlock,
455
- mcp_servers: mcpServersBlock,
456
- permissions: permissionsBlock,
457
- compaction: compactionBlock,
458
- security: securityBlock,
459
- failure_taxonomy: failureTaxonomyBlock,
460
- cli: cliOptionsBlock,
461
- chains: chainsBlock,
462
- wallets: walletsBlock,
463
- contracts: contractsBlock,
464
- transaction_policy: transactionPolicyBlock,
465
- })
466
- .strict();
467
-
468
- const workflowStepSchema = z
469
- .object({
470
- name: safeName,
471
- instructions: z.string().min(1),
472
- model: z.string().min(1).optional(),
473
- tools: z.array(z.string().min(1)).optional(),
474
- tool_config: toolConfigBlock,
475
- })
476
- .strict();
477
-
478
- const workflowSchema = z
479
- .object({
480
- name: safeName,
481
- target: z.literal("workflow"),
482
- model: z.string().min(1),
483
- steps: z.array(workflowStepSchema).min(1),
484
- mcp_servers: mcpServersBlock,
485
- permissions: permissionsBlock,
486
- compaction: compactionBlock,
487
- failure_taxonomy: failureTaxonomyBlock,
488
- chains: chainsBlock,
489
- wallets: walletsBlock,
490
- contracts: contractsBlock,
491
- transaction_policy: transactionPolicyBlock,
492
- })
493
- .strict();
494
-
495
- // Channel target (Section 12). Secret fields (botToken/signingSecret/appToken)
496
- // are kept as plain strings here; the compiler's `lower()` rewrites strings
497
- // matching `$VAR_NAME` into env-var references in the IR so the compiled
498
- // bundle reads `process.env.VAR_NAME` at runtime instead of embedding secrets.
499
- const slackChannelSchema = z
500
- .object({
501
- botToken: z.string().min(1),
502
- signingSecret: z.string().min(1),
503
- // Reserved for a future Socket Mode listener. The v0 webhook daemon parses
504
- // and carries this field but does not use it, so it is documented as
505
- // reserved rather than implying an unimplemented requirement.
506
- appToken: z
507
- .string()
508
- .min(1)
509
- .optional()
510
- .describe(
511
- "reserved for a future Socket Mode path; parsed but unused by the v0 webhook daemon",
512
- ),
513
- })
514
- .strict();
515
-
516
- const telegramChannelSchema = z
517
- .object({
518
- botToken: z.string().min(1),
519
- secretToken: z.string().min(1),
520
- })
521
- .strict();
522
-
523
- const discordChannelSchema = z
524
- .object({
525
- applicationId: z.string().min(1),
526
- botToken: z.string().min(1),
527
- publicKeyHex: z.string().min(1),
528
- })
529
- .strict();
530
-
531
- const whatsappChannelSchema = z
532
- .object({
533
- phoneNumberId: z.string().min(1),
534
- accessToken: z.string().min(1),
535
- appSecret: z.string().min(1),
536
- })
537
- .strict();
538
-
539
- const imessageChannelSchema = z
540
- .object({
541
- chatDbPath: z.string().min(1).optional(),
542
- cursorPath: z.string().min(1).optional(),
543
- })
544
- .strict();
545
-
546
- const channelsBlock = z
547
- .object({
548
- slack: slackChannelSchema.optional(),
549
- telegram: telegramChannelSchema.optional(),
550
- discord: discordChannelSchema.optional(),
551
- whatsapp: whatsappChannelSchema.optional(),
552
- imessage: imessageChannelSchema.optional(),
553
- })
554
- .strict()
555
- .refine(
556
- (c) =>
557
- c.slack !== undefined ||
558
- c.telegram !== undefined ||
559
- c.discord !== undefined ||
560
- c.whatsapp !== undefined ||
561
- c.imessage !== undefined,
562
- {
563
- message:
564
- "channels block requires at least one channel (slack | telegram | discord | whatsapp | imessage)",
565
- },
566
- );
567
-
568
- const routingBlock = z
569
- .object({
570
- sessionKey: z.enum(["thread", "user", "channel"]),
571
- })
572
- .strict();
573
-
574
- const channelAgentSchema = z
575
- .object({
576
- model: z.string().min(1),
577
- instructions: z.string().min(1),
578
- tools: z.array(z.string().min(1)).optional(),
579
- tool_config: toolConfigBlock,
580
- sub_agents: subAgentsBlock,
581
- })
582
- .strict();
583
-
584
- const channelSchema = z
585
- .object({
586
- name: safeName,
587
- target: z.literal("channel"),
588
- agent: channelAgentSchema,
589
- channels: channelsBlock,
590
- routing: routingBlock,
591
- mcp_servers: mcpServersBlock,
592
- permissions: permissionsBlock,
593
- compaction: compactionBlock,
594
- failure_taxonomy: failureTaxonomyBlock,
595
- heartbeat: heartbeatBlock,
596
- gateway: channelGatewayBlock,
597
- chains: chainsBlock,
598
- wallets: walletsBlock,
599
- contracts: contractsBlock,
600
- transaction_policy: transactionPolicyBlock,
601
- })
602
- .strict();
603
-
604
- // Graph target (Section 19) — stateful DAG runtime. Nodes are LLM-backed
605
- // invocations; edges link nodes; HITL pauses interrupt the run on
606
- // `requestApproval()`. Each node may have its own model + tools.
607
- const graphNodeSchema = z
608
- .object({
609
- instructions: z.string().min(1),
610
- model: z.string().min(1).optional(),
611
- tools: z.array(z.string().min(1)).optional(),
612
- tool_config: toolConfigBlock,
613
- /**
614
- * When true, the node calls `ctx.requestApproval(prompt)` before
615
- * returning. The engine pauses, persists a checkpoint, and waits for
616
- * `resume(checkpointId, decision)` from the operator/CLI.
617
- */
618
- hitl: z
619
- .object({
620
- prompt: z.string().min(1),
621
- })
622
- .strict()
623
- .optional(),
624
- })
625
- .strict();
626
-
627
- const graphEdgeSchema = z
628
- .object({
629
- from: z.string().min(1),
630
- to: z.string().min(1),
631
- })
632
- .strict();
633
-
634
- const graphSchema = z
635
- .object({
636
- name: safeName,
637
- target: z.literal("graph"),
638
- model: z.string().min(1),
639
- entry: z.string().min(1),
640
- nodes: z.record(safeName, graphNodeSchema),
641
- edges: z.array(graphEdgeSchema).default([]),
642
- permissions: permissionsBlock,
643
- compaction: compactionBlock,
644
- failure_taxonomy: failureTaxonomyBlock,
645
- chains: chainsBlock,
646
- wallets: walletsBlock,
647
- contracts: contractsBlock,
648
- transaction_policy: transactionPolicyBlock,
649
- })
650
- .strict();
651
-
652
- // Managed daemon target (Section 20). Multi-tenant gateway with
653
- // per-tenant budgets + policy overrides; emitted bundle is daemon.ts +
654
- // agent.ts. Authentication is HS256 JWT — the signing secret enters
655
- // via env at boot, not via the spec.
656
- const managedTenantSchema = z
657
- .object({
658
- id: z.string().min(1),
659
- budget: z
660
- .object({
661
- maxInputTokens: z.number().int().positive(),
662
- maxOutputTokens: z.number().int().positive(),
663
- })
664
- .strict(),
665
- })
666
- .strict();
667
-
668
- const managedAgentSchema = z
669
- .object({
670
- model: z.string().min(1),
671
- instructions: z.string().min(1),
672
- })
673
- .strict();
674
-
675
- const managedSchema = z
676
- .object({
677
- name: safeName,
678
- target: z.literal("managed"),
679
- agent: managedAgentSchema,
680
- tenants: z.array(managedTenantSchema).min(1),
681
- permissions: permissionsBlock,
682
- compaction: compactionBlock,
683
- failure_taxonomy: failureTaxonomyBlock,
684
- })
685
- .strict();
686
-
687
- // Vector-store backend ids accepted in specs. Mirrors `VectorBackendId`
688
- // from @crewhaus/vector-store (and `IrVectorBackend`) — the canonical set
689
- // of implemented backends — kept inline so the spec stays dependency-light.
690
- // Keep in sync when a backend is added or removed.
691
- const VECTOR_BACKENDS = ["in-memory", "lance", "qdrant", "pinecone", "weaviate"] as const;
692
-
693
- // The HTTP backends construct only with a `url` + `collection` (the
694
- // vector-store factory throws otherwise); parseSpec requires both so a
695
- // spec that selects one without them fails at compile, not at runtime.
696
- const HTTP_VECTOR_BACKENDS = new Set(["qdrant", "pinecone", "weaviate"]);
697
-
698
- // Pipeline / RAG target (Section 21). Carries the embedder + vector-store
699
- // config, an indexing pipeline, and a chat agent that uses Retrieve.
700
- const pipelineDocumentSchema = z
701
- .object({
702
- id: z.string().min(1),
703
- text: z.string().min(1),
704
- metadata: z.record(z.string(), z.unknown()).optional(),
705
- })
706
- .strict();
707
-
708
- const pipelineSchema = z
709
- .object({
710
- name: safeName,
711
- target: z.literal("pipeline"),
712
- agent: z
713
- .object({
714
- model: z.string().min(1),
715
- instructions: z.string().min(1),
716
- })
717
- .strict(),
718
- retrieve: z
719
- .object({
720
- embedderModel: z.string().min(1),
721
- vectorBackend: z.enum(VECTOR_BACKENDS).default("in-memory"),
722
- defaultK: z.number().int().positive().max(50).default(5),
723
- // Remote (qdrant/pinecone/weaviate) + file (lance) backend config.
724
- // `url` is the service base URL (or, for lance, the on-disk index
725
- // path); `apiKey` accepts a `$ENV_REF` so the secret resolves from
726
- // `process.env` in the bundle rather than being baked into it. The
727
- // HTTP backends require `url` + `collection` (enforced in parseSpec).
728
- url: z.string().min(1).optional(),
729
- collection: z.string().min(1).optional(),
730
- apiKey: z.string().min(1).optional(),
731
- })
732
- .strict(),
733
- indexing: z
734
- .object({
735
- chunkStrategy: z.enum(["fixed", "semantic", "markdown"]).default("fixed"),
736
- chunkSize: z.number().int().positive().default(400),
737
- chunkOverlap: z.number().int().nonnegative().default(0),
738
- documents: z.array(pipelineDocumentSchema).min(1),
739
- })
740
- .strict(),
741
- permissions: permissionsBlock,
742
- compaction: compactionBlock,
743
- failure_taxonomy: failureTaxonomyBlock,
744
- })
745
- .strict();
746
-
747
- // Crew target (Section 22). Multi-role agent runtime; each role is an
748
- // `agent`-shaped block (model + instructions + tools); `entry` names the
749
- // first-active role; optional `routing` block carries either `match`
750
- // rules or `llm` directive (lower-time placeholder for an LLM-backed
751
- // router; runtime falls back to "no router" when the target shape lands
752
- // on the codegen path).
753
- const crewRoleSchema = z
754
- .object({
755
- instructions: z.string().min(1),
756
- model: z.string().min(1).optional(),
757
- tools: z.array(z.string().min(1)).optional(),
758
- tool_config: toolConfigBlock,
759
- sub_agents: subAgentsBlock,
760
- })
761
- .strict();
762
-
763
- const crewRoutingMatchEntrySchema = z
764
- .object({
765
- contains: z.string().min(1),
766
- to: z.string().min(1),
767
- })
768
- .strict();
769
-
770
- const crewRoutingSchema = z
771
- .object({
772
- kind: z.enum(["match", "llm"]),
773
- match: z.record(z.string().min(1), z.array(crewRoutingMatchEntrySchema).min(1)).optional(),
774
- })
775
- .strict();
776
-
777
- const crewSchema = z
778
- .object({
779
- name: safeName,
780
- target: z.literal("crew"),
781
- /** Crew-wide model fallback used by any role that omits `role.model`. */
782
- model: z.string().min(1),
783
- entry: z.string().min(1),
784
- roles: z.record(safeName, crewRoleSchema),
785
- routing: crewRoutingSchema.optional(),
786
- mcp_servers: mcpServersBlock,
787
- permissions: permissionsBlock,
788
- compaction: compactionBlock,
789
- failure_taxonomy: failureTaxonomyBlock,
790
- chains: chainsBlock,
791
- wallets: walletsBlock,
792
- contracts: contractsBlock,
793
- transaction_policy: transactionPolicyBlock,
794
- })
795
- .strict();
796
- // `.refine()` on a discriminatedUnion member would change the type from
797
- // ZodObject to ZodEffects (incompatible with the union); the
798
- // "entry-in-roles" + "non-empty roles" cross-field checks live in
799
- // `parseSpec` below as a post-parse pass.
800
-
801
- // Research target (Section 23 RES). The compiled daemon decomposes
802
- // `goal` into `branchingFactor` sub-questions, runs one agent loop per
803
- // branch, and writes a numbered-citation report under
804
- // `.crewhaus/research/<runId>/`.
805
- const researchRetrieveSchema = z
806
- .object({
807
- allowedOrigins: z.array(z.string().min(1)).default([]),
808
- allowedFileRoots: z.array(z.string().min(1)).default([]),
809
- vectorBackend: z.enum(VECTOR_BACKENDS).optional(),
810
- })
811
- .strict();
812
-
813
- const researchSchema = z
814
- .object({
815
- name: safeName,
816
- target: z.literal("research"),
817
- agent: z
818
- .object({
819
- model: z.string().min(1),
820
- instructions: z.string().min(1),
821
- })
822
- .strict(),
823
- goal: z.string().min(1),
824
- branchingFactor: z.number().int().min(1).max(8).default(3),
825
- maxDurationMs: z.number().int().positive().default(300_000),
826
- retrieve: researchRetrieveSchema.default({}),
827
- tools: z.array(z.string().min(1)).optional(),
828
- tool_config: toolConfigBlock,
829
- mcp_servers: mcpServersBlock,
830
- permissions: permissionsBlock,
831
- compaction: compactionBlock,
832
- failure_taxonomy: failureTaxonomyBlock,
833
- chains: chainsBlock,
834
- wallets: walletsBlock,
835
- contracts: contractsBlock,
836
- transaction_policy: transactionPolicyBlock,
837
- })
838
- .strict();
839
-
840
- // Batch target (Section 23 BATCH). Queue-worker daemon: pulls jobs
841
- // from `queue`, runs the agent on each input, dedups via idempotency
842
- // keys. v0 ships an in-memory adapter for tests + smoke; SQS / Redis
843
- // Streams / Postgres adapters land in follow-up PRs.
844
- const batchQueueSchema = z
845
- .object({
846
- adapter: z.enum(["in-memory", "sqs", "redis-streams", "postgres"]),
847
- visibilityTimeoutMs: z.number().int().positive().default(30_000),
848
- visibilityRenewIntervalMs: z.number().int().positive().optional(),
849
- maxRetries: z.number().int().min(1).max(10).default(3),
850
- seedJobs: z.array(z.string().min(1)).optional(),
851
- })
852
- .strict();
853
-
854
- const batchSchema = z
855
- .object({
856
- name: safeName,
857
- target: z.literal("batch"),
858
- agent: z
859
- .object({
860
- model: z.string().min(1),
861
- instructions: z.string().min(1),
862
- })
863
- .strict(),
864
- queue: batchQueueSchema,
865
- concurrency: z.number().int().min(1).max(64).default(4),
866
- idempotencyWindowMs: z.number().int().positive().default(60_000),
867
- tools: z.array(z.string().min(1)).optional(),
868
- tool_config: toolConfigBlock,
869
- mcp_servers: mcpServersBlock,
870
- permissions: permissionsBlock,
871
- compaction: compactionBlock,
872
- failure_taxonomy: failureTaxonomyBlock,
873
- chains: chainsBlock,
874
- wallets: walletsBlock,
875
- contracts: contractsBlock,
876
- transaction_policy: transactionPolicyBlock,
877
- })
878
- .strict();
879
-
880
- // Voice target (Section 24 VOICE). Realtime audio agent.
881
- const voiceBlockSchema = z
882
- .object({
883
- provider: z.enum(["openai", "vapi"]),
884
- voiceId: z.string().min(1).default("alloy"),
885
- vad: z.enum(["server", "none"]).default("server"),
886
- bargeInTriggerFrames: z.number().int().min(1).max(20).default(4),
887
- bargeInWindowMs: z.number().int().min(60).max(2000).default(200),
888
- })
889
- .strict();
890
-
891
- const voiceTelephonySchema = z
892
- .object({
893
- provider: z.enum(["twilio", "livekit-sip", "in-memory"]),
894
- })
895
- .strict();
896
-
897
- const voiceSchema = z
898
- .object({
899
- name: safeName,
900
- target: z.literal("voice"),
901
- agent: z
902
- .object({
903
- model: z.string().min(1),
904
- instructions: z.string().min(1),
905
- })
906
- .strict(),
907
- voice: voiceBlockSchema,
908
- telephony: voiceTelephonySchema.optional(),
909
- tools: z.array(z.string().min(1)).optional(),
910
- tool_config: toolConfigBlock,
911
- mcp_servers: mcpServersBlock,
912
- permissions: permissionsBlock,
913
- compaction: compactionBlock,
914
- failure_taxonomy: failureTaxonomyBlock,
915
- })
916
- .strict();
917
-
918
- // Browser target (Section 25 BROW). Computer-use / browser-driver agent.
919
- const browserDriverSchema = z
920
- .object({
921
- backend: z.enum(["host", "chromium", "remote"]).default("chromium"),
922
- viewport: z
923
- .object({
924
- width: z.number().int().positive().default(1280),
925
- height: z.number().int().positive().default(720),
926
- })
927
- .strict()
928
- .default({ width: 1280, height: 720 }),
929
- startUrl: z.string().url().optional(),
930
- })
931
- .strict();
932
-
933
- const browserSchema = z
934
- .object({
935
- name: safeName,
936
- target: z.literal("browser"),
937
- agent: z
938
- .object({
939
- model: z.string().min(1),
940
- instructions: z.string().min(1),
941
- })
942
- .strict(),
943
- driver: browserDriverSchema.default({}),
944
- /** Vision-grounding model. Defaults to the agent's primary model. */
945
- groundingModel: z.string().min(1).optional(),
946
- tools: z.array(z.string().min(1)).optional(),
947
- tool_config: toolConfigBlock,
948
- mcp_servers: mcpServersBlock,
949
- permissions: permissionsBlock,
950
- compaction: compactionBlock,
951
- failure_taxonomy: failureTaxonomyBlock,
952
- })
953
- .strict();
954
-
955
- /**
956
- * Section 29 — `target: "eval"` — the EVAL target shape. A spec carries an
957
- * agent definition, a dataset reference (resolved via §29 dataset-registry),
958
- * a list of grader names (resolved via §29 grader-registry), concurrency
959
- * and seed knobs. The compiled bundle boots dataset-registry +
960
- * grader-registry + eval-runner and writes results to
961
- * `.crewhaus/evals/<runId>/`.
962
- */
963
- const evalSchema = z
964
- .object({
965
- name: safeName,
966
- target: z.literal("eval"),
967
- agent: z
968
- .object({
969
- model: z.string().min(1),
970
- instructions: z.string().min(1),
971
- tools: z.array(z.string().min(1)).optional(),
972
- })
973
- .strict(),
974
- dataset: z
975
- .object({
976
- name: safeName,
977
- version: z.string().min(1),
978
- split: z.enum(["train", "dev", "test"]).default("dev"),
979
- })
980
- .strict(),
981
- graders: z
982
- .array(
983
- z
984
- .object({
985
- name: safeName,
986
- opts: z.record(z.unknown()).optional(),
987
- })
988
- .strict(),
989
- )
990
- .min(1),
991
- concurrency: z.number().int().min(1).default(4),
992
- seed: z.number().int().optional(),
993
- failure_taxonomy: failureTaxonomyBlock,
994
- })
995
- .strict();
996
-
997
- /**
998
- * Section 47 — `onchain` target. Long-running event-driven daemon.
999
- * Triggers fire on contract events / block scans / address watches;
1000
- * each trigger runs one agent turn with the decoded payload as the
1001
- * user message. Wallets + transaction_policy let the agent respond
1002
- * with signed transactions (escrow release, treasury rebalance, etc).
1003
- */
1004
- const onchainTriggerSchema = z.discriminatedUnion("kind", [
1005
- z
1006
- .object({
1007
- kind: z.literal("event"),
1008
- chainId: z.string().min(1),
1009
- contract: z.string().min(1),
1010
- event: z.string().min(1),
1011
- filter: z.record(z.string(), z.unknown()).optional(),
1012
- })
1013
- .strict(),
1014
- z
1015
- .object({
1016
- kind: z.literal("block"),
1017
- chainId: z.string().min(1),
1018
- scanIntervalMs: z.number().int().min(1000).max(3_600_000),
1019
- })
1020
- .strict(),
1021
- z
1022
- .object({
1023
- kind: z.literal("address"),
1024
- chainId: z.string().min(1),
1025
- address: z.string().min(1),
1026
- direction: z.enum(["in", "out", "both"]).default("both"),
1027
- })
1028
- .strict(),
1029
- ]);
1030
-
1031
- const onchainSchema = z
1032
- .object({
1033
- name: safeName,
1034
- target: z.literal("onchain"),
1035
- agent: z
1036
- .object({
1037
- model: z.string().min(1),
1038
- instructions: z.string().min(1),
1039
- })
1040
- .strict(),
1041
- chains: z.array(chainBindingSchema).min(1),
1042
- wallets: z.array(walletBindingSchema).default([]),
1043
- contracts: z.array(contractBindingSchema).default([]),
1044
- transaction_policy: transactionPolicySchema.default({
1045
- defaultWriteApproval: "required",
1046
- allowedContracts: [],
1047
- simulationRequired: true,
1048
- }),
1049
- triggers: z.array(onchainTriggerSchema).min(1),
1050
- idempotencyWindowMs: z.number().int().positive().default(60_000),
1051
- tools: z.array(z.string().min(1)).optional(),
1052
- tool_config: toolConfigBlock,
1053
- mcp_servers: mcpServersBlock,
1054
- permissions: permissionsBlock,
1055
- compaction: compactionBlock,
1056
- failure_taxonomy: failureTaxonomyBlock,
1057
- })
1058
- .strict();
1059
-
1060
- /**
1061
- * Section 47 — `onchain-game` target. Perceive-act-perceive loop
1062
- * against a game contract: read state via `stateReader`, ask the model
1063
- * for a move, broadcast it as a transaction, await confirmation,
1064
- * re-read state. Single chain, single wallet.
1065
- */
1066
- const onchainGameSchema = z
1067
- .object({
1068
- name: safeName,
1069
- target: z.literal("onchain-game"),
1070
- agent: z
1071
- .object({
1072
- model: z.string().min(1),
1073
- instructions: z.string().min(1),
1074
- })
1075
- .strict(),
1076
- chain: chainBindingSchema,
1077
- wallet: walletBindingSchema,
1078
- game: z
1079
- .object({
1080
- contract: contractBindingSchema,
1081
- stateReader: z.string().min(1),
1082
- actionsContract: z.string().min(1).optional(),
1083
- turnSemantics: z.enum(["turn-based", "real-time", "async"]).default("turn-based"),
1084
- moveTimeoutMs: z.number().int().positive().optional(),
1085
- objective: z.string().min(1).optional(),
1086
- })
1087
- .strict(),
1088
- transaction_policy: transactionPolicySchema.default({
1089
- defaultWriteApproval: "required",
1090
- allowedContracts: [],
1091
- simulationRequired: true,
1092
- }),
1093
- tools: z.array(z.string().min(1)).optional(),
1094
- tool_config: toolConfigBlock,
1095
- mcp_servers: mcpServersBlock,
1096
- permissions: permissionsBlock,
1097
- compaction: compactionBlock,
1098
- failure_taxonomy: failureTaxonomyBlock,
1099
- })
1100
- .strict();
1101
-
1102
- export const Spec = z.discriminatedUnion("target", [
1103
- cliSchema,
1104
- workflowSchema,
1105
- channelSchema,
1106
- graphSchema,
1107
- managedSchema,
1108
- pipelineSchema,
1109
- crewSchema,
1110
- researchSchema,
1111
- batchSchema,
1112
- voiceSchema,
1113
- browserSchema,
1114
- evalSchema,
1115
- onchainSchema,
1116
- onchainGameSchema,
1117
- ]);
1118
-
1119
- export type Spec = z.infer<typeof Spec>;
1120
- export type SpecCli = z.infer<typeof cliSchema>;
1121
- export type SpecWorkflow = z.infer<typeof workflowSchema>;
1122
- export type SpecWorkflowStep = z.infer<typeof workflowStepSchema>;
1123
- export type SpecChannel = z.infer<typeof channelSchema>;
1124
- export type SpecChannelAgent = z.infer<typeof channelAgentSchema>;
1125
- export type SpecSlackChannel = z.infer<typeof slackChannelSchema>;
1126
- export type SpecTelegramChannel = z.infer<typeof telegramChannelSchema>;
1127
- export type SpecDiscordChannel = z.infer<typeof discordChannelSchema>;
1128
- export type SpecWhatsAppChannel = z.infer<typeof whatsappChannelSchema>;
1129
- export type SpecIMessageChannel = z.infer<typeof imessageChannelSchema>;
1130
- export type SpecGraph = z.infer<typeof graphSchema>;
1131
- export type SpecGraphNode = z.infer<typeof graphNodeSchema>;
1132
- export type SpecGraphEdge = z.infer<typeof graphEdgeSchema>;
1133
- export type SpecManaged = z.infer<typeof managedSchema>;
1134
- export type SpecManagedTenant = z.infer<typeof managedTenantSchema>;
1135
- export type SpecPipeline = z.infer<typeof pipelineSchema>;
1136
- export type SpecPipelineDocument = z.infer<typeof pipelineDocumentSchema>;
1137
- export type SpecCrew = z.infer<typeof crewSchema>;
1138
- export type SpecCrewRole = z.infer<typeof crewRoleSchema>;
1139
- export type SpecCrewRouting = z.infer<typeof crewRoutingSchema>;
1140
- export type SpecResearch = z.infer<typeof researchSchema>;
1141
- export type SpecResearchRetrieve = z.infer<typeof researchRetrieveSchema>;
1142
- export type SpecBatch = z.infer<typeof batchSchema>;
1143
- export type SpecBatchQueue = z.infer<typeof batchQueueSchema>;
1144
- export type SpecOnchain = z.infer<typeof onchainSchema>;
1145
- export type SpecOnchainGame = z.infer<typeof onchainGameSchema>;
1146
- export type SpecChainTrigger = z.infer<typeof onchainTriggerSchema>;
1147
- export type SpecVoice = z.infer<typeof voiceSchema>;
1148
- export type SpecVoiceBlock = z.infer<typeof voiceBlockSchema>;
1149
- export type SpecVoiceTelephony = z.infer<typeof voiceTelephonySchema>;
1150
- export type SpecBrowser = z.infer<typeof browserSchema>;
1151
- export type SpecBrowserDriver = z.infer<typeof browserDriverSchema>;
1152
- export type SpecEval = z.infer<typeof evalSchema>;
1153
- export type SpecMcpServerConfig = z.infer<typeof mcpServerConfigSchema>;
1154
- export type SpecSubAgentDefinition = z.infer<typeof subAgentDefinitionSchema>;
1155
- export type SpecCompactionBlock = z.infer<typeof compactionBlock>;
1156
- export type SpecSecurityBlock = z.infer<typeof securityBlock>;
1157
- export type SpecFailureTaxonomyEntry = z.infer<typeof failureTaxonomyEntrySchema>;
1158
- export type SpecFailureTaxonomy = z.infer<typeof failureTaxonomyBlock>;
1159
-
1160
- export { SpecParseError };
1161
-
1162
- export function parseSpec(yamlText: string): Spec {
1163
- let raw: unknown;
1164
- try {
1165
- raw = parseYaml(yamlText);
1166
- } catch (err) {
1167
- throw new SpecParseError("invalid YAML", err);
1168
- }
1169
-
1170
- // Friendly early-rejection for `permissions.mode: bypass` so the error
1171
- // message names the actual security policy rather than a Zod enum mismatch.
1172
- // The Zod schema also excludes "bypass" from its enum (defense in depth).
1173
- if (typeof raw === "object" && raw !== null && "permissions" in raw) {
1174
- const perms = (raw as { permissions?: unknown }).permissions;
1175
- if (typeof perms === "object" && perms !== null && "mode" in perms) {
1176
- const mode = (perms as { mode?: unknown }).mode;
1177
- if (mode === "bypass") {
1178
- throw new SpecParseError(
1179
- "permissions.mode: bypass is rejected — bypass mode is only available via the --permission-mode CLI flag, never from a spec file",
1180
- );
1181
- }
1182
- }
1183
- }
1184
-
1185
- const result = Spec.safeParse(raw);
1186
- if (!result.success) {
1187
- throw new SpecParseError(
1188
- `spec validation failed:\n${result.error.issues
1189
- .map((i) => ` ${i.path.join(".") || "<root>"}: ${i.message}`)
1190
- .join("\n")}`,
1191
- result.error,
1192
- );
1193
- }
1194
- // Section 22 — crew cross-field invariants. Kept here rather than as
1195
- // `.refine()`s on the schema so the discriminated-union member stays
1196
- // a plain ZodObject (Zod's discriminatedUnion rejects ZodEffects).
1197
- const data = result.data;
1198
- if (data.target === "crew") {
1199
- const roleNames = Object.keys(data.roles);
1200
- if (roleNames.length === 0) {
1201
- throw new SpecParseError("crew target requires at least one role");
1202
- }
1203
- if (!roleNames.includes(data.entry)) {
1204
- throw new SpecParseError(
1205
- `crew.entry "${data.entry}" must name one of crew.roles (got: ${roleNames.join(", ")})`,
1206
- );
1207
- }
1208
- if (data.routing !== undefined && data.routing.kind === "match" && data.routing.match) {
1209
- for (const [from, rules] of Object.entries(data.routing.match)) {
1210
- if (!roleNames.includes(from)) {
1211
- throw new SpecParseError(`crew.routing.match["${from}"]: source role not in crew.roles`);
1212
- }
1213
- for (const rule of rules) {
1214
- if (!roleNames.includes(rule.to)) {
1215
- throw new SpecParseError(
1216
- `crew.routing.match["${from}"].to = "${rule.to}" — target role not in crew.roles`,
1217
- );
1218
- }
1219
- }
1220
- }
1221
- }
1222
- }
1223
- // Section 21 — pipeline HTTP-backend invariants. qdrant/pinecone/weaviate
1224
- // throw at construction without a url + collection, so selecting one
1225
- // without both would emit an unrunnable bundle. Reject at parse time with
1226
- // a message naming the missing field (kept here, not as a `.refine()`, so
1227
- // the discriminated-union member stays a plain ZodObject).
1228
- if (data.target === "pipeline" && HTTP_VECTOR_BACKENDS.has(data.retrieve.vectorBackend)) {
1229
- const { vectorBackend, url, collection } = data.retrieve;
1230
- if (!url) {
1231
- throw new SpecParseError(
1232
- `pipeline retrieve.vectorBackend "${vectorBackend}" requires retrieve.url (the remote service base URL)`,
1233
- );
1234
- }
1235
- if (!collection) {
1236
- throw new SpecParseError(
1237
- `pipeline retrieve.vectorBackend "${vectorBackend}" requires retrieve.collection`,
1238
- );
1239
- }
1240
- }
1241
- return data;
1242
- }