@crewhaus/spec 0.1.7 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (3) hide show
  1. package/dist/index.d.ts +2798 -187
  2. package/dist/index.js +255 -1
  3. package/package.json +2 -2
package/dist/index.js CHANGED
@@ -26,6 +26,24 @@ const safeName = z
26
26
  * Will grow into the full catalog spec (eval, deploy) — see
27
27
  * docs/MODULE-CATALOG.md PART A Layer F1.
28
28
  */
29
+ /**
30
+ * Section 28 (#43) — OPTIONAL spec-schema version stamp. Every target schema is
31
+ * `.strict()`, so before this field a migration that stamped `version: 1` on a
32
+ * spec's YAML produced a document `parseSpec` REJECTED ("Unrecognized key(s)").
33
+ * This additive optional field gives migrations somewhere to stamp:
34
+ *
35
+ * - ABSENT → the spec is current/unversioned (the pre-#43 world). Fully
36
+ * back-compat: every existing spec keeps parsing unchanged.
37
+ * - PRESENT → a non-negative integer the migration-engine reads as the spec's
38
+ * schema version (`spec.version ?? 0` in migration-engine/migration-runner).
39
+ *
40
+ * It is a spec-schema knob, NOT the IR `version` (which stays `0` — the IR's
41
+ * own contract version). `lower()` does not thread this field into the IR; it
42
+ * exists purely so the versioned-migration chain has a home. Added to EVERY
43
+ * member of the discriminated union because the union is `.strict()` — a field
44
+ * absent from a member would still be rejected on that target.
45
+ */
46
+ const versionField = z.number().int().nonnegative().optional();
29
47
  // Permissions block (Section 7). SECURITY: `mode: "bypass"` is intentionally
30
48
  // absent from the enum — bypass can only enter the system via the CLI flag.
31
49
  // Defense in depth: parse-time and runtime checks both reject it.
@@ -163,6 +181,66 @@ const toolConfigBlock = z
163
181
  }
164
182
  })
165
183
  .optional();
