@crewhaus/spec 0.1.2 → 0.1.3

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