@crewhaus/spec 0.1.8 → 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 +2450 -188
  2. package/dist/index.js +235 -3
  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,19 +343,51 @@ 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();
273
380
  /**
274
381
  * Response-feedback block — declares that a harness collects human ratings on
275
382
  * agent responses (thumbs/stars/scale/comment) which `crewhaus distill` turns
276
383
  * into eval datasets + graders. Cross-cutting like security: carried on the
277
384
  * interactive shapes that consume it (cli, channel). `channelReactions` gates
278
385
  * codegen of Slack 👍/👎 → feedback in the channel target; `modality`/`storage`
279
- * configure the capture surfaces; `autoDistill` is a forward-looking flag for a
280
- * continuous flywheel. `.strict()` so a typo'd sub-key fails the build.
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.
281
391
  */
282
392
  const feedbackBlock = z
283
393
  .object({
@@ -286,10 +396,93 @@ const feedbackBlock = z
286
396
  scale: z.object({ min: z.number().int(), max: z.number().int() }).strict().optional(),
287
397
  storage: z.object({ location: safeName }).strict().optional(),
288
398
  autoDistill: z.boolean().optional(),
399
+ exitPrompt: z.boolean().optional(),
289
400
  channelReactions: z.boolean().optional(),
290
401
  })
291
402
  .strict()
292
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();
293
486
  /**
294
487
  * Section 47 — blockchain subsystem blocks (cross-cutting). Any shape may
295
488
  * declare any subset of `chains` / `wallets` / `contracts` /
@@ -422,6 +615,7 @@ const channelGatewayBlock = z
422
615
  const cliSchema = z
423
616
  .object({
424
617
  name: safeName,
618
+ version: versionField,
425
619
  target: z.literal("cli"),
426
620
  agent: z
427
621
  .object({
@@ -431,6 +625,11 @@ const cliSchema = z
431
625
  // runtime default applies. Raise it for turns that emit large
432
626
  // multi-file edits so the model isn't cut off mid-`tool_use`.
433
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,
434
633
  sub_agents: subAgentsBlock,
435
634
  })
436
635
  .strict(),
@@ -441,7 +640,10 @@ const cliSchema = z
441
640
  compaction: compactionBlock,
442
641
  security: securityBlock,
443
642
  failure_taxonomy: failureTaxonomyBlock,
643
+ budget: budgetBlock,
444
644
  feedback: feedbackBlock,
645
+ memory: memoryBlock,
646
+ observability: observabilityBlock,
445
647
  cli: cliOptionsBlock,
446
648
  chains: chainsBlock,
447
649
  wallets: walletsBlock,
@@ -461,6 +663,7 @@ const workflowStepSchema = z
461
663
  const workflowSchema = z
462
664
  .object({
463
665
  name: safeName,
666
+ version: versionField,
464
667
  target: z.literal("workflow"),
465
668
  model: z.string().min(1),
466
669
  steps: z.array(workflowStepSchema).min(1),
@@ -543,6 +746,11 @@ const channelAgentSchema = z
543
746
  .object({
544
747
  model: z.string().min(1),
545
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,
546
754
  tools: z.array(z.string().min(1)).optional(),
547
755
  tool_config: toolConfigBlock,
548
756
  sub_agents: subAgentsBlock,
@@ -551,6 +759,7 @@ const channelAgentSchema = z
551
759
  const channelSchema = z
552
760
  .object({
553
761
  name: safeName,
762
+ version: versionField,
554
763
  target: z.literal("channel"),
555
764
  agent: channelAgentSchema,
556
765
  channels: channelsBlock,
@@ -559,7 +768,10 @@ const channelSchema = z
559
768
  permissions: permissionsBlock,
560
769
  compaction: compactionBlock,
561
770
  failure_taxonomy: failureTaxonomyBlock,
771
+ budget: budgetBlock,
562
772
  feedback: feedbackBlock,
773
+ memory: memoryBlock,
774
+ observability: observabilityBlock,
563
775
  heartbeat: heartbeatBlock,
564
776
  gateway: channelGatewayBlock,
565
777
  chains: chainsBlock,
@@ -599,6 +811,7 @@ const graphEdgeSchema = z
599
811
  const graphSchema = z
600
812
  .object({
601
813
  name: safeName,
814
+ version: versionField,
602
815
  target: z.literal("graph"),
603
816
  model: z.string().min(1),
604
817
  entry: z.string().min(1),
@@ -632,17 +845,26 @@ const managedAgentSchema = z
632
845
  .object({
633
846
  model: z.string().min(1),
634
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,
635
853
  })
636
854
  .strict();
637
855
  const managedSchema = z
638
856
  .object({
639
857
  name: safeName,
858
+ version: versionField,
640
859
  target: z.literal("managed"),
641
860
  agent: managedAgentSchema,
642
861
  tenants: z.array(managedTenantSchema).min(1),
643
862
  permissions: permissionsBlock,
644
863
  compaction: compactionBlock,
645
864
  failure_taxonomy: failureTaxonomyBlock,
865
+ budget: budgetBlock,
866
+ memory: memoryBlock,
867
+ observability: observabilityBlock,
646
868
  })
647
869
  .strict();
648
870
  // Vector-store backend ids accepted in specs. Mirrors `VectorBackendId`
@@ -666,6 +888,7 @@ const pipelineDocumentSchema = z
666
888
  const pipelineSchema = z
667
889
  .object({
668
890
  name: safeName,
891
+ version: versionField,
669
892
  target: z.literal("pipeline"),
670
893
  agent: z
671
894
  .object({
@@ -731,6 +954,7 @@ const crewRoutingSchema = z
731
954
  const crewSchema = z
732
955
  .object({
733
956
  name: safeName,
957
+ version: versionField,
734
958
  target: z.literal("crew"),
735
959
  /** Crew-wide model fallback used by any role that omits `role.model`. */