184
+ /**
185
+ * Item 22 — spec-declared provider failover chain, on the agent blocks of
186
+ * the shapes whose emitted runtime calls `runChatLoop` with a single
187
+ * primary model (cli, channel, managed today).
188
+ *
189
+ * `model_fallbacks` is an ordered list of model strings tried when the
190
+ * primary's circuit breaker is open — each entry follows the SAME
191
+ * model-string grammar as `agent.model` (`claude-*`, `openai/*`,
192
+ * `bedrock/*`, `groq/*`, …; validated like `agent.model` at parse time,
193
+ * with the full grammar enforced by the model-router when resolved).
194
+ * Cross-provider fallbacks resolve their own credentials lazily via the
195
+ * normal model-router path — a fallback with a missing key warns at boot
196
+ * and is skipped when tried, never hard-failing the run.
197
+ *
198
+ * `circuit_breaker` tunes the per-candidate breakers. Field names mirror
199
+ * `CircuitBreakerOptions` in `@crewhaus/circuit-breaker` exactly
200
+ * (failureThreshold / windowMs / cooldownMs); package defaults (5 failures
201
+ * / 60s window / 30s cooldown) apply per field when omitted. Declaring
202
+ * `circuit_breaker` WITHOUT `model_fallbacks` is valid: the primary
203
+ * adapter alone gets breaker-wrapped (fail-fast on a degraded provider
204
+ * instead of hammering it).
205
+ */
206
+ const modelFallbacksBlock = z.array(z.string().min(1)).min(1).optional();
207
+ const circuitBreakerBlock = z
208
+ .object({
209
+ /** Consecutive failures inside windowMs that trip the breaker. */
210
+ failureThreshold: z.number().int().positive().optional(),
211
+ /** Window for counting consecutive failures (ms). */
212
+ windowMs: z.number().int().positive().optional(),
213
+ /** How long the breaker stays open before allowing a probe (ms). */
214
+ cooldownMs: z.number().int().positive().optional(),
215
+ })
216
+ .strict()
217
+ .optional();
218
+ /**
219
+ * Item 26 — opt-in two-tier turn-difficulty router. When present, the runtime
220
+ * picks a model tier PER TURN from deterministic signals (estimated context
221
+ * tokens, whether tools are in play, turn index, prior-turn tool_use density):
222
+ * the cheap `fast` model for easy turns, the `default` model for hard ones. A
223
+ * fast-tier turn that FAILS re-runs on `default` (misroute recovery). Both are
224
+ * full model-router grammar strings. `routing` tunes the escalation thresholds
225
+ * (all optional — sensible defaults apply). Omitted entirely → single-model
226
+ * behaviour, byte-identical bundles.
227
+ */
228
+ const modelTiersBlock = z
229
+ .object({
230
+ fast: z.string().min(1),
231
+ default: z.string().min(1),
232
+ routing: z
233
+ .object({
234
+ contextTokenThreshold: z.number().int().positive().optional(),
235
+ toolsToDefault: z.boolean().optional(),
236
+ firstTurnToDefault: z.boolean().optional(),
237
+ priorToolDensityThreshold: z.number().int().positive().optional(),
238
+ })
239
+ .strict()
240
+ .optional(),
241
+ })
242
+ .strict()
243
+ .optional();
166
244
  /**
167
245
  * Section 17 — optional override for the model used by
168
246
  * `compaction-autocompact` when summarising long conversations. Defaults
@@ -265,11 +343,146 @@ const failureTaxonomyEntrySchema = z
265
343
  .object({
266
344
  class: z.string().min(1),
267
345
  pattern: z.string().min(1),
268
- recovery: z.enum(["retry", "compact", "continue", "tombstone", "fail"]),
346
+ // Item 23 `switch-model` routes the same turn onto the next provider
347
+ // failover candidate (pairs with `agent.model_fallbacks`; a no-op
348
+ // re-issue when no chain is declared). See recovery-engine +
349
+ // AUTOMATION-OPPORTUNITIES.md item 23.
350
+ recovery: z.enum(["retry", "compact", "continue", "tombstone", "switch-model", "fail"]),
269
351
  hint: z.string().min(1).optional(),
270
352
  })
271
353
  .strict();
272
354
  const failureTaxonomyBlock = z.array(failureTaxonomyEntrySchema).optional();
355
+ /**
356
+ * Item 27 — run-level spend cap with a degradation ladder. Generalizes the
357
+ * optimizer's `--budget-usd` to normal runs. `usd` is the dollar ceiling;
358
+ * when the run's accrued spend reaches it, `on_exceed` decides:
359
+ * - `{ action: "stop" }` — end the run cleanly before the next turn.
360
+ * - `{ action: "degrade", model }` — re-resolve the primary model to the
361
+ * cheaper `model` (one rung) and continue; a later breach on the
362
+ * degraded model stops the run.
363
+ * The check is PRE-TURN (beside compaction), so an in-flight turn always
364
+ * completes. Carried on the same interactive shapes as the failover chain
365
+ * (cli, channel, managed). `on_exceed.model` follows the agent.model
366
+ * grammar. Defaults to `{ action: "stop" }` when `on_exceed` is omitted.
367
+ */
368
+ const budgetBlock = z
369
+ .object({
370
+ usd: z.number().positive(),
371
+ on_exceed: z
372
+ .discriminatedUnion("action", [
373
+ z.object({ action: z.literal("stop") }).strict(),
374
+ z.object({ action: z.literal("degrade"), model: z.string().min(1) }).strict(),
375
+ ])
376
+ .default({ action: "stop" }),
377
+ })
378
+ .strict()
379
+ .optional();
380
+ /**
381
+ * Response-feedback block — declares that a harness collects human ratings on
382
+ * agent responses (thumbs/stars/scale/comment) which `crewhaus distill` turns
383
+ * into eval datasets + graders. Cross-cutting like security: carried on the
384
+ * interactive shapes that consume it (cli, channel). `channelReactions` gates
385
+ * codegen of Slack 👍/👎 → feedback in the channel target; `modality`/`storage`
386
+ * configure the capture surfaces; `autoDistill` turns accumulated ratings into
387
+ * versioned `<name>-ratings` registry datasets at CLI run teardown (item 1);
388
+ * `exitPrompt` gates the one-keystroke REPL exit rating prompt (default on
389
+ * when the block is present; set `false` to keep capture surfaces without the
390
+ * prompt). `.strict()` so a typo'd sub-key fails the build.
391
+ */
392
+ const feedbackBlock = z
393
+ .object({
394
+ enabled: z.boolean().optional(),
395
+ modality: z.enum(["binary", "stars", "scale", "comment"]).default("binary"),
396
+ scale: z.object({ min: z.number().int(), max: z.number().int() }).strict().optional(),
397
+ storage: z.object({ location: safeName }).strict().optional(),
398
+ autoDistill: z.boolean().optional(),
399
+ exitPrompt: z.boolean().optional(),
400
+ channelReactions: z.boolean().optional(),
401
+ })
402
+ .strict()
403
+ .optional();
404
+ /**
405
+ * Feature #53 — cross-session memory block. Its mere presence wires the
406
+ * Remember/Recall tools into the harness (no hand-editing). The auto-*
407
+ * switches layer on top: `autoCapture` summarizes the session's durable
408
+ * outcomes into `.crewhaus/memories/<name>.jsonl` at run teardown;
409
+ * `autoRecall` injects the top-`recallK` relevant memories into the system
410
+ * prompt at session start (mirrors project-memory auto-load). Carried on the
411
+ * interactive shapes that run a chat loop (cli, channel, managed, research).
412
+ * `.strict()` so a typo'd sub-key fails the build.
413
+ */
414
+ const memoryBlock = z
415
+ .object({
416
+ enabled: z.boolean().optional(),
417
+ autoCapture: z.boolean().optional(),
418
+ autoCaptureThreshold: z.number().int().positive().optional(),
419
+ autoRecall: z.boolean().optional(),
420
+ recallK: z.number().int().positive().max(50).optional(),
421
+ })
422
+ .strict()
423
+ .optional();
424
+ /**
425
+ * Ops item 37 — cross-cutting `observability` block. Today it carries one
426
+ * sub-block, `slo`, that declares production Service-Level Objectives + the
427
+ * mitigation ladder the runtime SLO monitor walks on a SUSTAINED breach.
428
+ *
429
+ * The monitor (runtime-core, env/spec-gated) folds bus events into rolling
430
+ * windows (reusing the alert-watchdog's accumulator + the metrics-collector's
431
+ * TTFT histogram) and, when a target is breached for `windowSeconds`, executes
432
+ * the ladder rungs in the declared order: `alert` (webhook/hook), `pause-intake`
433
+ * (gateway/managed 429 `budget_exceeded` path), `rollback` (auto-rollback the
434
+ * env pin via deployment-controller). Every rung is audit-logged.
435
+ *
436
+ * Targets are all OPTIONAL — declare only the SLOs you care about; an omitted
437
+ * target is never evaluated. `mitigation` defaults to `["alert"]` (observe-only
438
+ * is safe) so a spec that lists thresholds without a ladder still warns. Higher
439
+ * rungs are opt-in because they touch traffic/deploys — a spec must ask for them
440
+ * explicitly. `.strict()` so a typo'd sub-key fails the build.
441
+ *
442
+ * NOTE `egress_block_rate` derives from the `permission_decision` egress
443
+ * outcomes (no dedicated egress TraceEvent exists); `ttft_ms`/`p95_latency_ms`
444
+ * derive from the same per-turn/TTFT samples the alert-watchdog accumulates.
445
+ */
446
+ const sloBlock = z
447
+ .object({
448
+ /** Fractional error rate ceiling (unrecovered errors / model calls), e.g. 0.05. */
449
+ error_rate: z.number().min(0).max(1).optional(),
450
+ /** p95 per-turn latency ceiling, milliseconds. */
451
+ p95_latency_ms: z.number().positive().optional(),
452
+ /** p95 time-to-first-token ceiling, milliseconds. */
453
+ ttft_ms: z.number().positive().optional(),
454
+ /** Cost burn ceiling, USD per hour of wall-clock. */
455
+ cost_per_hour_usd: z.number().positive().optional(),
456
+ /** Fractional egress-block rate ceiling (egress-blocked / external calls), e.g. 0.1. */
457
+ egress_block_rate: z.number().min(0).max(1).optional(),
458
+ /**
459
+ * Rolling window (seconds) a breach must persist before the ladder fires.
460
+ * A single blip never mitigates — the monitor only acts on a SUSTAINED
461
+ * breach across this window. Default 300s (5 min).
462
+ */
463
+ window_seconds: z.number().int().positive().optional(),
464
+ /**
465
+ * Mitigation ladder, walked in declared order on a sustained breach. Each
466
+ * rung is executed at most once per session. `alert` is always safe;
467
+ * `pause-intake` / `rollback` touch traffic + deploys so they are opt-in.
468
+ */
469
+ mitigation: z
470
+ .array(z.enum(["alert", "pause-intake", "rollback"]))
471
+ .nonempty()
472
+ .optional(),
473
+ })
474
+ .strict()
475
+ .refine((s) => s.error_rate !== undefined ||
476
+ s.p95_latency_ms !== undefined ||
477
+ s.ttft_ms !== undefined ||
478
+ s.cost_per_hour_usd !== undefined ||
479
+ s.egress_block_rate !== undefined, { message: "observability.slo must declare at least one target threshold" });
480
+ const observabilityBlock = z
481
+ .object({
482
+ slo: sloBlock.optional(),
483
+ })
484
+ .strict()
485
+ .optional();
273
486
  /**
274
487
  * Section 47 — blockchain subsystem blocks (cross-cutting). Any shape may
275
488
  * declare any subset of `chains` / `wallets` / `contracts` /
@@ -402,6 +615,7 @@ const channelGatewayBlock = z
402
615
  const cliSchema = z
403
616
  .object({
404
617
  name: safeName,
618
+ version: versionField,
405
619
  target: z.literal("cli"),
406
620
  agent: z
407
621
  .object({
@@ -411,6 +625,11 @@ const cliSchema = z
411
625
  // runtime default applies. Raise it for turns that emit large
412
626
  // multi-file edits so the model isn't cut off mid-`tool_use`.
413
627
  max_tokens: z.number().int().positive().optional(),
628
+ // Item 22 — provider failover chain (see modelFallbacksBlock docs).
629
+ model_fallbacks: modelFallbacksBlock,
630
+ circuit_breaker: circuitBreakerBlock,
631
+ // Item 26 — opt-in two-tier turn-difficulty router.
632
+ model_tiers: modelTiersBlock,
414
633
  sub_agents: subAgentsBlock,
415
634
  })
416
635
  .strict(),
@@ -421,6 +640,10 @@ const cliSchema = z
421
640
  compaction: compactionBlock,
422
641
  security: securityBlock,
423
642
  failure_taxonomy: failureTaxonomyBlock,
643
+ budget: budgetBlock,
644
+ feedback: feedbackBlock,
645
+ memory: memoryBlock,
646
+ observability: observabilityBlock,
424
647
  cli: cliOptionsBlock,
425
648
  chains: chainsBlock,
426
649
  wallets: walletsBlock,
@@ -440,6 +663,7 @@ const workflowStepSchema = z
440
663
  const workflowSchema = z
441
664
  .object({
442
665
  name: safeName,
666
+ version: versionField,
443
667
  target: z.literal("workflow"),
444
668
  model: z.string().min(1),
445
669
  steps: z.array(workflowStepSchema).min(1),
@@ -522,6 +746,11 @@ const channelAgentSchema = z
522
746
  .object({
523
747
  model: z.string().min(1),
524
748
  instructions: z.string().min(1),
749
+ // Item 22 — provider failover chain (see modelFallbacksBlock docs).
750
+ model_fallbacks: modelFallbacksBlock,
751
+ circuit_breaker: circuitBreakerBlock,
752
+ // Item 26 — opt-in two-tier turn-difficulty router.
753
+ model_tiers: modelTiersBlock,
525
754
  tools: z.array(z.string().min(1)).optional(),
526
755
  tool_config: toolConfigBlock,
527
756
  sub_agents: subAgentsBlock,
@@ -530,6 +759,7 @@ const channelAgentSchema = z
530
759
  const channelSchema = z
531
760
  .object({
532
761
  name: safeName,
762
+ version: versionField,
533
763
  target: z.literal("channel"),
534
764
  agent: channelAgentSchema,
535
765
  channels: channelsBlock,
@@ -538,6 +768,10 @@ const channelSchema = z
538
768
  permissions: permissionsBlock,
539
769
  compaction: compactionBlock,
540
770
  failure_taxonomy: failureTaxonomyBlock,
771
+ budget: budgetBlock,
772
+ feedback: feedbackBlock,
773
+ memory: memoryBlock,
774
+ observability: observabilityBlock,
541
775
  heartbeat: heartbeatBlock,
542
776
  gateway: channelGatewayBlock,
543
777
  chains: chainsBlock,
@@ -577,6 +811,7 @@ const graphEdgeSchema = z
577
811
  const graphSchema = z
578
812
  .object({
579
813
  name: safeName,
814
+ version: versionField,
580
815
  target: z.literal("graph"),
581
816
  model: z.string().min(1),
582
817
  entry: z.string().min(1),
@@ -610,17 +845,26 @@ const managedAgentSchema = z
610
845
  .object({
611
846
  model: z.string().min(1),
612
847
  instructions: z.string().min(1),
848
+ // Item 22 — provider failover chain (see modelFallbacksBlock docs).
849
+ model_fallbacks: modelFallbacksBlock,
850
+ circuit_breaker: circuitBreakerBlock,
851
+ // Item 26 — opt-in two-tier turn-difficulty router.
852
+ model_tiers: modelTiersBlock,
613
853
  })
614
854
  .strict();
615
855
  const managedSchema = z
616
856
  .object({
617
857
  name: safeName,
858
+ version: versionField,
618
859
  target: z.literal("managed"),
619
860
  agent: managedAgentSchema,
620
861
  tenants: z.array(managedTenantSchema).min(1),
621
862
  permissions: permissionsBlock,
622
863
  compaction: compactionBlock,
623
864
  failure_taxonomy: failureTaxonomyBlock,
865
+ budget: budgetBlock,
866
+ memory: memoryBlock,
867
+ observability: observabilityBlock,
624
868
  })
625
869
  .strict();
626
870
  // Vector-store backend ids accepted in specs. Mirrors `VectorBackendId`
@@ -644,6 +888,7 @@ const pipelineDocumentSchema = z
644
888
  const pipelineSchema = z
645
889
  .object({
646
890
  name: safeName,
891
+ version: versionField,
647
892
  target: z.literal("pipeline"),
648
893
  agent: z
649
894
  .object({
@@ -709,6 +954,7 @@ const crewRoutingSchema = z
709
954
  const crewSchema = z
710
955
  .object({
711
956
  name: safeName,
957
+ version: versionField,
712
958
  target: z.literal("crew"),
713
959
  /** Crew-wide model fallback used by any role that omits `role.model`. */
