@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/package.json +2 -2
- package/src/index.d.ts +11112 -0
- package/src/index.d.ts.map +1 -0
- package/src/index.js +1091 -0
- package/src/index.js.map +1 -0
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
|