736
960
  model: z.string().min(1),
@@ -765,6 +989,7 @@ const researchRetrieveSchema = z
765
989
  const researchSchema = z
766
990
  .object({
767
991
  name: safeName,
992
+ version: versionField,
768
993
  target: z.literal("research"),
769
994
  agent: z
770
995
  .object({
@@ -782,6 +1007,7 @@ const researchSchema = z
782
1007
  permissions: permissionsBlock,
783
1008
  compaction: compactionBlock,
784
1009
  failure_taxonomy: failureTaxonomyBlock,
1010
+ memory: memoryBlock,
785
1011
  chains: chainsBlock,
786
1012
  wallets: walletsBlock,
787
1013
  contracts: contractsBlock,
@@ -804,6 +1030,7 @@ const batchQueueSchema = z
804
1030
  const batchSchema = z
805
1031
  .object({
806
1032
  name: safeName,
1033
+ version: versionField,
807
1034
  target: z.literal("batch"),
808
1035
  agent: z
809
1036
  .object({
@@ -844,6 +1071,7 @@ const voiceTelephonySchema = z
844
1071
  const voiceSchema = z
845
1072
  .object({
846
1073
  name: safeName,
1074
+ version: versionField,
847
1075
  target: z.literal("voice"),
848
1076
  agent: z
849
1077
  .object({
@@ -878,6 +1106,7 @@ const browserDriverSchema = z
878
1106
  const browserSchema = z
879
1107
  .object({
880
1108
  name: safeName,
1109
+ version: versionField,
881
1110
  target: z.literal("browser"),
882
1111
  agent: z
883
1112
  .object({
@@ -907,6 +1136,7 @@ const browserSchema = z
907
1136
  const evalSchema = z
908
1137
  .object({
909
1138
  name: safeName,
1139
+ version: versionField,
910
1140
  target: z.literal("eval"),
911
1141
  agent: z
912
1142
  .object({
@@ -971,6 +1201,7 @@ const onchainTriggerSchema = z.discriminatedUnion("kind", [
971
1201
  const onchainSchema = z
972
1202
  .object({
973
1203
  name: safeName,
1204
+ version: versionField,
974
1205
  target: z.literal("onchain"),
975
1206
  agent: z
976
1207
  .object({
@@ -1005,6 +1236,7 @@ const onchainSchema = z
1005
1236
  const onchainGameSchema = z
1006
1237
  .object({
1007
1238
  name: safeName,
1239
+ version: versionField,
1008
1240
  target: z.literal("onchain-game"),
1009
1241
  agent: z
1010
1242
  .object({
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@crewhaus/spec",
3
- "version": "0.1.8",
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.8",
18
+ "@crewhaus/errors": "0.2.0",
19
19
  "yaml": "^2.6.0",
20
20
  "zod": "^3.23.8"
21
21
  },