714
960
  model: z.string().min(1),
@@ -743,6 +989,7 @@ const researchRetrieveSchema = z
743
989
  const researchSchema = z
744
990
  .object({
745
991
  name: safeName,
992
+ version: versionField,
746
993
  target: z.literal("research"),
747
994
  agent: z
748
995
  .object({
@@ -760,6 +1007,7 @@ const researchSchema = z
760
1007
  permissions: permissionsBlock,
761
1008
  compaction: compactionBlock,
762
1009
  failure_taxonomy: failureTaxonomyBlock,
1010
+ memory: memoryBlock,
763
1011
  chains: chainsBlock,
764
1012
  wallets: walletsBlock,
765
1013
  contracts: contractsBlock,
@@ -782,6 +1030,7 @@ const batchQueueSchema = z
782
1030
  const batchSchema = z
783
1031
  .object({
784
1032
  name: safeName,
1033
+ version: versionField,
785
1034
  target: z.literal("batch"),
786
1035
  agent: z
787
1036
  .object({
@@ -822,6 +1071,7 @@ const voiceTelephonySchema = z
822
1071
  const voiceSchema = z
823
1072
  .object({
824
1073
  name: safeName,
1074
+ version: versionField,
825
1075
  target: z.literal("voice"),
826
1076
  agent: z
827
1077
  .object({
@@ -856,6 +1106,7 @@ const browserDriverSchema = z
856
1106
  const browserSchema = z
857
1107
  .object({
858
1108
  name: safeName,
1109
+ version: versionField,
859
1110
  target: z.literal("browser"),
860
1111
  agent: z
861
1112
  .object({
@@ -885,6 +1136,7 @@ const browserSchema = z
885
1136
  const evalSchema = z
886
1137
  .object({
887
1138
  name: safeName,
1139
+ version: versionField,
888
1140
  target: z.literal("eval"),
889
1141
  agent: z
890
1142
  .object({
@@ -949,6 +1201,7 @@ const onchainTriggerSchema = z.discriminatedUnion("kind", [
949
1201
  const onchainSchema = z
950
1202
  .object({
951
1203
  name: safeName,
1204
+ version: versionField,
952
1205
  target: z.literal("onchain"),
953
1206
  agent: z
954
1207
  .object({
@@ -983,6 +1236,7 @@ const onchainSchema = z
983
1236
  const onchainGameSchema = z
984
1237
  .object({
985
1238
  name: safeName,
1239
+ version: versionField,
986
1240
  target: z.literal("onchain-game"),
987
1241
  agent: z
988
1242
  .object({
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@crewhaus/spec",
3
- "version": "0.1.7",
3
+ "version": "0.2.0",
4
4
  "type": "module",
5
5
  "description": "User-facing spec schema (Zod) + YAML parser",
6
6
  "main": "dist/index.js",
@@ -15,7 +15,7 @@
15
15
  "test": "bun test src"
16
16
  },
17
17
  "dependencies": {
18
- "@crewhaus/errors": "0.1.7",
18
+ "@crewhaus/errors": "0.2.0",
19
19
  "yaml": "^2.6.0",
20
20
  "zod": "^3.23.8"
21
21
  },