@crewhaus/spec 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (3) hide show
  1. package/package.json +43 -0
  2. package/src/index.test.ts +1011 -0
  3. package/src/index.ts +1035 -0
package/src/index.ts ADDED
@@ -0,0 +1,1035 @@
1
+ import { SpecParseError } from "@crewhaus/errors";
2
+ import { parse as parseYaml } from "yaml";
3
+ import { z } from "zod";
4
+
5
+ /**
6
+ * v0 spec schema — a discriminated union over `target`.
7
+ *
8
+ * - `cli`: a single streaming-chat agent (Section 1–5).
9
+ * - `workflow`: a sequence of named steps run in order, threading the prior
10
+ * step's final assistant text into the next step's user message (Section 6).
11
+ * - `channel`: a long-running daemon that listens for inbound channel events
12
+ * (Slack today, more channels later) and runs one agent turn per inbound
13
+ * message, threaded by the routing key. Section 12.
14
+ *
15
+ * Will grow into the full catalog spec (eval, deploy) — see
16
+ * docs/MODULE-CATALOG.md PART A Layer F1.
17
+ */
18
+
19
+ // Permissions block (Section 7). SECURITY: `mode: "bypass"` is intentionally
20
+ // absent from the enum — bypass can only enter the system via the CLI flag.
21
+ // Defense in depth: parse-time and runtime checks both reject it.
22
+ const permissionRuleSchema = z
23
+ .object({
24
+ type: z.enum(["alwaysAllow", "alwaysDeny", "alwaysAsk"]),
25
+ pattern: z.string().min(1),
26
+ })
27
+ .strict();
28
+
29
+ const permissionsBlock = z
30
+ .object({
31
+ mode: z.enum(["default", "plan", "auto"]).optional(),
32
+ rules: z.array(permissionRuleSchema).optional(),
33
+ })
34
+ .strict()
35
+ .optional();
36
+
37
+ // MCP servers block (Section 9). Discriminated on `transport` so unknown
38
+ // configs surface as a clear "Invalid literal value" error rather than a
39
+ // confusing union-of-rejections.
40
+ const stdioMcpConfig = z
41
+ .object({
42
+ transport: z.literal("stdio"),
43
+ command: z.string().min(1),
44
+ args: z.array(z.string()).optional(),
45
+ env: z.record(z.string()).optional(),
46
+ })
47
+ .strict();
48
+
49
+ const sseMcpConfig = z
50
+ .object({
51
+ transport: z.literal("sse"),
52
+ url: z.string().url(),
53
+ headers: z.record(z.string()).optional(),
54
+ })
55
+ .strict();
56
+
57
+ const mcpServerConfigSchema = z.discriminatedUnion("transport", [stdioMcpConfig, sseMcpConfig]);
58
+
59
+ const mcpServersBlock = z.record(z.string().min(1), mcpServerConfigSchema).optional();
60
+
61
+ // Section 13 — sub-agent definitions. Inline on the agent block (cli +
62
+ // channel today; workflow has no agent block). The map's key is the
63
+ // `subagent_type` users pass to the Task tool. Permissions field mirrors
64
+ // the runtime's resolution shape.
65
+ const subAgentDefinitionSchema = z
66
+ .object({
67
+ description: z.string().min(1),
68
+ instructions: z.string().min(1),
69
+ tools: z.array(z.string().min(1)).optional(),
70
+ model: z.string().min(1).optional(),
71
+ permissions: z
72
+ .union([
73
+ z.enum(["inherit", "scoped"]),
74
+ z
75
+ .object({
76
+ allow: z.array(z.string().min(1)),
77
+ deny: z.array(z.string().min(1)),
78
+ })
79
+ .strict(),
80
+ ])
81
+ .optional(),
82
+ inherit_bypass: z.boolean().optional(),
83
+ })
84
+ .strict();
85
+
86
+ const subAgentsBlock = z.record(z.string().min(1), subAgentDefinitionSchema).optional();
87
+
88
+ /**
89
+ * Section 14 — per-tool runtime config map. Tool-specific schemas live
90
+ * inside each tool package; the spec layer treats every value as opaque
91
+ * `unknown` and forwards it verbatim to the IR. The codegen layer emits
92
+ * an init call (e.g. `registerFetchConfig({ ... })`) for tools whose
93
+ * BUILTIN_TOOL_MAP entry declares an `initSymbol`.
94
+ */
95
+ const toolConfigBlock = z.record(z.string().min(1), z.unknown()).optional();
96
+
97
+ /**
98
+ * Section 17 — optional override for the model used by
99
+ * `compaction-autocompact` when summarising long conversations. Defaults
100
+ * to the agent's primary model when omitted, but you can target a
101
+ * cheaper/faster model (or a different provider) for compaction.
102
+ */
103
+ const compactionBlock = z
104
+ .object({
105
+ model: z.string().min(1).optional(),
106
+ /** Pillar 2 — opt in to the pre-compaction curator pass. Defaults
107
+ * to `false` when omitted; the IR carries the user's choice
108
+ * verbatim so target emitters can wire `@crewhaus/compaction-curator`
109
+ * on the runtime path. See docs/MODULE-CATALOG.md R6 + recipe 52. */
110
+ curate: z.boolean().optional(),
111
+ /** Cosine-similarity threshold above which two items are considered
112
+ * duplicates by the curator. Defaults to 0.92 in the curator
113
+ * itself; spec-level override targets per-corpus tuning. Must be
114
+ * in (0, 1] — values outside that range can't be cosine outputs. */
115
+ dedupeThreshold: z.number().gt(0).lte(1).optional(),
116
+ /** Max items the curator keeps after the relevance reorder. When
117
+ * omitted, the curator only reorders (no top-K trim). Spec-level
118
+ * override is the natural knob for RAG pipelines that want a hard
119
+ * cap on retrieved chunks per turn. */
120
+ relevanceTopK: z.number().int().positive().optional(),
121
+ })
122
+ .strict()
123
+ .optional();
124
+
125
+ /**
126
+ * Section 55 (Track A) — named failure taxonomy. Cross-cutting block
127
+ * available on every target shape. Each entry names a failure class and
128
+ * tells the recovery engine which `RecoveryAction` to take when the
129
+ * pattern matches an error's `message`. Optional `hint` is surfaced to
130
+ * the model as a one-shot system message on `continue`/`retry` recovery.
131
+ *
132
+ * Source: Natural-Language Agent Harnesses (arxiv 2603.25723, Tsinghua,
133
+ * March 2026) names failure_taxonomy as one of the six components a
134
+ * portable harness must expose. Cited paper: NLAH (arxiv 2603.25723).
135
+ */
136
+ const failureTaxonomyEntrySchema = z
137
+ .object({
138
+ class: z.string().min(1),
139
+ pattern: z.string().min(1),
140
+ recovery: z.enum(["retry", "compact", "continue", "tombstone", "fail"]),
141
+ hint: z.string().min(1).optional(),
142
+ })
143
+ .strict();
144
+
145
+ const failureTaxonomyBlock = z.array(failureTaxonomyEntrySchema).optional();
146
+
147
+ /**
148
+ * Section 47 — blockchain subsystem blocks (cross-cutting). Any shape may
149
+ * declare any subset of `chains` / `wallets` / `contracts` /
150
+ * `transaction_policy`. Authoring rules:
151
+ * - `chains[]`: at least one when other blocks are present.
152
+ * - `wallets[]`: every entry references a declared `chains[].id`.
153
+ * - `contracts[]`: every entry references a declared `chains[].id`.
154
+ * - `transaction_policy`: enforced by §47 IR pass at compile time;
155
+ * entries in `allowed_contracts` must reference declared `contracts[].id`.
156
+ * Per-field semantics mirror the IR variants in `@crewhaus/ir`.
157
+ */
158
+ const chainFinalitySchema = z.discriminatedUnion("kind", [
159
+ z
160
+ .object({
161
+ kind: z.literal("confirmations"),
162
+ count: z.number().int().min(0).max(256),
163
+ })
164
+ .strict(),
165
+ z.object({ kind: z.literal("finalized") }).strict(),
166
+ z.object({ kind: z.literal("safe") }).strict(),
167
+ ]);
168
+
169
+ const chainBindingSchema = z
170
+ .object({
171
+ id: z.string().min(1),
172
+ kind: z.literal("evm"),
173
+ rpcUrls: z.array(z.string().min(1)).min(1),
174
+ rpcPolicy: z.enum(["single", "quorum", "fallback"]).default("single"),
175
+ finality: chainFinalitySchema,
176
+ reorgTolerant: z.boolean().default(true),
177
+ })
178
+ .strict();
179
+
180
+ const walletBindingSchema = z
181
+ .object({
182
+ id: z.string().min(1),
183
+ chainId: z.string().min(1),
184
+ custody: z.enum(["user-controlled", "kms", "hsm", "local"]),
185
+ signingPolicy: z
186
+ .enum(["explicit-user-approval", "policy-gated", "automated"])
187
+ .default("explicit-user-approval"),
188
+ keyRef: z.string().min(1).optional(),
189
+ })
190
+ .strict();
191
+
192
+ const contractBindingSchema = z
193
+ .object({
194
+ id: z.string().min(1),
195
+ chainId: z.string().min(1),
196
+ address: z.string().min(1),
197
+ abiRef: z.string().min(1),
198
+ })
199
+ .strict();
200
+
201
+ const transactionPolicySchema = z
202
+ .object({
203
+ defaultWriteApproval: z.enum(["required", "policy", "none"]).default("required"),
204
+ maxValueUsd: z.number().positive().optional(),
205
+ allowedContracts: z.array(z.string().min(1)).default([]),
206
+ simulationRequired: z.boolean().default(true),
207
+ })
208
+ .strict();
209
+
210
+ const chainsBlock = z.array(chainBindingSchema).optional();
211
+ const walletsBlock = z.array(walletBindingSchema).optional();
212
+ const contractsBlock = z.array(contractBindingSchema).optional();
213
+ const transactionPolicyBlock = transactionPolicySchema.optional();
214
+
215
+ /**
216
+ * Phase 3 §3.3 — CLI banner with optional tagline rotation. When set,
217
+ * the compiled cli-target bundle prints this banner on cold start
218
+ * (suppressed under `--resume` / `--continue` so resumed sessions
219
+ * don't re-banner). Static mode picks the first tagline; random mode
220
+ * picks one uniformly per startup.
221
+ */
222
+ const cliBannerBlock = z
223
+ .object({
224
+ taglineMode: z.enum(["static", "random"]).default("static"),
225
+ taglines: z.array(z.string().min(1)).min(1),
226
+ })
227
+ .strict()
228
+ .optional();
229
+
230
+ const cliOptionsBlock = z
231
+ .object({
232
+ banner: cliBannerBlock,
233
+ /**
234
+ * Phase 2 M2.2 — TUI polish gate. "basic" is the current readline-
235
+ * driven REPL; "rich" is reserved for future Ink-based output
236
+ * (status line, multi-line input, ESC interrupt). Today both modes
237
+ * compile identically; the field is forward-compatible.
238
+ */
239
+ tui: z.enum(["basic", "rich"]).default("basic"),
240
+ })
241
+ .strict()
242
+ .optional();
243
+
244
+ /**
245
+ * Phase 3 §3.1 — heartbeat scheduled wake for channel daemons. When
246
+ * present, target-channel-bot emits a setInterval loop that
247
+ * synthesises a heartbeat turn at the configured interval. The
248
+ * `every` field accepts a duration string (e.g. "2h", "30m", "60s").
249
+ * `instructions` is what the runtime sends as the synthetic user
250
+ * message at each tick; pair with HEARTBEAT.md in cwd for richer
251
+ * playbook reads.
252
+ */
253
+ const HEARTBEAT_DURATION_REGEX = /^\d+(?:ms|s|m|h)$/;
254
+
255
+ const heartbeatBlock = z
256
+ .object({
257
+ every: z
258
+ .string()
259
+ .regex(
260
+ HEARTBEAT_DURATION_REGEX,
261
+ 'heartbeat.every must be a duration like "2h", "30m", "60s", or "500ms"',
262
+ ),
263
+ instructions: z.string().min(1),
264
+ })
265
+ .strict()
266
+ .optional();
267
+
268
+ /**
269
+ * Phase 3 §3.4 — channel daemon control-UI gateway. When set, the
270
+ * compiled daemon spawns a second HTTP listener on `port` that serves
271
+ * a status endpoint (and, when `ui: true`, a minimal dashboard).
272
+ * Mirrors OpenClaw's Gateway control plane in concept; ours starts
273
+ * minimal and is intended to host packaged Studio UI in a follow-up.
274
+ */
275
+ const channelGatewayBlock = z
276
+ .object({
277
+ port: z.number().int().min(1).max(65535),
278
+ ui: z.boolean().default(false),
279
+ })
280
+ .strict()
281
+ .optional();
282
+
283
+ const cliSchema = z
284
+ .object({
285
+ name: z.string().min(1),
286
+ target: z.literal("cli"),
287
+ agent: z
288
+ .object({
289
+ model: z.string().min(1),
290
+ instructions: z.string().min(1),
291
+ sub_agents: subAgentsBlock,
292
+ })
293
+ .strict(),
294
+ tools: z.array(z.string().min(1)).optional(),
295
+ tool_config: toolConfigBlock,
296
+ mcp_servers: mcpServersBlock,
297
+ permissions: permissionsBlock,
298
+ compaction: compactionBlock,
299
+ failure_taxonomy: failureTaxonomyBlock,
300
+ cli: cliOptionsBlock,
301
+ chains: chainsBlock,
302
+ wallets: walletsBlock,
303
+ contracts: contractsBlock,
304
+ transaction_policy: transactionPolicyBlock,
305
+ })
306
+ .strict();
307
+
308
+ const workflowStepSchema = z
309
+ .object({
310
+ name: z.string().min(1),
311
+ instructions: z.string().min(1),
312
+ model: z.string().min(1).optional(),
313
+ tools: z.array(z.string().min(1)).optional(),
314
+ tool_config: toolConfigBlock,
315
+ })
316
+ .strict();
317
+
318
+ const workflowSchema = z
319
+ .object({
320
+ name: z.string().min(1),
321
+ target: z.literal("workflow"),
322
+ model: z.string().min(1),
323
+ steps: z.array(workflowStepSchema).min(1),
324
+ mcp_servers: mcpServersBlock,
325
+ permissions: permissionsBlock,
326
+ compaction: compactionBlock,
327
+ failure_taxonomy: failureTaxonomyBlock,
328
+ chains: chainsBlock,
329
+ wallets: walletsBlock,
330
+ contracts: contractsBlock,
331
+ transaction_policy: transactionPolicyBlock,
332
+ })
333
+ .strict();
334
+
335
+ // Channel target (Section 12). Secret fields (botToken/signingSecret/appToken)
336
+ // are kept as plain strings here; the compiler's `lower()` rewrites strings
337
+ // matching `$VAR_NAME` into env-var references in the IR so the compiled
338
+ // bundle reads `process.env.VAR_NAME` at runtime instead of embedding secrets.
339
+ const slackChannelSchema = z
340
+ .object({
341
+ botToken: z.string().min(1),
342
+ signingSecret: z.string().min(1),
343
+ appToken: z.string().min(1).optional(),
344
+ })
345
+ .strict();
346
+
347
+ const telegramChannelSchema = z
348
+ .object({
349
+ botToken: z.string().min(1),
350
+ secretToken: z.string().min(1),
351
+ })
352
+ .strict();
353
+
354
+ const discordChannelSchema = z
355
+ .object({
356
+ applicationId: z.string().min(1),
357
+ botToken: z.string().min(1),
358
+ publicKeyHex: z.string().min(1),
359
+ })
360
+ .strict();
361
+
362
+ const whatsappChannelSchema = z
363
+ .object({
364
+ phoneNumberId: z.string().min(1),
365
+ accessToken: z.string().min(1),
366
+ appSecret: z.string().min(1),
367
+ })
368
+ .strict();
369
+
370
+ const imessageChannelSchema = z
371
+ .object({
372
+ chatDbPath: z.string().min(1).optional(),
373
+ cursorPath: z.string().min(1).optional(),
374
+ })
375
+ .strict();
376
+
377
+ const channelsBlock = z
378
+ .object({
379
+ slack: slackChannelSchema.optional(),
380
+ telegram: telegramChannelSchema.optional(),
381
+ discord: discordChannelSchema.optional(),
382
+ whatsapp: whatsappChannelSchema.optional(),
383
+ imessage: imessageChannelSchema.optional(),
384
+ })
385
+ .strict()
386
+ .refine(
387
+ (c) =>
388
+ c.slack !== undefined ||
389
+ c.telegram !== undefined ||
390
+ c.discord !== undefined ||
391
+ c.whatsapp !== undefined ||
392
+ c.imessage !== undefined,
393
+ {
394
+ message:
395
+ "channels block requires at least one channel (slack | telegram | discord | whatsapp | imessage)",
396
+ },
397
+ );
398
+
399
+ const routingBlock = z
400
+ .object({
401
+ sessionKey: z.enum(["thread", "user", "channel"]),
402
+ })
403
+ .strict();
404
+
405
+ const channelAgentSchema = z
406
+ .object({
407
+ model: z.string().min(1),
408
+ instructions: z.string().min(1),
409
+ tools: z.array(z.string().min(1)).optional(),
410
+ tool_config: toolConfigBlock,
411
+ sub_agents: subAgentsBlock,
412
+ })
413
+ .strict();
414
+
415
+ const channelSchema = z
416
+ .object({
417
+ name: z.string().min(1),
418
+ target: z.literal("channel"),
419
+ agent: channelAgentSchema,
420
+ channels: channelsBlock,
421
+ routing: routingBlock,
422
+ mcp_servers: mcpServersBlock,
423
+ permissions: permissionsBlock,
424
+ compaction: compactionBlock,
425
+ failure_taxonomy: failureTaxonomyBlock,
426
+ heartbeat: heartbeatBlock,
427
+ gateway: channelGatewayBlock,
428
+ chains: chainsBlock,
429
+ wallets: walletsBlock,
430
+ contracts: contractsBlock,
431
+ transaction_policy: transactionPolicyBlock,
432
+ })
433
+ .strict();
434
+
435
+ // Graph target (Section 19) — stateful DAG runtime. Nodes are LLM-backed
436
+ // invocations; edges link nodes; HITL pauses interrupt the run on
437
+ // `requestApproval()`. Each node may have its own model + tools.
438
+ const graphNodeSchema = z
439
+ .object({
440
+ instructions: z.string().min(1),
441
+ model: z.string().min(1).optional(),
442
+ tools: z.array(z.string().min(1)).optional(),
443
+ tool_config: toolConfigBlock,
444
+ /**
445
+ * When true, the node calls `ctx.requestApproval(prompt)` before
446
+ * returning. The engine pauses, persists a checkpoint, and waits for
447
+ * `resume(checkpointId, decision)` from the operator/CLI.
448
+ */
449
+ hitl: z
450
+ .object({
451
+ prompt: z.string().min(1),
452
+ })
453
+ .strict()
454
+ .optional(),
455
+ })
456
+ .strict();
457
+
458
+ const graphEdgeSchema = z
459
+ .object({
460
+ from: z.string().min(1),
461
+ to: z.string().min(1),
462
+ })
463
+ .strict();
464
+
465
+ const graphSchema = z
466
+ .object({
467
+ name: z.string().min(1),
468
+ target: z.literal("graph"),
469
+ model: z.string().min(1),
470
+ entry: z.string().min(1),
471
+ nodes: z.record(z.string().min(1), graphNodeSchema),
472
+ edges: z.array(graphEdgeSchema).default([]),
473
+ permissions: permissionsBlock,
474
+ compaction: compactionBlock,
475
+ failure_taxonomy: failureTaxonomyBlock,
476
+ chains: chainsBlock,
477
+ wallets: walletsBlock,
478
+ contracts: contractsBlock,
479
+ transaction_policy: transactionPolicyBlock,
480
+ })
481
+ .strict();
482
+
483
+ // Managed daemon target (Section 20). Multi-tenant gateway with
484
+ // per-tenant budgets + policy overrides; emitted bundle is daemon.ts +
485
+ // agent.ts. Authentication is HS256 JWT — the signing secret enters
486
+ // via env at boot, not via the spec.
487
+ const managedTenantSchema = z
488
+ .object({
489
+ id: z.string().min(1),
490
+ budget: z
491
+ .object({
492
+ maxInputTokens: z.number().int().positive(),
493
+ maxOutputTokens: z.number().int().positive(),
494
+ })
495
+ .strict(),
496
+ })
497
+ .strict();
498
+
499
+ const managedAgentSchema = z
500
+ .object({
501
+ model: z.string().min(1),
502
+ instructions: z.string().min(1),
503
+ })
504
+ .strict();
505
+
506
+ const managedSchema = z
507
+ .object({
508
+ name: z.string().min(1),
509
+ target: z.literal("managed"),
510
+ agent: managedAgentSchema,
511
+ tenants: z.array(managedTenantSchema).min(1),
512
+ permissions: permissionsBlock,
513
+ compaction: compactionBlock,
514
+ failure_taxonomy: failureTaxonomyBlock,
515
+ })
516
+ .strict();
517
+
518
+ // Pipeline / RAG target (Section 21). Carries the embedder + vector-store
519
+ // config, an indexing pipeline, and a chat agent that uses Retrieve.
520
+ const pipelineDocumentSchema = z
521
+ .object({
522
+ id: z.string().min(1),
523
+ text: z.string().min(1),
524
+ metadata: z.record(z.string(), z.unknown()).optional(),
525
+ })
526
+ .strict();
527
+
528
+ const pipelineSchema = z
529
+ .object({
530
+ name: z.string().min(1),
531
+ target: z.literal("pipeline"),
532
+ agent: z
533
+ .object({
534
+ model: z.string().min(1),
535
+ instructions: z.string().min(1),
536
+ })
537
+ .strict(),
538
+ retrieve: z
539
+ .object({
540
+ embedderModel: z.string().min(1),
541
+ vectorBackend: z.enum(["in-memory"]).default("in-memory"),
542
+ defaultK: z.number().int().positive().max(50).default(5),
543
+ })
544
+ .strict(),
545
+ indexing: z
546
+ .object({
547
+ chunkStrategy: z.enum(["fixed", "semantic", "markdown"]).default("fixed"),
548
+ chunkSize: z.number().int().positive().default(400),
549
+ chunkOverlap: z.number().int().nonnegative().default(0),
550
+ documents: z.array(pipelineDocumentSchema).min(1),
551
+ })
552
+ .strict(),
553
+ permissions: permissionsBlock,
554
+ compaction: compactionBlock,
555
+ failure_taxonomy: failureTaxonomyBlock,
556
+ })
557
+ .strict();
558
+
559
+ // Crew target (Section 22). Multi-role agent runtime; each role is an
560
+ // `agent`-shaped block (model + instructions + tools); `entry` names the
561
+ // first-active role; optional `routing` block carries either `match`
562
+ // rules or `llm` directive (lower-time placeholder for an LLM-backed
563
+ // router; runtime falls back to "no router" when the target shape lands
564
+ // on the codegen path).
565
+ const crewRoleSchema = z
566
+ .object({
567
+ instructions: z.string().min(1),
568
+ model: z.string().min(1).optional(),
569
+ tools: z.array(z.string().min(1)).optional(),
570
+ tool_config: toolConfigBlock,
571
+ sub_agents: subAgentsBlock,
572
+ })
573
+ .strict();
574
+
575
+ const crewRoutingMatchEntrySchema = z
576
+ .object({
577
+ contains: z.string().min(1),
578
+ to: z.string().min(1),
579
+ })
580
+ .strict();
581
+
582
+ const crewRoutingSchema = z
583
+ .object({
584
+ kind: z.enum(["match", "llm"]),
585
+ match: z.record(z.string().min(1), z.array(crewRoutingMatchEntrySchema).min(1)).optional(),
586
+ })
587
+ .strict();
588
+
589
+ const crewSchema = z
590
+ .object({
591
+ name: z.string().min(1),
592
+ target: z.literal("crew"),
593
+ /** Crew-wide model fallback used by any role that omits `role.model`. */
594
+ model: z.string().min(1),
595
+ entry: z.string().min(1),
596
+ roles: z.record(z.string().min(1), crewRoleSchema),
597
+ routing: crewRoutingSchema.optional(),
598
+ mcp_servers: mcpServersBlock,
599
+ permissions: permissionsBlock,
600
+ compaction: compactionBlock,
601
+ failure_taxonomy: failureTaxonomyBlock,
602
+ chains: chainsBlock,
603
+ wallets: walletsBlock,
604
+ contracts: contractsBlock,
605
+ transaction_policy: transactionPolicyBlock,
606
+ })
607
+ .strict();
608
+ // `.refine()` on a discriminatedUnion member would change the type from
609
+ // ZodObject to ZodEffects (incompatible with the union); the
610
+ // "entry-in-roles" + "non-empty roles" cross-field checks live in
611
+ // `parseSpec` below as a post-parse pass.
612
+
613
+ // Research target (Section 23 RES). The compiled daemon decomposes
614
+ // `goal` into `branchingFactor` sub-questions, runs one agent loop per
615
+ // branch, and writes a numbered-citation report under
616
+ // `.crewhaus/research/<runId>/`.
617
+ const researchRetrieveSchema = z
618
+ .object({
619
+ allowedOrigins: z.array(z.string().min(1)).default([]),
620
+ allowedFileRoots: z.array(z.string().min(1)).default([]),
621
+ vectorBackend: z.enum(["in-memory"]).optional(),
622
+ })
623
+ .strict();
624
+
625
+ const researchSchema = z
626
+ .object({
627
+ name: z.string().min(1),
628
+ target: z.literal("research"),
629
+ agent: z
630
+ .object({
631
+ model: z.string().min(1),
632
+ instructions: z.string().min(1),
633
+ })
634
+ .strict(),
635
+ goal: z.string().min(1),
636
+ branchingFactor: z.number().int().min(1).max(8).default(3),
637
+ maxDurationMs: z.number().int().positive().default(300_000),
638
+ retrieve: researchRetrieveSchema.default({}),
639
+ tools: z.array(z.string().min(1)).optional(),
640
+ tool_config: toolConfigBlock,
641
+ mcp_servers: mcpServersBlock,
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
+ // Batch target (Section 23 BATCH). Queue-worker daemon: pulls jobs
653
+ // from `queue`, runs the agent on each input, dedups via idempotency
654
+ // keys. v0 ships an in-memory adapter for tests + smoke; SQS / Redis
655
+ // Streams / Postgres adapters land in follow-up PRs.
656
+ const batchQueueSchema = z
657
+ .object({
658
+ adapter: z.enum(["in-memory", "sqs", "redis-streams", "postgres"]),
659
+ visibilityTimeoutMs: z.number().int().positive().default(30_000),
660
+ visibilityRenewIntervalMs: z.number().int().positive().optional(),
661
+ maxRetries: z.number().int().min(1).max(10).default(3),
662
+ seedJobs: z.array(z.string().min(1)).optional(),
663
+ })
664
+ .strict();
665
+
666
+ const batchSchema = z
667
+ .object({
668
+ name: z.string().min(1),
669
+ target: z.literal("batch"),
670
+ agent: z
671
+ .object({
672
+ model: z.string().min(1),
673
+ instructions: z.string().min(1),
674
+ })
675
+ .strict(),
676
+ queue: batchQueueSchema,
677
+ concurrency: z.number().int().min(1).max(64).default(4),
678
+ idempotencyWindowMs: z.number().int().positive().default(60_000),
679
+ tools: z.array(z.string().min(1)).optional(),
680
+ tool_config: toolConfigBlock,
681
+ mcp_servers: mcpServersBlock,
682
+ permissions: permissionsBlock,
683
+ compaction: compactionBlock,
684
+ failure_taxonomy: failureTaxonomyBlock,
685
+ chains: chainsBlock,
686
+ wallets: walletsBlock,
687
+ contracts: contractsBlock,
688
+ transaction_policy: transactionPolicyBlock,
689
+ })
690
+ .strict();
691
+
692
+ // Voice target (Section 24 VOICE). Realtime audio agent.
693
+ const voiceBlockSchema = z
694
+ .object({
695
+ provider: z.enum(["openai", "vapi"]),
696
+ voiceId: z.string().min(1).default("alloy"),
697
+ vad: z.enum(["server", "none"]).default("server"),
698
+ bargeInTriggerFrames: z.number().int().min(1).max(20).default(4),
699
+ bargeInWindowMs: z.number().int().min(60).max(2000).default(200),
700
+ })
701
+ .strict();
702
+
703
+ const voiceTelephonySchema = z
704
+ .object({
705
+ provider: z.enum(["twilio", "livekit-sip", "in-memory"]),
706
+ })
707
+ .strict();
708
+
709
+ const voiceSchema = z
710
+ .object({
711
+ name: z.string().min(1),
712
+ target: z.literal("voice"),
713
+ agent: z
714
+ .object({
715
+ model: z.string().min(1),
716
+ instructions: z.string().min(1),
717
+ })
718
+ .strict(),
719
+ voice: voiceBlockSchema,
720
+ telephony: voiceTelephonySchema.optional(),
721
+ tools: z.array(z.string().min(1)).optional(),
722
+ tool_config: toolConfigBlock,
723
+ mcp_servers: mcpServersBlock,
724
+ permissions: permissionsBlock,
725
+ compaction: compactionBlock,
726
+ failure_taxonomy: failureTaxonomyBlock,
727
+ })
728
+ .strict();
729
+
730
+ // Browser target (Section 25 BROW). Computer-use / browser-driver agent.
731
+ const browserDriverSchema = z
732
+ .object({
733
+ backend: z.enum(["host", "chromium", "remote"]).default("chromium"),
734
+ viewport: z
735
+ .object({
736
+ width: z.number().int().positive().default(1280),
737
+ height: z.number().int().positive().default(720),
738
+ })
739
+ .strict()
740
+ .default({ width: 1280, height: 720 }),
741
+ startUrl: z.string().url().optional(),
742
+ })
743
+ .strict();
744
+
745
+ const browserSchema = z
746
+ .object({
747
+ name: z.string().min(1),
748
+ target: z.literal("browser"),
749
+ agent: z
750
+ .object({
751
+ model: z.string().min(1),
752
+ instructions: z.string().min(1),
753
+ })
754
+ .strict(),
755
+ driver: browserDriverSchema.default({}),
756
+ /** Vision-grounding model. Defaults to the agent's primary model. */
757
+ groundingModel: z.string().min(1).optional(),
758
+ tools: z.array(z.string().min(1)).optional(),
759
+ tool_config: toolConfigBlock,
760
+ mcp_servers: mcpServersBlock,
761
+ permissions: permissionsBlock,
762
+ compaction: compactionBlock,
763
+ failure_taxonomy: failureTaxonomyBlock,
764
+ })
765
+ .strict();
766
+
767
+ /**
768
+ * Section 29 — `target: "eval"` — the EVAL target shape. A spec carries an
769
+ * agent definition, a dataset reference (resolved via §29 dataset-registry),
770
+ * a list of grader names (resolved via §29 grader-registry), concurrency
771
+ * and seed knobs. The compiled bundle boots dataset-registry +
772
+ * grader-registry + eval-runner and writes results to
773
+ * `.crewhaus/evals/<runId>/`.
774
+ */
775
+ const evalSchema = z
776
+ .object({
777
+ name: z.string().min(1),
778
+ target: z.literal("eval"),
779
+ agent: z
780
+ .object({
781
+ model: z.string().min(1),
782
+ instructions: z.string().min(1),
783
+ tools: z.array(z.string().min(1)).optional(),
784
+ })
785
+ .strict(),
786
+ dataset: z
787
+ .object({
788
+ name: z.string().min(1),
789
+ version: z.string().min(1),
790
+ split: z.enum(["train", "dev", "test"]).default("dev"),
791
+ })
792
+ .strict(),
793
+ graders: z
794
+ .array(
795
+ z
796
+ .object({
797
+ name: z.string().min(1),
798
+ opts: z.record(z.unknown()).optional(),
799
+ })
800
+ .strict(),
801
+ )
802
+ .min(1),
803
+ concurrency: z.number().int().min(1).default(4),
804
+ seed: z.number().int().optional(),
805
+ failure_taxonomy: failureTaxonomyBlock,
806
+ })
807
+ .strict();
808
+
809
+ /**
810
+ * Section 47 — `onchain` target. Long-running event-driven daemon.
811
+ * Triggers fire on contract events / block scans / address watches;
812
+ * each trigger runs one agent turn with the decoded payload as the
813
+ * user message. Wallets + transaction_policy let the agent respond
814
+ * with signed transactions (escrow release, treasury rebalance, etc).
815
+ */
816
+ const onchainTriggerSchema = z.discriminatedUnion("kind", [
817
+ z
818
+ .object({
819
+ kind: z.literal("event"),
820
+ chainId: z.string().min(1),
821
+ contract: z.string().min(1),
822
+ event: z.string().min(1),
823
+ filter: z.record(z.string(), z.unknown()).optional(),
824
+ })
825
+ .strict(),
826
+ z
827
+ .object({
828
+ kind: z.literal("block"),
829
+ chainId: z.string().min(1),
830
+ scanIntervalMs: z.number().int().min(1000).max(3_600_000),
831
+ })
832
+ .strict(),
833
+ z
834
+ .object({
835
+ kind: z.literal("address"),
836
+ chainId: z.string().min(1),
837
+ address: z.string().min(1),
838
+ direction: z.enum(["in", "out", "both"]).default("both"),
839
+ })
840
+ .strict(),
841
+ ]);
842
+
843
+ const onchainSchema = z
844
+ .object({
845
+ name: z.string().min(1),
846
+ target: z.literal("onchain"),
847
+ agent: z
848
+ .object({
849
+ model: z.string().min(1),
850
+ instructions: z.string().min(1),
851
+ })
852
+ .strict(),
853
+ chains: z.array(chainBindingSchema).min(1),
854
+ wallets: z.array(walletBindingSchema).default([]),
855
+ contracts: z.array(contractBindingSchema).default([]),
856
+ transaction_policy: transactionPolicySchema.default({
857
+ defaultWriteApproval: "required",
858
+ allowedContracts: [],
859
+ simulationRequired: true,
860
+ }),
861
+ triggers: z.array(onchainTriggerSchema).min(1),
862
+ idempotencyWindowMs: z.number().int().positive().default(60_000),
863
+ tools: z.array(z.string().min(1)).optional(),
864
+ tool_config: toolConfigBlock,
865
+ mcp_servers: mcpServersBlock,
866
+ permissions: permissionsBlock,
867
+ compaction: compactionBlock,
868
+ failure_taxonomy: failureTaxonomyBlock,
869
+ })
870
+ .strict();
871
+
872
+ /**
873
+ * Section 47 — `onchain-game` target. Perceive-act-perceive loop
874
+ * against a game contract: read state via `stateReader`, ask the model
875
+ * for a move, broadcast it as a transaction, await confirmation,
876
+ * re-read state. Single chain, single wallet.
877
+ */
878
+ const onchainGameSchema = z
879
+ .object({
880
+ name: z.string().min(1),
881
+ target: z.literal("onchain-game"),
882
+ agent: z
883
+ .object({
884
+ model: z.string().min(1),
885
+ instructions: z.string().min(1),
886
+ })
887
+ .strict(),
888
+ chain: chainBindingSchema,
889
+ wallet: walletBindingSchema,
890
+ game: z
891
+ .object({
892
+ contract: contractBindingSchema,
893
+ stateReader: z.string().min(1),
894
+ actionsContract: z.string().min(1).optional(),
895
+ turnSemantics: z.enum(["turn-based", "real-time", "async"]).default("turn-based"),
896
+ moveTimeoutMs: z.number().int().positive().optional(),
897
+ objective: z.string().min(1).optional(),
898
+ })
899
+ .strict(),
900
+ transaction_policy: transactionPolicySchema.default({
901
+ defaultWriteApproval: "required",
902
+ allowedContracts: [],
903
+ simulationRequired: true,
904
+ }),
905
+ tools: z.array(z.string().min(1)).optional(),
906
+ tool_config: toolConfigBlock,
907
+ mcp_servers: mcpServersBlock,
908
+ permissions: permissionsBlock,
909
+ compaction: compactionBlock,
910
+ failure_taxonomy: failureTaxonomyBlock,
911
+ })
912
+ .strict();
913
+
914
+ export const Spec = z.discriminatedUnion("target", [
915
+ cliSchema,
916
+ workflowSchema,
917
+ channelSchema,
918
+ graphSchema,
919
+ managedSchema,
920
+ pipelineSchema,
921
+ crewSchema,
922
+ researchSchema,
923
+ batchSchema,
924
+ voiceSchema,
925
+ browserSchema,
926
+ evalSchema,
927
+ onchainSchema,
928
+ onchainGameSchema,
929
+ ]);
930
+
931
+ export type Spec = z.infer<typeof Spec>;
932
+ export type SpecCli = z.infer<typeof cliSchema>;
933
+ export type SpecWorkflow = z.infer<typeof workflowSchema>;
934
+ export type SpecWorkflowStep = z.infer<typeof workflowStepSchema>;
935
+ export type SpecChannel = z.infer<typeof channelSchema>;
936
+ export type SpecChannelAgent = z.infer<typeof channelAgentSchema>;
937
+ export type SpecSlackChannel = z.infer<typeof slackChannelSchema>;
938
+ export type SpecTelegramChannel = z.infer<typeof telegramChannelSchema>;
939
+ export type SpecDiscordChannel = z.infer<typeof discordChannelSchema>;
940
+ export type SpecWhatsAppChannel = z.infer<typeof whatsappChannelSchema>;
941
+ export type SpecIMessageChannel = z.infer<typeof imessageChannelSchema>;
942
+ export type SpecGraph = z.infer<typeof graphSchema>;
943
+ export type SpecGraphNode = z.infer<typeof graphNodeSchema>;
944
+ export type SpecGraphEdge = z.infer<typeof graphEdgeSchema>;
945
+ export type SpecManaged = z.infer<typeof managedSchema>;
946
+ export type SpecManagedTenant = z.infer<typeof managedTenantSchema>;
947
+ export type SpecPipeline = z.infer<typeof pipelineSchema>;
948
+ export type SpecPipelineDocument = z.infer<typeof pipelineDocumentSchema>;
949
+ export type SpecCrew = z.infer<typeof crewSchema>;
950
+ export type SpecCrewRole = z.infer<typeof crewRoleSchema>;
951
+ export type SpecCrewRouting = z.infer<typeof crewRoutingSchema>;
952
+ export type SpecResearch = z.infer<typeof researchSchema>;
953
+ export type SpecResearchRetrieve = z.infer<typeof researchRetrieveSchema>;
954
+ export type SpecBatch = z.infer<typeof batchSchema>;
955
+ export type SpecBatchQueue = z.infer<typeof batchQueueSchema>;
956
+ export type SpecOnchain = z.infer<typeof onchainSchema>;
957
+ export type SpecOnchainGame = z.infer<typeof onchainGameSchema>;
958
+ export type SpecChainTrigger = z.infer<typeof onchainTriggerSchema>;
959
+ export type SpecVoice = z.infer<typeof voiceSchema>;
960
+ export type SpecVoiceBlock = z.infer<typeof voiceBlockSchema>;
961
+ export type SpecVoiceTelephony = z.infer<typeof voiceTelephonySchema>;
962
+ export type SpecBrowser = z.infer<typeof browserSchema>;
963
+ export type SpecBrowserDriver = z.infer<typeof browserDriverSchema>;
964
+ export type SpecEval = z.infer<typeof evalSchema>;
965
+ export type SpecMcpServerConfig = z.infer<typeof mcpServerConfigSchema>;
966
+ export type SpecSubAgentDefinition = z.infer<typeof subAgentDefinitionSchema>;
967
+ export type SpecCompactionBlock = z.infer<typeof compactionBlock>;
968
+ export type SpecFailureTaxonomyEntry = z.infer<typeof failureTaxonomyEntrySchema>;
969
+ export type SpecFailureTaxonomy = z.infer<typeof failureTaxonomyBlock>;
970
+
971
+ export { SpecParseError };
972
+
973
+ export function parseSpec(yamlText: string): Spec {
974
+ let raw: unknown;
975
+ try {
976
+ raw = parseYaml(yamlText);
977
+ } catch (err) {
978
+ throw new SpecParseError("invalid YAML", err);
979
+ }
980
+
981
+ // Friendly early-rejection for `permissions.mode: bypass` so the error
982
+ // message names the actual security policy rather than a Zod enum mismatch.
983
+ // The Zod schema also excludes "bypass" from its enum (defense in depth).
984
+ if (typeof raw === "object" && raw !== null && "permissions" in raw) {
985
+ const perms = (raw as { permissions?: unknown }).permissions;
986
+ if (typeof perms === "object" && perms !== null && "mode" in perms) {
987
+ const mode = (perms as { mode?: unknown }).mode;
988
+ if (mode === "bypass") {
989
+ throw new SpecParseError(
990
+ "permissions.mode: bypass is rejected — bypass mode is only available via the --permission-mode CLI flag, never from a spec file",
991
+ );
992
+ }
993
+ }
994
+ }
995
+
996
+ const result = Spec.safeParse(raw);
997
+ if (!result.success) {
998
+ throw new SpecParseError(
999
+ `spec validation failed:\n${result.error.issues
1000
+ .map((i) => ` ${i.path.join(".") || "<root>"}: ${i.message}`)
1001
+ .join("\n")}`,
1002
+ result.error,
1003
+ );
1004
+ }
1005
+ // Section 22 — crew cross-field invariants. Kept here rather than as
1006
+ // `.refine()`s on the schema so the discriminated-union member stays
1007
+ // a plain ZodObject (Zod's discriminatedUnion rejects ZodEffects).
1008
+ const data = result.data;
1009
+ if (data.target === "crew") {
1010
+ const roleNames = Object.keys(data.roles);
1011
+ if (roleNames.length === 0) {
1012
+ throw new SpecParseError("crew target requires at least one role");
1013
+ }
1014
+ if (!roleNames.includes(data.entry)) {
1015
+ throw new SpecParseError(
1016
+ `crew.entry "${data.entry}" must name one of crew.roles (got: ${roleNames.join(", ")})`,
1017
+ );
1018
+ }
1019
+ if (data.routing !== undefined && data.routing.kind === "match" && data.routing.match) {
1020
+ for (const [from, rules] of Object.entries(data.routing.match)) {
1021
+ if (!roleNames.includes(from)) {
1022
+ throw new SpecParseError(`crew.routing.match["${from}"]: source role not in crew.roles`);
1023
+ }
1024
+ for (const rule of rules) {
1025
+ if (!roleNames.includes(rule.to)) {
1026
+ throw new SpecParseError(
1027
+ `crew.routing.match["${from}"].to = "${rule.to}" — target role not in crew.roles`,
1028
+ );
1029
+ }
1030
+ }
1031
+ }
1032
+ }
1033
+ }
1034
+ return data;
1035
+ }