@crewhaus/spec 0.3.2 → 0.4.2

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 +22836 -8828
  2. package/dist/index.js +1221 -74
  3. package/package.json +4 -3
package/dist/index.js CHANGED
@@ -1,6 +1,7 @@
1
1
  import { SpecParseError } from "@crewhaus/errors";
2
2
  import { parse as parseYaml } from "yaml";
3
3
  import { z } from "zod";
4
+ import { zodToJsonSchema } from "zod-to-json-schema";
4
5
  // SECURITY (codegen-injection backstop, #147/#148): spec/role/node/step names
5
6
  // flow verbatim into generated source across ~14 emitters — `//` and `/* */`
6
7
  // comments, template literals, JSON `package.json` manifests, YAML frontmatter,
@@ -57,6 +58,26 @@ const permissionsBlock = z
57
58
  .object({
58
59
  mode: z.enum(["default", "plan", "auto"]).optional(),
59
60
  rules: z.array(permissionRuleSchema).optional(),
61
+ /**
62
+ * Loop contract 0.4 (Batch C, G11) — what a tool permission that resolves
63
+ * to `ask` does on a NON-interactive surface (single-turn / daemon /
64
+ * gateway, anywhere without a synchronous human prompt). `"pause"`
65
+ * (DEFAULT — the safe direction) parks the turn: the runtime persists a
66
+ * `PendingApproval`, publishes an `approval_requested` trace event, and
67
+ * ends the turn with the `approval_pending` failure class + resume token
68
+ * so a later `grant`/`deny` decision re-runs the tool call pre-resolved.
69
+ * `"deny"` is the pre-0.4 collapse behaviour — an ask on a non-interactive
70
+ * surface becomes a denial in place. The REPL always keeps its
71
+ * synchronous prompt regardless of this key.
72
+ *
73
+ * Deliberately OMITTED from `OPTIMIZABLE_PATHS`: this is a safety /
74
+ * human-in-the-loop control, not a quality knob. Letting an optimizer
75
+ * flip a pending-approval `pause` to `deny` (or vice-versa) would let the
76
+ * search loop silently rewrite the approval posture of a deployment — the
77
+ * same reason the intent-gate grader and other safety surfaces stay out
78
+ * of the optimizer's reach.
79
+ */
80
+ ask_mode: z.enum(["pause", "deny"]).optional(),
60
81
  })
61
82
  .strict()
62
83
  .optional();
@@ -102,6 +123,17 @@ const subAgentDefinitionSchema = z
102
123
  ])
103
124
  .optional(),
104
125
  inherit_bypass: z.boolean().optional(),
126
+ /**
127
+ * Item 2 (G31 — A2A federation) — wire this sub-agent to a REMOTE peer
128
+ * instead of spawning it locally. `url` is the peer deployment's base
129
+ * URL; the spawner routes the Task call through `@crewhaus/federation-
130
+ * router` to the peer's inbound A2A handler (whose Agent Card lives at
131
+ * `<url>/.well-known/agent-card.json`), mapping the federation envelope
132
+ * onto A2A message/task semantics. Present ⇒ the entry is a federated
133
+ * peer reference; `description`/`instructions` still describe it to the
134
+ * parent's Task tool (the remote peer owns its own prompt).
135
+ */
136
+ federation: z.object({ url: z.string().url() }).strict().optional(),
105
137
  })
106
138
  .strict();
107
139
  const subAgentsBlock = z.record(safeName, subAgentDefinitionSchema).optional();
@@ -361,6 +393,21 @@ function refineModelSelection(agent, ctx) {
361
393
  const compactionBlock = z
362
394
  .object({
363
395
  model: z.string().min(1).optional(),
396
+ /** Loop contract 0.4 (Batch A) — context-window fill fraction that
397
+ * triggers autocompaction (e.g. 0.85 = compact at 85% full). Bounded
398
+ * to 0.5–0.99: below half the window a compaction pass costs more
399
+ * than it saves, and 1.0 would only ever fire after an overflow.
400
+ * OPTIMIZABLE (`["compaction","threshold"]` in spec-patch). When
401
+ * omitted the runtime default applies. */
402
+ threshold: z.number().gte(0.5).lte(0.99).optional(),
403
+ /** Loop contract 0.4 (Batch A) — messages preserved verbatim at the
404
+ * HEAD of the transcript by `compaction-snip` before summarising the
405
+ * middle. When omitted the snip package's default applies. */
406
+ snip_keep_head: z.number().int().positive().optional(),
407
+ /** Loop contract 0.4 (Batch A) — messages preserved verbatim at the
408
+ * TAIL of the transcript by `compaction-snip`. When omitted the snip
409
+ * package's default applies. */
410
+ snip_keep_tail: z.number().int().positive().optional(),
364
411
  /** Pillar 2 — opt in to the pre-compaction curator pass. Defaults
365
412
  * to `false` when omitted; the IR carries the user's choice
366
413
  * verbatim so target emitters can wire `@crewhaus/compaction-curator`
@@ -488,6 +535,297 @@ const budgetBlock = z
488
535
  })
489
536
  .strict()
490
537
  .optional();
538
+ /**
539
+ * Loop contract 0.4 (Batch B, G02) — the top-level `evaluation:` block:
540
+ * in-loop output evaluation on the interactive shapes (cli, channel,
541
+ * managed). After each completed assistant turn the runtime scores the
542
+ * final text with `grader`; a score below `threshold` triggers the
543
+ * `on_fail` behaviour:
544
+ *
545
+ * - `retry` (default) — re-prompt the model with the judge's rationale
546
+ * appended as a system nudge, at most `max_retries` times.
547
+ * - `halt` — abort the turn with a classified `evaluation` failure.
548
+ * - `note` — emit an `eval_graded` trace event only.
549
+ *
550
+ * Graders:
551
+ * - `{ type: llm_judge, criteria, model? }` — a model scores the reply
552
+ * in [0,1] against `criteria`; `model` defaults to the shape's primary
553
+ * model (the `cheapest` sentinel resolves like `compaction.model`).
554
+ * Judge calls are METERED into the run budget.
555
+ * - `{ type: contains, value }` / `{ type: regex, value }` —
556
+ * deterministic pass/fail text checks (no threshold; no model spend).
557
+ *
558
+ * `threshold` (0..1, default 0.7) applies to `llm_judge` only — declaring
559
+ * it with a deterministic grader is a parse error. `.strict()` throughout
560
+ * so a typo'd sub-key fails the build.
561
+ */
562
+ const evaluationGraderSchema = z.discriminatedUnion("type", [
563
+ z
564
+ .object({
565
+ type: z.literal("llm_judge"),
566
+ model: z
567
+ .string()
568
+ .min(1)
569
+ .optional()
570
+ .describe("judge model id; defaults to the shape's primary model (the cheapest sentinel resolves at compile time)"),
571
+ criteria: z
572
+ .string()
573
+ .min(1)
574
+ .describe("what a passing reply must satisfy — the judge scores the final text against this"),
575
+ })
576
+ .strict()
577
+ .describe("model-scored grader: an LLM judges the final text in [0,1] against criteria"),
578
+ z
579
+ .object({
580
+ type: z.literal("contains"),
581
+ value: z.string().min(1).describe("substring the final text must contain (case-sensitive)"),
582
+ })
583
+ .strict()
584
+ .describe("deterministic grader: pass iff the final text contains value"),
585
+ z
586
+ .object({
587
+ type: z.literal("regex"),
588
+ value: z.string().min(1).describe("JavaScript regular expression the final text must match"),
589
+ })
590
+ .strict()
591
+ .describe("deterministic grader: pass iff the final text matches the regex"),
592
+ ]);
593
+ const evaluationBlock = z
594
+ .object({
595
+ grader: evaluationGraderSchema,
596
+ threshold: z
597
+ .number()
598
+ .min(0)
599
+ .max(1)
600
+ .optional()
601
+ .describe("passing score in 0..1 (default 0.7); llm_judge grader only"),
602
+ on_fail: z
603
+ .enum(["retry", "halt", "note"])
604
+ .optional()
605
+ .describe("below-threshold behaviour: retry re-prompts with the judge rationale (default), halt aborts the turn classified, note emits a trace event only"),
606
+ max_retries: z
607
+ .number()
608
+ .int()
609
+ .min(1)
610
+ .max(5)
611
+ .optional()
612
+ .describe("hard cap on evaluation-triggered retries per turn (default 1)"),
613
+ })
614
+ .strict()
615
+ .superRefine((e, ctx) => {
616
+ if (e.threshold !== undefined && e.grader.type !== "llm_judge") {
617
+ ctx.addIssue({
618
+ code: z.ZodIssueCode.custom,
619
+ path: ["threshold"],
620
+ message: `evaluation.threshold applies to the llm_judge grader only — the "${e.grader.type}" grader is deterministic pass/fail`,
621
+ });
622
+ }
623
+ if (e.grader.type === "regex") {
624
+ try {
625
+ new RegExp(e.grader.value);
626
+ }
627
+ catch (err) {
628
+ ctx.addIssue({
629
+ code: z.ZodIssueCode.custom,
630
+ path: ["grader", "value"],
631
+ message: `evaluation.grader.value is not a valid regular expression: ${err instanceof Error ? err.message : String(err)}`,
632
+ });
633
+ }
634
+ }
635
+ })
636
+ .describe("in-loop output evaluation: score each completed assistant turn and retry/halt/note below threshold")
637
+ .optional();
638
+ /**
639
+ * Loop contract 0.4 (Batch B, G02) — the `judge:` gate declared by
640
+ * `kind: "judge"` workflow steps and graph nodes. A judge step/node runs no
641
+ * agent turn of its own: it scores the PREVIOUS step's (workflow) /
642
+ * upstream node's (graph) final output in [0,1] against `criteria` and,
643
+ * below `threshold` (default 0.7), applies `on_fail`:
644
+ *
645
+ * - `retry_previous` (default) — re-run the gated step/node with the
646
+ * judge rationale appended as a system nudge, at most `max_retries`
647
+ * times.
648
+ * - `halt` — abort the run with a classified `evaluation` failure.
649
+ * - `continue` — record the `judge_verdict` trace event and proceed.
650
+ *
651
+ * `model` defaults to the shape's top-level `model` (the `cheapest`
652
+ * sentinel resolves like `compaction.model`). Judge calls are METERED into
653
+ * the run budget.
654
+ */
655
+ const judgeGateBlock = z
656
+ .object({
657
+ criteria: z
658
+ .string()
659
+ .min(1)
660
+ .describe("what a passing upstream output must satisfy — the judge scores against this"),
661
+ model: z
662
+ .string()
663
+ .min(1)
664
+ .optional()
665
+ .describe("judge model id; defaults to the shape's top-level model"),
666
+ threshold: z.number().min(0).max(1).optional().describe("passing score in 0..1 (default 0.7)"),
667
+ on_fail: z
668
+ .enum(["retry_previous", "halt", "continue"])
669
+ .optional()
670
+ .describe("below-threshold behaviour: retry_previous re-runs the gated step/node (default), halt aborts classified, continue records the verdict and proceeds"),
671
+ max_retries: z
672
+ .number()
673
+ .int()
674
+ .min(1)
675
+ .max(5)
676
+ .optional()
677
+ .describe("hard cap on judge-triggered re-runs of the gated step/node (default 1)"),
678
+ })
679
+ .strict()
680
+ .describe("judge gate config for kind: judge workflow steps and graph nodes");
681
+ /**
682
+ * Loop contract 0.4 (Batch A) — extended-thinking selector, carried on the
683
+ * agent blocks of the interactive shapes (cli, channel, managed) and at
684
+ * step/node/role granularity on workflow steps, graph nodes, and crew roles.
685
+ * Exactly ONE of the two forms must be declared (enforced by superRefine):
686
+ *
687
+ * - `{ budget_tokens: n }` — an explicit thinking-token budget (>= 1024,
688
+ * the provider floor), passed through to the provider verbatim.
689
+ * - `{ effort: low|medium|high }` — a portable effort preset the adapter
690
+ * layer converts to a provider-appropriate budget
691
+ * (`EFFORT_THINKING_BUDGET_TOKENS` in `@crewhaus/adapter-anthropic`).
692
+ *
693
+ * `.strict()` so a typo'd sub-key fails the build.
694
+ */
695
+ const thinkingBlock = z
696
+ .object({
697
+ budget_tokens: z.number().int().min(1024).optional(),
698
+ effort: z.enum(["low", "medium", "high"]).optional(),
699
+ })
700
+ .strict()
701
+ .superRefine((t, ctx) => {
702
+ const forms = (t.budget_tokens !== undefined ? 1 : 0) + (t.effort !== undefined ? 1 : 0);
703
+ if (forms !== 1) {
704
+ ctx.addIssue({
705
+ code: z.ZodIssueCode.custom,
706
+ message: "thinking requires exactly one of budget_tokens (explicit token budget >= 1024) or effort (low|medium|high preset)",
707
+ });
708
+ }
709
+ })
710
+ .optional();
711
+ /**
712
+ * Loop contract 0.4 (Batch A) — runaway-loop detection tuning inside the
713
+ * `limits:` block. `window` is the trailing tool-call window inspected;
714
+ * `threshold` (>1 — a single repeat is normal) is how many identical calls
715
+ * inside the window count as a loop; `escalation` picks the response:
716
+ * `warn` (trace event only), `justify` (demand a justification string via
717
+ * the intent gate), `abort` (end the run). Runtime owns per-knob defaults.
718
+ */
719
+ const loopDetectionBlock = z
720
+ .object({
721
+ window: z.number().int().positive().optional(),
722
+ threshold: z.number().int().min(2).optional(),
723
+ escalation: z.enum(["warn", "justify", "abort"]).optional(),
724
+ })
725
+ .strict();
726
+ /**
727
+ * Loop contract 0.4 (Batch A) — the top-level `limits:` block: hard runtime
728
+ * ceilings for one agent loop. Carried on the loop-running shapes (cli,
729
+ * channel, managed, workflow, graph, crew, research, batch, browser); the
730
+ * strict union rejects it loudly elsewhere. Every field optional — declare
731
+ * only the ceilings you want; the runtime owns per-knob defaults.
732
+ *
733
+ * - `max_tool_iterations` — cap on tool-use round-trips per turn
734
+ * (OPTIMIZABLE, `["limits","max_tool_iterations"]` in spec-patch).
735
+ * - `max_concurrent_tools` — parallel tool-execution ceiling per block.
736
+ * - `context_limit` — hard context-token ceiling (overrides the model's).
737
+ * - `deadline_ms` — wall-clock ceiling for the whole run.
738
+ * - `turn_timeout_ms` — wall-clock ceiling for one turn.
739
+ * - `model_call_timeout_ms` — wall-clock ceiling for one model call.
740
+ * - `loop_detection` — see {@link loopDetectionBlock}.
741
+ *
742
+ * The crew shape additionally accepts a `crew:` sub-block (see
743
+ * {@link crewLimitsBlock}) for orchestration-level ceilings.
744
+ */
745
+ const limitsObject = z
746
+ .object({
747
+ max_tool_iterations: z.number().int().positive().optional(),
748
+ max_concurrent_tools: z.number().int().positive().optional(),
749
+ context_limit: z.number().int().positive().optional(),
750
+ deadline_ms: z.number().int().positive().optional(),
751
+ turn_timeout_ms: z.number().int().positive().optional(),
752
+ model_call_timeout_ms: z.number().int().positive().optional(),
753
+ loop_detection: loopDetectionBlock.optional(),
754
+ })
755
+ .strict();
756
+ const limitsBlock = limitsObject.optional();
757
+ /**
758
+ * Loop contract 0.4 (Batch A) — crew-only orchestration ceilings nested
759
+ * under `limits.crew`. `max_activations` caps total role activations per
760
+ * run; `refusal_depth` (>= 0 — 0 means "never refuse") caps how many times
761
+ * a role may bounce a handoff back; `max_a2a_depth` caps agent-to-agent
762
+ * delegation depth. Accepted ONLY on the crew shape — the base
763
+ * {@link limitsObject} everywhere else rejects the `crew` key.
764
+ */
765
+ const crewLimitsBlock = limitsObject
766
+ .extend({
767
+ crew: z
768
+ .object({
769
+ max_activations: z.number().int().positive().optional(),
770
+ refusal_depth: z.number().int().nonnegative().optional(),
771
+ max_a2a_depth: z.number().int().positive().optional(),
772
+ })
773
+ .strict()
774
+ .optional(),
775
+ })
776
+ .strict()
777
+ .optional();
778
+ /**
779
+ * Loop contract 0.4 (Batch A) — the hook-event names accepted in a spec's
780
+ * `hooks:` block. DUPLICATED from `HOOK_EVENTS` in `@crewhaus/hooks-engine`
781
+ * (the runtime source of truth) because the spec package stays
782
+ * dependency-light — it must not import runtime packages. A cross-check
783
+ * test in `packages/hooks-engine` imports this const and asserts equality
784
+ * with `HOOK_EVENTS`, so the two lists cannot drift silently. Keep in sync.
785
+ */
786
+ export const SPEC_HOOK_EVENTS = [
787
+ "session-start",
788
+ "stop",
789
+ "pre-tool",
790
+ "post-tool",
791
+ "pre-model",
792
+ "post-model",
793
+ "pre-compact",
794
+ "post-compact",
795
+ "pre-slash",
796
+ "alert",
797
+ ];
798
+ /**
799
+ * Loop contract 0.4 (Batch A) — spec-declared lifecycle hooks, the in-spec
800
+ * equivalent of `.crewhaus/settings.json` `hooks` entries (same shape as
801
+ * hooks-engine's `HookDef`, snake_case per spec convention: `timeout_ms` ↔
802
+ * `timeoutMs`). Carried on the same shapes as `limits:`. Each entry spawns
803
+ * `command` at the named lifecycle `event` (optionally filtered by the
804
+ * `matcher` glob against the payload's `name`).
805
+ */
806
+ const hookSchema = z
807
+ .object({
808
+ event: z.enum(SPEC_HOOK_EVENTS),
809
+ matcher: z.string().min(1).optional(),
810
+ command: z.string().min(1),
811
+ timeout_ms: z.number().int().positive().optional(),
812
+ })
813
+ .strict();
814
+ const hooksBlock = z.array(hookSchema).optional();
815
+ /**
816
+ * Loop contract 0.4 (Batch A) — per-tool rate limits on the agent blocks of
817
+ * the interactive shapes (cli, channel, managed). Keys are tool names (or
818
+ * `"*"` for the catch-all bucket); `rpm` is the sustained requests-per-
819
+ * minute ceiling, `burst` the optional short-burst allowance on top.
820
+ */
821
+ const rateLimitsBlock = z
822
+ .record(z.string().min(1), z
823
+ .object({
824
+ rpm: z.number().int().positive(),
825
+ burst: z.number().int().positive().optional(),
826
+ })
827
+ .strict())
828
+ .optional();
491
829
  /**
492
830
  * Response-feedback block — declares that a harness collects human ratings on
493
831
  * agent responses (thumbs/stars/scale/comment) which `crewhaus distill` turns
@@ -577,6 +915,26 @@ const memoryDreamBlock = z
577
915
  * prompt at session start (mirrors project-memory auto-load). Carried on the
578
916
  * agent-loop shapes (cli, channel, managed, research, crew).
579
917
  *
918
+ * Loop contract 0.4 (Batch E, G46) — DEFAULT CHANGE (mildly breaking): when
919
+ * the `memory:` block is PRESENT, `autoRecall` now defaults to `true`
920
+ * (`"session-start"`) and `autoCapture` defaults to `true` (behind the
921
+ * existing `autoCaptureThreshold` gate) — both previously defaulted to
922
+ * `false`. The resolved booleans are stamped into the IR at lower time, so
923
+ * declaring `memory:` at all opts into recall+capture. Opt back out with
924
+ * `autoRecall: false` / `autoCapture: false`.
925
+ *
926
+ * EMBEDDER RESOLUTION ORDER (Batch E, G76) — coherent across the three
927
+ * embedder knobs, applied by the runtime/emitters (the IR carries the raw
928
+ * declared strings):
929
+ * - fact-store recall + the compaction curator: `memory.embedder` →
930
+ * `memory.wiki.embedder` → (none ⇒ BM25-only lexical);
931
+ * - the wiki semantic tier: `memory.wiki.embedder` → `memory.embedder` →
932
+ * (none ⇒ BM25-only);
933
+ * - agent-shape RAG (the `knowledge:` block): `knowledge.embedder` →
934
+ * `memory.embedder` → `memory.wiki.embedder` (a vector store needs
935
+ * embeddings, so with all three absent the target falls back to its
936
+ * default embedder model rather than BM25).
937
+ *
580
938
  * v0.3.0 (§9) extensions — all optional so pre-0.3.0 specs parse (and lower)
581
939
  * unchanged:
582
940
  * - `backend`: `file` (the default when absent) | `thredz` (reserved — the
@@ -594,19 +952,118 @@ const memoryBlock = z
594
952
  .object({
595
953
  enabled: z.boolean().optional(),
596
954
  backend: z.enum(["file", "thredz"]).optional(),
955
+ /** Loop contract 0.4 (Batch A) — top-level embedder for the FACT store
956
+ * (same `@crewhaus/embedder` factory grammar as `wiki.embedder`).
957
+ * Runtime fallback order: `embedder` → `wiki.embedder` — declaring
958
+ * only the wiki one keeps prior behaviour; the top-level knob lets a
959
+ * spec enable hybrid fact recall without enabling the wiki tier. */
960
+ embedder: z.string().min(1).optional(),
597
961
  ttl: z
598
962
  .string()
599
963
  .regex(DURATION_REGEX, 'memory.ttl must be a duration like "90d", "12h", "30m", "60s", or "500ms"')
600
964
  .optional(),
601
965
  autoCapture: z.boolean().optional(),
602
966
  autoCaptureThreshold: z.number().int().positive().optional(),
603
- autoRecall: z.boolean().optional(),
967
+ /** Loop contract 0.4 (Batch E, G21) — WHEN auto-recall runs. The boolean
968
+ * form is the pre-0.4 on/off switch (`true` ≡ `"session-start"`); the
969
+ * string form picks the cadence: `"session-start"` injects the recalled
970
+ * block ONCE at boot (the default when memory is present, G46), while
971
+ * `"per-turn"` re-runs the recall closure against the latest user message
972
+ * every turn (or every `refreshEvery` turns) and swaps the volatile
973
+ * recalled tail block WITHOUT re-injecting into the frozen cache prefix. */
974
+ autoRecall: z.union([z.boolean(), z.enum(["session-start", "per-turn"])]).optional(),
975
+ /** Loop contract 0.4 (Batch E, G21) — turns between per-turn recall
976
+ * refreshes (int > 0). Declaring it implies `autoRecall: "per-turn"`
977
+ * (it IS the "every N turns" cadence knob); `"per-turn"` without it
978
+ * refreshes every turn. OPTIMIZABLE (`["memory","refreshEvery"]`). It is
979
+ * a contradiction alongside `autoRecall: false` (rejected at lower time).*/
980
+ refreshEvery: z.number().int().positive().optional(),
604
981
  recallK: z.number().int().positive().max(50).optional(),
982
+ /** Loop contract 0.4 (Batch E, G77) — fold session summaries in as a
983
+ * third RRF ranker in the recall fusion (over the existing
984
+ * sessions-index), beside the fact-store and wiki rankers. Default
985
+ * false; opting in surfaces "what we concluded last time" without a
986
+ * dedicated tool call. */
987
+ sessionRecall: z.boolean().optional(),
605
988
  wiki: memoryWikiBlock.optional(),
606
989
  dream: memoryDreamBlock.optional(),
607
990
  })
608
991
  .strict()
609
992
  .optional();
993
+ // Vector-store backend ids accepted in specs. Mirrors `VectorBackendId`
994
+ // from @crewhaus/vector-store (and `IrVectorBackend`) — the canonical set
995
+ // of implemented backends — kept inline so the spec stays dependency-light.
996
+ // Keep in sync when a backend is added or removed. (Declared here, above the
997
+ // first consumer — the `knowledge:` block — so the cli/channel/managed
998
+ // schemas can reference it; the pipeline/research retrieve blocks below reuse
999
+ // the same const.)
1000
+ const VECTOR_BACKENDS = ["in-memory", "lance", "qdrant", "pinecone", "weaviate"];
1001
+ // The HTTP backends construct only with a `url` + `collection` (the
1002
+ // vector-store factory throws otherwise); parseSpec requires both so a
1003
+ // spec that selects one without them fails at compile, not at runtime.
1004
+ const HTTP_VECTOR_BACKENDS = new Set(["qdrant", "pinecone", "weaviate"]);
1005
+ /**
1006
+ * Loop contract 0.4 (Batch E, G22) — a single knowledge source: exactly ONE
1007
+ * of `path` (a file/dir on disk), `glob` (a shell glob) or `url` (a remote
1008
+ * document) per entry. The exactly-one rule is a self-contained superRefine
1009
+ * so the error is path-bearing at the offending source.
1010
+ */
1011
+ const knowledgeSourceSchema = z
1012
+ .object({
1013
+ path: z.string().min(1).optional(),
1014
+ glob: z.string().min(1).optional(),
1015
+ url: z.string().min(1).optional(),
1016
+ })
1017
+ .strict()
1018
+ .superRefine((s, ctx) => {
1019
+ const set = [s.path, s.glob, s.url].filter((v) => v !== undefined).length;
1020
+ if (set !== 1) {
1021
+ ctx.addIssue({
1022
+ code: z.ZodIssueCode.custom,
1023
+ message: "each knowledge source needs exactly one of path/glob/url",
1024
+ path: [],
1025
+ });
1026
+ }
1027
+ });
1028
+ /**
1029
+ * Loop contract 0.4 (Batch E, G22) — the agent-shape RAG block. Presence
1030
+ * registers the EXISTING `@crewhaus/tool-retrieve` (chunker → embedder →
1031
+ * vector-store) as a `Retrieve` tool with citations, ingesting `sources` at
1032
+ * build/boot. It REUSES target-pipeline's retrieve lowering + engine (not a
1033
+ * fork), so the sub-keys mirror the pipeline shape:
1034
+ * - `embedder`: the `@crewhaus/embedder` factory grammar for the retrieve
1035
+ * tier. Optional; resolution order `knowledge.embedder → memory.embedder
1036
+ * → memory.wiki.embedder → the target's default embedder model` (a vector
1037
+ * store needs embeddings — see the memory block's EMBEDDER RESOLUTION
1038
+ * ORDER note).
1039
+ * - `vector_backend`: the SAME enum as pipeline `retrieve.vectorBackend`
1040
+ * (default `in-memory`).
1041
+ * - `sources` (required, >= 1): the corpus to ingest (see
1042
+ * {@link knowledgeSourceSchema}).
1043
+ * - `chunk.size` / `chunk.overlap`: chunker tuning (OPTIMIZABLE); default
1044
+ * to pipeline's 400 / 0 at lower time.
1045
+ * - `default_k`: hits returned per Retrieve call (int 1..50, default 5,
1046
+ * OPTIMIZABLE).
1047
+ * `.strict()` so a typo'd sub-key fails the build. Carried on cli/channel/
1048
+ * managed (the interactive agent-loop shapes); the pipeline shape keeps its
1049
+ * dedicated first-class `retrieve:`/`indexing:` blocks.
1050
+ */
1051
+ const knowledgeBlock = z
1052
+ .object({
1053
+ embedder: z.string().min(1).optional(),
1054
+ vector_backend: z.enum(VECTOR_BACKENDS).optional(),
1055
+ sources: z.array(knowledgeSourceSchema).min(1),
1056
+ chunk: z
1057
+ .object({
1058
+ size: z.number().int().positive().optional(),
1059
+ overlap: z.number().int().nonnegative().optional(),
1060
+ })
1061
+ .strict()
1062
+ .optional(),
1063
+ default_k: z.number().int().positive().max(50).optional(),
1064
+ })
1065
+ .strict()
1066
+ .optional();
610
1067
  /**
611
1068
  * v0.3.0 Goal 1 (§2.1) — the top-level `continuity:` block: focus, plans,
612
1069
  * goals, the proof-of-action ladder, the requirements ledger, and teardown
@@ -694,6 +1151,14 @@ const thredzObject = z
694
1151
  .regex(THREDZ_HANDLE_RE, "thredz.agents must be a lowercase handle matching ^[a-z][a-z0-9-]{2,31}$ (or true to derive one from the spec name)"),
695
1152
  ])
696
1153
  .optional(),
1154
+ /**
1155
+ * Item 5 (G44) — enable the nine Thredz messaging tools (`message_send`
1156
+ * / `inbox_poll` / `message_ack` / `thread_get` / `agent_*`). DEFAULT
1157
+ * false: the send-side tools are destructive + justification-gated, so
1158
+ * they stay off unless the author asks. The Thredz server side is already
1159
+ * live (thredz-api) — this flips their registration on.
1160
+ */
1161
+ messaging: z.boolean().optional(),
697
1162
  })
698
1163
  .strict();
699
1164
  const thredzBlock = z.union([z.boolean(), z.string().min(1), thredzObject]).optional();
@@ -811,9 +1276,103 @@ const sloBlock = z
811
1276
  s.ttft_ms !== undefined ||
812
1277
  s.cost_per_hour_usd !== undefined ||
813
1278
  s.egress_block_rate !== undefined, { message: "observability.slo must declare at least one target threshold" });
1279
+ /**
1280
+ * Loop contract 0.4 (Batch C, G26) — the observability control sub-blocks.
1281
+ * These declare which of the runtime's observability subscribers the emitted
1282
+ * bundle wires and how it stamps their env / subscriber options.
1283
+ *
1284
+ * DEFAULTS SEMANTICS (critical — mirrored in `@crewhaus/ir` + the lowering):
1285
+ * cost tracking and the trace ring buffer are DEFAULT ON even when the whole
1286
+ * `observability:` block is absent — spec ABSENCE is NOT `off`. An EXPLICIT
1287
+ * opt-out (`cost: { enabled: false }` / `trace: { level: off }`) wins. So the
1288
+ * lowering carries only what the spec declares (absent sub-block ⇒ absent IR
1289
+ * key ⇒ the emitter applies the default), and the presence of an explicit
1290
+ * `enabled: false` / `level: off` is what turns a subscriber off.
1291
+ *
1292
+ * - `trace.level`: `off` (no ring buffer, no printer) | `ring` (ring buffer
1293
+ * only, the DEFAULT) | `pretty` (ring + colorised stderr printer) | `json`
1294
+ * (ring + JSON-Lines printer). Absent ⇒ `ring`.
1295
+ * - `metrics.enabled`: attach the metrics-collector subscriber. Opt-IN —
1296
+ * absent ⇒ off.
1297
+ * - `cost.enabled`: attach the cost-tracker subscriber. DEFAULT ON — absent
1298
+ * ⇒ on; set `false` to suppress cost accrual entirely.
1299
+ * - `alerts.enabled`: arm the alert watchdog. Opt-IN — absent ⇒ off.
1300
+ * - `incidents.enabled`: arm incident capture. Opt-IN — absent ⇒ off.
1301
+ * - `otel.endpoint`: OTLP exporter endpoint (e.g. `http://localhost:4318`).
1302
+ * Absent ⇒ no OTel export. Carried verbatim (a `$VAR` value is the
1303
+ * emitter's to resolve).
1304
+ *
1305
+ * Every feature toggle carries a `.default(true)` on `enabled` so a bare
1306
+ * `metrics: {}` reads as "on"; the ABSENT-block default (opt-in features off,
1307
+ * cost/ring on) is applied downstream, not here. `.strict()` so a typo'd
1308
+ * sub-key fails the build.
1309
+ */
1310
+ const observabilityToggle = z.object({ enabled: z.boolean().default(true) }).strict();
1311
+ const observabilityTraceBlock = z
1312
+ .object({ level: z.enum(["off", "ring", "pretty", "json"]).default("ring") })
1313
+ .strict();
1314
+ const observabilityOtelBlock = z.object({ endpoint: z.string().min(1).optional() }).strict();
814
1315
  const observabilityBlock = z
815
1316
  .object({
816
1317
  slo: sloBlock.optional(),
1318
+ // Loop contract 0.4 (Batch C, G26) — subscriber/exporter controls.
1319
+ trace: observabilityTraceBlock.optional(),
1320
+ metrics: observabilityToggle.optional(),
1321
+ cost: observabilityToggle.optional(),
1322
+ alerts: observabilityToggle.optional(),
1323
+ incidents: observabilityToggle.optional(),
1324
+ otel: observabilityOtelBlock.optional(),
1325
+ })
1326
+ .strict()
1327
+ .optional();
1328
+ /**
1329
+ * "Watch me" — the sampled phase-2 judge pass of `crewhaus watchme report`,
1330
+ * the ONE model-spending analysis phase. Absent ⇒ deterministic-only reports
1331
+ * (the field defaults below still resolve at lower time, so a budgeted judge
1332
+ * always names a model).
1333
+ */
1334
+ const watchmeJudgeBlock = z
1335
+ .object({
1336
+ /** Judge model for the sampled phase-2 quality pass. Refused at runtime
1337
+ * if unpriced (dream-engine pattern) — the budget cap must be
1338
+ * enforceable. */
1339
+ model: z.string().min(1).default("claude-haiku-4-5"),
1340
+ /** Fraction of ungraded turns escalated to the judge. */
1341
+ sample_rate: z.number().min(0).max(1).default(0.15),
1342
+ /** Per-report spend cap. 0 (default) = deterministic-only reports. */
1343
+ budget_usd: z.number().min(0).default(0),
1344
+ })
1345
+ .strict();
1346
+ /**
1347
+ * "Watch me" — observe this harness's interactions and learn from them
1348
+ * (design/watch-me.md). Presence turns on the live capture tap; `crewhaus
1349
+ * watchme report` distills the watched sessions post-hoc. Carried on the
1350
+ * three interactive-loop shapes (cli, channel, managed); the strict unions
1351
+ * reject the key loudly elsewhere (research/crew are a named deferral,
1352
+ * design/watch-me.md §13.1).
1353
+ *
1354
+ * Deliberately a SIBLING of `observability:`, not a sub-key of it:
1355
+ * observability controls the generic telemetry subscribers (ring buffer,
1356
+ * printers, metrics, cost, alerts, otel) while watchme is a learning feature
1357
+ * with its own durable store and spec-synthesis outputs. Capture is
1358
+ * INDEPENDENT of `observability.trace.level` — that knob controls the ring
1359
+ * buffer + printers only, never the watchme tap.
1360
+ *
1361
+ * Every knob defaults, so a bare `watchme: {}` is a complete declaration.
1362
+ * NO `watchme.*` path is optimizer-tunable — see the exclusion note beside
1363
+ * OPTIMIZABLE_PATHS in `@crewhaus/spec-patch`.
1364
+ */
1365
+ const watchmeBlock = z
1366
+ .object({
1367
+ enabled: z.boolean().default(true),
1368
+ /** "full" = write the .events.jsonl trace sibling; "mirrors" = rely on the
1369
+ * default-on advisor mirrors only (retro-analysis grade, no extra file). */
1370
+ capture: z.enum(["full", "mirrors"]).default("full"),
1371
+ judge: watchmeJudgeBlock.optional(),
1372
+ /** "user" additionally registers this harness in the global registry at run time. */
1373
+ scope: z.enum(["harness", "user"]).default("harness"),
1374
+ /** Publish redacted distilled findings to the wiki/Thredz at report time. */
1375
+ share: z.boolean().default(false),
817
1376
  })
818
1377
  .strict()
819
1378
  .optional();
@@ -888,10 +1447,13 @@ const contractsBlock = z.array(contractBindingSchema).optional();
888
1447
  const transactionPolicyBlock = transactionPolicySchema.optional();
889
1448
  /**
890
1449
  * Phase 3 §3.3 — CLI banner with optional tagline rotation. When set,
891
- * the compiled cli-target bundle prints this banner on cold start
892
- * (suppressed under `--resume` / `--continue` so resumed sessions
893
- * don't re-banner). Static mode picks the first tagline; random mode
894
- * picks one uniformly per startup.
1450
+ * BOTH cli surfaces print this banner on cold start — the compiled
1451
+ * bundle and `crewhaus run` (which used to ignore the block entirely,
1452
+ * making an authored banner invisible to anyone who ran the spec
1453
+ * directly). Suppressed under `--resume` / `--continue` so resumed
1454
+ * sessions don't re-banner, and under `CREWHAUS_RESUMED=1` for a
1455
+ * wrapper re-invoking a compiled bundle. Static mode picks the first
1456
+ * tagline; random mode picks one uniformly per startup.
895
1457
  */
896
1458
  const cliBannerBlock = z
897
1459
  .object({
@@ -904,12 +1466,20 @@ const cliOptionsBlock = z
904
1466
  .object({
905
1467
  banner: cliBannerBlock,
906
1468
  /**
907
- * Phase 2 M2.2 — TUI polish gate. "basic" is the current readline-
908
- * driven REPL; "rich" is reserved for future Ink-based output
909
- * (status line, multi-line input, ESC interrupt). Today both modes
910
- * compile identically; the field is forward-compatible.
1469
+ * Phase 2 M2.2 — TUI mode. `"basic"` is the readline-driven REPL and the
1470
+ * only mode. Loop contract 0.4 (Batch F, G81) DROPS the never-implemented
1471
+ * `"rich"` (Ink-based) placeholder: it compiled identically to `"basic"`,
1472
+ * so it only ever advertised a capability that did not exist. Declaring
1473
+ * it now fails the compile with a migration note; a future rich TUI would
1474
+ * reintroduce the value when it actually ships.
911
1475
  */
912
- tui: z.enum(["basic", "rich"]).default("basic"),
1476
+ tui: z
1477
+ .literal("basic", {
1478
+ errorMap: () => ({
1479
+ message: 'cli.tui "rich" was never implemented and is dropped in loop-contract 0.4 — remove the `tui:` key (the basic readline REPL is the only mode).',
1480
+ }),
1481
+ })
1482
+ .default("basic"),
913
1483
  })
914
1484
  .strict()
915
1485
  .optional();
@@ -931,6 +1501,52 @@ const heartbeatBlock = z
931
1501
  })
932
1502
  .strict()
933
1503
  .optional();
1504
+ /**
1505
+ * Loop contract 0.4 (Batch F, temporal contract / G84 schedule half) — a
1506
+ * `schedule:` block on the daemon-able shapes (channel / managed / batch): a
1507
+ * cron OR interval wake trigger, lowered into the emitted daemon's wake loop
1508
+ * by the temporal downstream. `jitter` (a duration) spreads a random +/- delay
1509
+ * across the trigger so a fleet of identical daemons doesn't stampede on the
1510
+ * boundary; `instructions` is the synthetic prompt each wake runs (the
1511
+ * heartbeat contract, generalised past the fixed interval to a cron). Exactly
1512
+ * one of the two `kind`s — the discriminated union makes the required field
1513
+ * per kind (`cron` vs `every`) a type error to omit.
1514
+ *
1515
+ * Unlike `heartbeat` (channel-only, interval-only), `schedule` is the general
1516
+ * temporal surface: it accepts a cron expression AND rides the `runs resume`
1517
+ * rehydration path, so an interrupted scheduled run resumes exactly-once.
1518
+ */
1519
+ // A 5- or 6-field cron expression (minute-granularity, optional seconds/year).
1520
+ // Field validity beyond the char class is the daemon's cron parser's job.
1521
+ const CRON_REGEX = /^[0-9*\/,\-?LW#]+(?:\s+[0-9*\/,\-?LW#]+){4,5}$/;
1522
+ const scheduleJitter = z
1523
+ .string()
1524
+ .regex(DURATION_REGEX, 'schedule.jitter must be a duration like "30s", "5m", or "500ms"');
1525
+ const scheduleCronBlock = z
1526
+ .object({
1527
+ kind: z.literal("cron"),
1528
+ cron: z
1529
+ .string()
1530
+ .regex(CRON_REGEX, 'schedule.cron must be a 5- or 6-field cron expression, e.g. "0 */6 * * *"'),
1531
+ /** IANA tz name the cron is evaluated in (e.g. "America/New_York"). */
1532
+ timezone: z.string().min(1).optional(),
1533
+ jitter: scheduleJitter.optional(),
1534
+ instructions: z.string().min(1).optional(),
1535
+ })
1536
+ .strict();
1537
+ const scheduleIntervalBlock = z
1538
+ .object({
1539
+ kind: z.literal("interval"),
1540
+ every: z
1541
+ .string()
1542
+ .regex(DURATION_REGEX, 'schedule.every must be a duration like "6h", "30m", or "60s"'),
1543
+ jitter: scheduleJitter.optional(),
1544
+ instructions: z.string().min(1).optional(),
1545
+ })
1546
+ .strict();
1547
+ const scheduleBlock = z
1548
+ .discriminatedUnion("kind", [scheduleCronBlock, scheduleIntervalBlock])
1549
+ .optional();
934
1550
  /**
935
1551
  * Phase 3 §3.4 — channel daemon control-UI gateway. When set, the
936
1552
  * compiled daemon spawns a second HTTP listener on `port` that serves
@@ -945,6 +1561,46 @@ const channelGatewayBlock = z
945
1561
  })
946
1562
  .strict()
947
1563
  .optional();
1564
+ /**
1565
+ * Item 1 (G30) — the `expose:` block: project THIS compiled bundle's turn
1566
+ * function as an MCP server so Claude Code / IDEs / other CrewHaus runtimes
1567
+ * can call the whole agent as a tool. Carried on the serving shapes
1568
+ * (cli/channel/managed).
1569
+ *
1570
+ * - `mcp.transport`: `stdio` (a spawned stdio MCP server — the
1571
+ * `crewhaus serve --mcp` path) or `sse` (an HTTP+SSE endpoint; SSE-backed
1572
+ * exposure rides the gateway-server tenancy/budgets where the shape has
1573
+ * them).
1574
+ * - `mcp.tools`: `chat` (DEFAULT — one primary invoke tool taking
1575
+ * `{ message }` and returning the final assistant text) or `per-subagent`
1576
+ * (that primary tool PLUS one tool per declared sub-agent). `per-subagent`
1577
+ * needs sub-agents to project — enforced cross-field in `parseSpec`.
1578
+ *
1579
+ * Omitted entirely → the bundle is not exposed as an MCP server (the default).
1580
+ */
1581
+ const exposeBlock = z
1582
+ .object({
1583
+ mcp: z
1584
+ .object({
1585
+ transport: z.enum(["stdio", "sse"]),
1586
+ tools: z.enum(["chat", "per-subagent"]).optional(),
1587
+ })
1588
+ .strict()
1589
+ .optional(),
1590
+ })
1591
+ .strict()
1592
+ .optional();
1593
+ /**
1594
+ * Item 3 (G32) — the `plugins:` list: names of installed marketplace plugins
1595
+ * whose contributions (tools / channels / models / graders / emitters, plus
1596
+ * skill dirs) this bundle loads at boot. Each entry is a plugin NAME resolved
1597
+ * against the pinned `plugin-registry` (the Ed25519 supply chain guards
1598
+ * install; this wires the previously-missing load path). Order is honoured
1599
+ * (load order). The `crewhaus run --plugins` flag overrides the list. Carried
1600
+ * on the codegen-serving shapes whose boot path reads the registry (cli +
1601
+ * channel).
1602
+ */
1603
+ const pluginsBlock = z.array(z.string().min(1)).optional();
948
1604
  const cliSchema = z
949
1605
  .object({
950
1606
  name: safeName,
@@ -958,6 +1614,13 @@ const cliSchema = z
958
1614
  // runtime default applies. Raise it for turns that emit large
959
1615
  // multi-file edits so the model isn't cut off mid-`tool_use`.
960
1616
  max_tokens: z.number().int().positive().optional(),
1617
+ // Loop contract 0.4 (Batch A) — extended-thinking selector.
1618
+ thinking: thinkingBlock,
1619
+ // Loop contract 0.4 (Batch A) — stream partial output tokens.
1620
+ // Optional; absent means false (the cli-shape default).
1621
+ streaming: z.boolean().optional(),
1622
+ // Loop contract 0.4 (Batch A) — per-tool rate limits.
1623
+ rate_limits: rateLimitsBlock,
961
1624
  // Item 22 — provider failover chain (see modelFallbacksBlock docs).
962
1625
  model_fallbacks: modelFallbacksBlock,
963
1626
  circuit_breaker: circuitBreakerBlock,
@@ -977,12 +1640,24 @@ const cliSchema = z
977
1640
  security: securityBlock,
978
1641
  failure_taxonomy: failureTaxonomyBlock,
979
1642
  budget: budgetBlock,
1643
+ limits: limitsBlock,
1644
+ hooks: hooksBlock,
1645
+ // Batch G — expose the bundle as an MCP server (G30) + load marketplace
1646
+ // plugins at boot (G32).
1647
+ expose: exposeBlock,
1648
+ plugins: pluginsBlock,
1649
+ // Loop contract 0.4 (Batch B, G02) — in-loop output evaluation.
1650
+ evaluation: evaluationBlock,
980
1651
  feedback: feedbackBlock,
981
1652
  memory: memoryBlock,
1653
+ // Loop contract 0.4 (Batch E, G22) — agent-shape RAG over doc sources.
1654
+ knowledge: knowledgeBlock,
982
1655
  continuity: continuityBlock,
983
1656
  thredz: thredzBlock,
984
1657
  learning: learningBlock,
985
1658
  observability: observabilityBlock,
1659
+ // "Watch me" — observe-and-learn (sibling of observability, see watchmeBlock).
1660
+ watchme: watchmeBlock,
986
1661
  cli: cliOptionsBlock,
987
1662
  chains: chainsBlock,
988
1663
  wallets: walletsBlock,
@@ -995,21 +1670,57 @@ const workflowStepSchema = z
995
1670
  name: safeName,
996
1671
  instructions: z.string().min(1),
997
1672
  model: z.string().min(1).optional(),
1673
+ // Model max OUTPUT tokens for this step's turn (mirrors cli
1674
+ // `agent.max_tokens`). Optional; runtime default when omitted.
1675
+ max_tokens: z.number().int().positive().optional(),
1676
+ // Loop contract 0.4 (Batch A) — per-step extended-thinking selector.
1677
+ thinking: thinkingBlock,
998
1678
  tools: z.array(z.string().min(1)).optional(),
999
1679
  tool_config: toolConfigBlock,
1680
+ // Item 9 (G37) — per-step model routing, adopting the cli agent block's
1681
+ // pooled pattern verbatim: ordered failover + breaker tuning + two-tier
1682
+ // router + N-candidate pool, sharing the one mutual-exclusion rule via
1683
+ // `refineModelSelection`. A PolicyRouter decides per step against the
1684
+ // shared routing-store scoreboard. Omitted → the step's single
1685
+ // (`step.model ?? workflow.model`) model, byte-identical bundles.
1686
+ model_fallbacks: modelFallbacksBlock,
1687
+ circuit_breaker: circuitBreakerBlock,
1688
+ model_tiers: modelTiersBlock,
1689
+ model_pool: modelPoolBlock,
1000
1690
  })
1001
- .strict();
1691
+ .strict()
1692
+ .superRefine(refineModelSelection);
1693
+ /**
1694
+ * Loop contract 0.4 (Batch B, G02) — the `kind: "judge"` workflow-step
1695
+ * variant: a gate over the PREVIOUS step's output (see
1696
+ * {@link judgeGateBlock}). Judge steps run no agent turn of their own, so
1697
+ * they carry no instructions/tools — only the gate config. A judge step
1698
+ * cannot be the first step (there is no previous output to gate; enforced
1699
+ * in `parseSpec`). Regular steps stay exactly as before (no `kind` key).
1700
+ */
1701
+ const workflowJudgeStepSchema = z
1702
+ .object({
1703
+ name: safeName,
1704
+ kind: z.literal("judge"),
1705
+ judge: judgeGateBlock,
1706
+ })
1707
+ .strict()
1708
+ .describe("judge gate step: scores the previous step's output instead of running an agent turn");
1709
+ const workflowAnyStepSchema = z.union([workflowStepSchema, workflowJudgeStepSchema]);
1002
1710
  const workflowSchema = z
1003
1711
  .object({
1004
1712
  name: safeName,
1005
1713
  version: versionField,
1006
1714
  target: z.literal("workflow"),
1007
1715
  model: z.string().min(1),
1008
- steps: z.array(workflowStepSchema).min(1),
1716
+ steps: z.array(workflowAnyStepSchema).min(1),
1009
1717
  mcp_servers: mcpServersBlock,
1010
1718
  permissions: permissionsBlock,
1011
1719
  compaction: compactionBlock,
1012
1720
  failure_taxonomy: failureTaxonomyBlock,
1721
+ budget: budgetBlock,
1722
+ limits: limitsBlock,
1723
+ hooks: hooksBlock,
1013
1724
  // v0.3.0 — carried but not emit-wired in 0.3.0 (ignored-note comment in
1014
1725
  // the generated bundle; NOT default-on here).
1015
1726
  continuity: continuityBlock,
@@ -1055,6 +1766,15 @@ const whatsappChannelSchema = z
1055
1766
  phoneNumberId: z.string().min(1),
1056
1767
  accessToken: z.string().min(1),
1057
1768
  appSecret: z.string().min(1),
1769
+ // The token Meta presents on the GET callback-URL verification handshake
1770
+ // (`hub.verify_token`). Optional: a daemon serving an already-verified
1771
+ // subscription does not need it, and without it the handshake fails
1772
+ // closed rather than echoing an unauthenticated challenge.
1773
+ verifyToken: z
1774
+ .string()
1775
+ .min(1)
1776
+ .optional()
1777
+ .describe("shared token echoed back on Meta's GET callback-URL verification handshake; required to verify a new webhook subscription"),
1058
1778
  })
1059
1779
  .strict();
1060
1780
  const imessageChannelSchema = z
@@ -1088,6 +1808,13 @@ const channelAgentSchema = z
1088
1808
  .object({
1089
1809
  model: z.string().min(1),
1090
1810
  instructions: z.string().min(1),
1811
+ // Model max OUTPUT tokens for one turn (mirrors cli `agent.max_tokens`).
1812
+ // Optional; runtime default when omitted.
1813
+ max_tokens: z.number().int().positive().optional(),
1814
+ // Loop contract 0.4 (Batch A) — extended-thinking selector.
1815
+ thinking: thinkingBlock,
1816
+ // Loop contract 0.4 (Batch A) — per-tool rate limits.
1817
+ rate_limits: rateLimitsBlock,
1091
1818
  // Item 22 — provider failover chain (see modelFallbacksBlock docs).
1092
1819
  model_fallbacks: modelFallbacksBlock,
1093
1820
  circuit_breaker: circuitBreakerBlock,
@@ -1114,13 +1841,28 @@ const channelSchema = z
1114
1841
  compaction: compactionBlock,
1115
1842
  failure_taxonomy: failureTaxonomyBlock,
1116
1843
  budget: budgetBlock,
1844
+ limits: limitsBlock,
1845
+ hooks: hooksBlock,
1846
+ // Batch G — expose the daemon's turn as an MCP server (G30) + load
1847
+ // marketplace plugins at boot (G32).
1848
+ expose: exposeBlock,
1849
+ plugins: pluginsBlock,
1850
+ // Loop contract 0.4 (Batch B, G02) — in-loop output evaluation.
1851
+ evaluation: evaluationBlock,
1117
1852
  feedback: feedbackBlock,
1118
1853
  memory: memoryBlock,
1854
+ // Loop contract 0.4 (Batch E, G22) — agent-shape RAG over doc sources.
1855
+ knowledge: knowledgeBlock,
1119
1856
  continuity: continuityBlock,
1120
1857
  thredz: thredzBlock,
1121
1858
  learning: learningBlock,
1122
1859
  observability: observabilityBlock,
1860
+ // "Watch me" — observe-and-learn (sibling of observability, see watchmeBlock).
1861
+ watchme: watchmeBlock,
1123
1862
  heartbeat: heartbeatBlock,
1863
+ // Loop contract 0.4 (Batch F) — cron/interval wake trigger (the general
1864
+ // temporal surface beside the interval-only `heartbeat`).
1865
+ schedule: scheduleBlock,
1124
1866
  gateway: channelGatewayBlock,
1125
1867
  chains: chainsBlock,
1126
1868
  wallets: walletsBlock,
@@ -1135,12 +1877,36 @@ const graphNodeSchema = z
1135
1877
  .object({
1136
1878
  instructions: z.string().min(1),
1137
1879
  model: z.string().min(1).optional(),
1880
+ // Model max OUTPUT tokens for this node's turn (mirrors cli
1881
+ // `agent.max_tokens`). Optional; runtime default when omitted.
1882
+ max_tokens: z.number().int().positive().optional(),
1883
+ // Loop contract 0.4 (Batch A) — per-node extended-thinking selector.
1884
+ thinking: thinkingBlock,
1138
1885
  tools: z.array(z.string().min(1)).optional(),
1139
1886
  tool_config: toolConfigBlock,
1140
1887
  /**
1141
- * When true, the node calls `ctx.requestApproval(prompt)` before
1142
- * returning. The engine pauses, persists a checkpoint, and waits for
1143
- * `resume(checkpointId, decision)` from the operator/CLI.
1888
+ * A human approval gate on this node, and a PRE-condition: the node
1889
+ * calls `ctx.requestApproval(prompt)` BEFORE its model turn, so the
1890
+ * prompt is answered against the UPSTREAM state (which the `hitl_pause`
1891
+ * event and the bundle's pause report both print) and no tokens are
1892
+ * spent until the human answers. The engine pauses, persists a
1893
+ * checkpoint, and waits for `resume(checkpointId, decision)` from the
1894
+ * operator/CLI; the resumed run replays this node from the top and
1895
+ * makes its FIRST model call.
1896
+ *
1897
+ * The decision string is recorded at `state["<node>_decision"]`, which
1898
+ * every downstream node reads as part of the upstream state. (NOTE:
1899
+ * `edges[].when.key` cannot name it yet — that key must name a declared
1900
+ * node; see the `graphEdgeWhenSchema` note below.) A rejecting decision
1901
+ * — `reject`, `no`, `deny`, `decline`, `abort`, `cancel`, `stop`, `veto`
1902
+ * (trimmed, case-insensitive) — cancels this node's turn entirely, so
1903
+ * the node records only its decision and no output; any other string,
1904
+ * including free text, approves it. To halt the run on a rejection,
1905
+ * guard the node's outgoing edge with `when: { key: <node>, exists:
1906
+ * true }` — a cancelled node records no output, so no edge matches.
1907
+ *
1908
+ * To have a human approve a node's OWN output, put the gate on the
1909
+ * DOWNSTREAM node: its upstream state is exactly that output.
1144
1910
  */
1145
1911
  hitl: z
1146
1912
  .object({
@@ -1150,10 +1916,70 @@ const graphNodeSchema = z
1150
1916
  .optional(),
1151
1917
  })
1152
1918
  .strict();
1919
+ /**
1920
+ * Loop contract 0.4 (Batch B, G02) — the `kind: "judge"` graph-node
1921
+ * variant: a gate over the node's UPSTREAM output (see
1922
+ * {@link judgeGateBlock}). Judge nodes run no agent turn of their own, so
1923
+ * they carry no instructions/tools — only the gate config. The graph entry
1924
+ * cannot be a judge node (there is no upstream output to gate; enforced in
1925
+ * `parseSpec`). Regular nodes stay exactly as before (no `kind` key).
1926
+ */
1927
+ const graphJudgeNodeSchema = z
1928
+ .object({
1929
+ kind: z.literal("judge"),
1930
+ judge: judgeGateBlock,
1931
+ })
1932
+ .strict()
1933
+ .describe("judge gate node: scores the upstream node's output instead of running an agent turn");
1934
+ const graphAnyNodeSchema = z.union([graphNodeSchema, graphJudgeNodeSchema]);
1935
+ /**
1936
+ * Loop contract 0.4 (Batch A) — declarative edge predicate over the graph's
1937
+ * shared state. The generated graph state is a plain record where each node
1938
+ * writes its reply under its own name (`state["<nodeName>"]`), so `key`
1939
+ * names the upstream NODE whose recorded output the predicate reads
1940
+ * (cross-validated against `nodes` in `parseSpec`). Exactly ONE test form
1941
+ * must be declared (enforced by superRefine):
1942
+ *
1943
+ * - `equals` — take the edge when `state[key] === equals` (string/number/
1944
+ * boolean strict equality).
1945
+ * - `exists: true` — take the edge when `state[key] !== undefined` (the
1946
+ * node has produced output — which, for a `hitl:` node, is FALSE when
1947
+ * the operator rejected the gate, since a rejected node records only
1948
+ * `state["<node>_decision"]`).
1949
+ *
1950
+ * GAP (unchanged by the pre-condition HITL fix): `key` may not yet name a
1951
+ * hitl node's `<node>_decision` record — the cross-check below pins it to a
1952
+ * declared node name, so a rejection can be observed via `exists` on the
1953
+ * node itself but not matched on the decision string. Widening it means
1954
+ * touching the three mirrored checks (this one, ir-passes' graph
1955
+ * wellformedness, target-graph's validateGraph).
1956
+ *
1957
+ * Lowered to `IrGraphEdge.when` and emitted as a graph-engine
1958
+ * `EdgeCondition` (`(state) => state[key] === equals` / `!== undefined`).
1959
+ * The engine evaluates edges in declaration order and takes the first
1960
+ * match; an edge without `when` matches unconditionally.
1961
+ */
1962
+ const graphEdgeWhenSchema = z
1963
+ .object({
1964
+ key: z.string().min(1),
1965
+ equals: z.union([z.string(), z.number(), z.boolean()]).optional(),
1966
+ exists: z.literal(true).optional(),
1967
+ })
1968
+ .strict()
1969
+ .superRefine((w, ctx) => {
1970
+ const forms = (w.equals !== undefined ? 1 : 0) + (w.exists !== undefined ? 1 : 0);
1971
+ if (forms !== 1) {
1972
+ ctx.addIssue({
1973
+ code: z.ZodIssueCode.custom,
1974
+ message: "edge when requires exactly one of equals (value test) or exists: true",
1975
+ });
1976
+ }
1977
+ });
1153
1978
  const graphEdgeSchema = z
1154
1979
  .object({
1155
1980
  from: z.string().min(1),
1156
1981
  to: z.string().min(1),
1982
+ when: graphEdgeWhenSchema.optional(),
1157
1983
  })
1158
1984
  .strict();
1159
1985
  const graphSchema = z
@@ -1163,11 +1989,23 @@ const graphSchema = z
1163
1989
  target: z.literal("graph"),
1164
1990
  model: z.string().min(1),
1165
1991
  entry: z.string().min(1),
1166
- nodes: z.record(safeName, graphNodeSchema),
1992
+ nodes: z.record(safeName, graphAnyNodeSchema),
1167
1993
  edges: z.array(graphEdgeSchema).default([]),
1994
+ /**
1995
+ * Loop contract 0.4 (Batch A) — parallel barrier groups, lowered onto
1996
+ * graph-engine's `addParallel`. Each group is >= 2 node names (the
1997
+ * engine rejects smaller groups) that execute concurrently when the
1998
+ * cursor reaches the group's FIRST member; execution continues from the
1999
+ * LAST member's outgoing edge. Node names are cross-validated against
2000
+ * `nodes` in `parseSpec`.
2001
+ */
2002
+ parallel: z.array(z.array(z.string().min(1)).min(2)).optional(),
1168
2003
  permissions: permissionsBlock,
1169
2004
  compaction: compactionBlock,
1170
2005
  failure_taxonomy: failureTaxonomyBlock,
2006
+ budget: budgetBlock,
2007
+ limits: limitsBlock,
2008
+ hooks: hooksBlock,
1171
2009
  chains: chainsBlock,
1172
2010
  wallets: walletsBlock,
1173
2011
  contracts: contractsBlock,
@@ -1193,6 +2031,13 @@ const managedAgentSchema = z
1193
2031
  .object({
1194
2032
  model: z.string().min(1),
1195
2033
  instructions: z.string().min(1),
2034
+ // Model max OUTPUT tokens for one turn (mirrors cli `agent.max_tokens`).
2035
+ // Optional; runtime default when omitted.
2036
+ max_tokens: z.number().int().positive().optional(),
2037
+ // Loop contract 0.4 (Batch A) — extended-thinking selector.
2038
+ thinking: thinkingBlock,
2039
+ // Loop contract 0.4 (Batch A) — per-tool rate limits.
2040
+ rate_limits: rateLimitsBlock,
1196
2041
  // Item 22 — provider failover chain (see modelFallbacksBlock docs).
1197
2042
  model_fallbacks: modelFallbacksBlock,
1198
2043
  circuit_breaker: circuitBreakerBlock,
@@ -1200,6 +2045,11 @@ const managedAgentSchema = z
1200
2045
  model_tiers: modelTiersBlock,
1201
2046
  // Adaptive model routing — N-candidate pool with a selection policy.
1202
2047
  model_pool: modelPoolBlock,
2048
+ // Loop contract 0.4 (Batch F, G81) — the managed daemon gets a tool
2049
+ // catalog + per-tenant tool_config overlays (applied at runtime through
2050
+ // the policy-engine's tenant context). Mirrors the channel agent block.
2051
+ tools: z.array(z.string().min(1)).optional(),
2052
+ tool_config: toolConfigBlock,
1203
2053
  })
1204
2054
  .strict()
1205
2055
  .superRefine(refineModelSelection);
@@ -1214,24 +2064,50 @@ const managedSchema = z
1214
2064
  compaction: compactionBlock,
1215
2065
  failure_taxonomy: failureTaxonomyBlock,
1216
2066
  budget: budgetBlock,
2067
+ limits: limitsBlock,
2068
+ hooks: hooksBlock,
2069
+ // Batch G — expose the managed daemon as an MCP server (G30). SSE-backed
2070
+ // exposure rides this shape's gateway-server tenancy/budgets. No
2071
+ // `plugins:` here: item 3's boot-path wiring covers cli + channel-bot
2072
+ // codegen, not the managed daemon.
2073
+ expose: exposeBlock,
2074
+ // Loop contract 0.4 (Batch B, G02) — in-loop output evaluation.
2075
+ evaluation: evaluationBlock,
2076
+ // NEW-inloop-coverage — human-rating capture on the GATEWAY shape.
2077
+ //
2078
+ // WHAT MANAGED SUPPORTS: the daemon serves a `feedback.submit` JSON-RPC
2079
+ // method (params = the user-supplied FeedbackRecord subset) that appends
2080
+ // a standard record to `.crewhaus/feedback/<tenant>.jsonl` — the exact
2081
+ // sink `crewhaus distill` / `optimize --ratings` / `judge calibrate`
2082
+ // already read; and `autoDistill: true` registers the janitor step that
2083
+ // turns those ratings into versioned `<name>-ratings` registry datasets
2084
+ // on the daemon's own clock (D39), because a gateway daemon never runs a
2085
+ // `crewhaus run` teardown.
2086
+ //
2087
+ // WHAT IT CANNOT SUPPORT: `exitPrompt` is meaningless here (there is no
2088
+ // REPL to exit — the compiler warns when it is set), and
2089
+ // `channelReactions` is the channel shape's own inbound-reaction gate
2090
+ // (also warned). Both parse for schema uniformity across shapes rather
2091
+ // than being silently honoured.
2092
+ feedback: feedbackBlock,
1217
2093
  memory: memoryBlock,
2094
+ // Loop contract 0.4 (Batch E, G22) — agent-shape RAG over doc sources.
2095
+ knowledge: knowledgeBlock,
1218
2096
  continuity: continuityBlock,
1219
2097
  thredz: thredzBlock,
1220
2098
  learning: learningBlock,
1221
2099
  observability: observabilityBlock,
2100
+ // "Watch me" — observe-and-learn (sibling of observability, see watchmeBlock).
2101
+ // Parse + lower ONLY on this shape in v1: compile() warns accepted-but-unwired.
2102
+ watchme: watchmeBlock,
2103
+ // Loop contract 0.4 (Batch F) — cron/interval wake trigger.
2104
+ schedule: scheduleBlock,
1222
2105
  })
1223
2106
  .strict();
1224
- // Vector-store backend ids accepted in specs. Mirrors `VectorBackendId`
1225
- // from @crewhaus/vector-store (and `IrVectorBackend`) — the canonical set
1226
- // of implemented backends — kept inline so the spec stays dependency-light.
1227
- // Keep in sync when a backend is added or removed.
1228
- const VECTOR_BACKENDS = ["in-memory", "lance", "qdrant", "pinecone", "weaviate"];
1229
- // The HTTP backends construct only with a `url` + `collection` (the
1230
- // vector-store factory throws otherwise); parseSpec requires both so a
1231
- // spec that selects one without them fails at compile, not at runtime.
1232
- const HTTP_VECTOR_BACKENDS = new Set(["qdrant", "pinecone", "weaviate"]);
1233
2107
  // Pipeline / RAG target (Section 21). Carries the embedder + vector-store
1234
2108
  // config, an indexing pipeline, and a chat agent that uses Retrieve.
2109
+ // (`VECTOR_BACKENDS` / `HTTP_VECTOR_BACKENDS` are declared above, beside the
2110
+ // `knowledge:` block that first consumes them.)
1235
2111
  const pipelineDocumentSchema = z
1236
2112
  .object({
1237
2113
  id: z.string().min(1),
@@ -1240,23 +2116,36 @@ const pipelineDocumentSchema = z
1240
2116
  })
1241
2117
  .strict();
1242
2118
  /**
1243
- * Adaptive model routing — the minimal single-agent block shared by the
1244
- * pipeline/research/batch/browser shapes, now carrying the opt-in
1245
- * `model_pool`. Their emitted runtimes each call `runChatLoop` with a single
1246
- * primary (exactly the cli shape's execution model), so the pool routes there
1247
- * with zero runtime changes. The superRefine is trivially satisfied today
1248
- * (these shapes carry no `model_tiers`/`model_fallbacks`) but keeps the
1249
- * mutual-exclusion rule uniform if they ever gain them. NOT used by
1250
- * onchain/onchain-game: their emitted bundles are callable modules whose
1251
- * agent-loop wiring is still deferred (see target-onchain slice-2 notes), so
1252
- * a `model_pool` there would be an inert spec field.
2119
+ * Adaptive model routing — the minimal single-agent block on the pipeline
2120
+ * shape, carrying the opt-in `model_pool`. Its emitted runtime calls
2121
+ * `runChatLoop` with a single primary (exactly the cli shape's execution
2122
+ * model), so the pool routes there with zero runtime changes. The
2123
+ * superRefine is trivially satisfied today (the shape carries no
2124
+ * `model_tiers`/`model_fallbacks`) but keeps the mutual-exclusion rule
2125
+ * uniform if it ever gains them. NOT used by onchain/onchain-game: their
2126
+ * emitted bundles are callable modules whose agent-loop wiring is still
2127
+ * deferred (see target-onchain slice-2 notes), so a `model_pool` there
2128
+ * would be an inert spec field.
1253
2129
  */
1254
- const pooledSingleAgentSchema = z
2130
+ const pooledSingleAgentObject = z
1255
2131
  .object({
1256
2132
  model: z.string().min(1),
1257
2133
  instructions: z.string().min(1),
1258
2134
  // Adaptive model routing — N-candidate pool with a selection policy.
1259
2135
  model_pool: modelPoolBlock,
2136
+ })
2137
+ .strict();
2138
+ const pooledSingleAgentSchema = pooledSingleAgentObject.superRefine(refineModelSelection);
2139
+ /**
2140
+ * Loop contract 0.4 (Batch A) — the research/batch/browser variant of the
2141
+ * pooled single-agent block: pipeline's shape plus `max_tokens` (model max
2142
+ * OUTPUT tokens for one turn, mirroring the cli docblock — optional; when
2143
+ * omitted the runtime default applies; raise it for turns that emit large
2144
+ * outputs so the model isn't cut off mid-`tool_use`).
2145
+ */
2146
+ const pooledSingleAgentWithMaxTokensSchema = pooledSingleAgentObject
2147
+ .extend({
2148
+ max_tokens: z.number().int().positive().optional(),
1260
2149
  })
1261
2150
  .strict()
1262
2151
  .superRefine(refineModelSelection);
@@ -1304,11 +2193,27 @@ const crewRoleSchema = z
1304
2193
  .object({
1305
2194
  instructions: z.string().min(1),
1306
2195
  model: z.string().min(1).optional(),
2196
+ // Model max OUTPUT tokens for this role's turns (mirrors cli
2197
+ // `agent.max_tokens`). Optional; runtime default when omitted.
2198
+ max_tokens: z.number().int().positive().optional(),
2199
+ // Loop contract 0.4 (Batch A) — per-role extended-thinking selector.
2200
+ thinking: thinkingBlock,
1307
2201
  tools: z.array(z.string().min(1)).optional(),
1308
2202
  tool_config: toolConfigBlock,
1309
2203
  sub_agents: subAgentsBlock,
2204
+ // Item 9 (G37) — per-role model routing, adopting the cli agent block's
2205
+ // pooled pattern verbatim: ordered failover + breaker tuning + two-tier
2206
+ // router + N-candidate pool, sharing the one mutual-exclusion rule via
2207
+ // `refineModelSelection`. A PolicyRouter decides per role against the
2208
+ // shared routing-store scoreboard. Omitted → the role's single
2209
+ // (`role.model ?? crew.model`) model, byte-identical bundles.
2210
+ model_fallbacks: modelFallbacksBlock,
2211
+ circuit_breaker: circuitBreakerBlock,
2212
+ model_tiers: modelTiersBlock,
2213
+ model_pool: modelPoolBlock,
1310
2214
  })
1311
- .strict();
2215
+ .strict()
2216
+ .superRefine(refineModelSelection);
1312
2217
  const crewRoutingMatchEntrySchema = z
1313
2218
  .object({
1314
2219
  contains: z.string().min(1),
@@ -1335,6 +2240,11 @@ const crewSchema = z
1335
2240
  permissions: permissionsBlock,
1336
2241
  compaction: compactionBlock,
1337
2242
  failure_taxonomy: failureTaxonomyBlock,
2243
+ budget: budgetBlock,
2244
+ // Loop contract 0.4 (Batch A) — crew is the ONE shape whose limits block
2245
+ // additionally accepts the `crew:` orchestration sub-block.
2246
+ limits: crewLimitsBlock,
2247
+ hooks: hooksBlock,
1338
2248
  // v0.3.0 — crew joins the memory-carrying shapes (§9: emit-wired; the
1339
2249
  // roles share the spec-scoped stores — the plan IS the coordination
1340
2250
  // surface, §2.7).
@@ -1342,6 +2252,11 @@ const crewSchema = z
1342
2252
  continuity: continuityBlock,
1343
2253
  thredz: thredzBlock,
1344
2254
  learning: learningBlock,
2255
+ // Loop contract 0.4 (Batch C, G26) — crew joins the observability-carrying
2256
+ // shapes (cli/channel/managed): the orchestrator's cost/trace/metrics/
2257
+ // alert/incident/otel subscribers are spec-controllable per the shared
2258
+ // block's defaults semantics.
2259
+ observability: observabilityBlock,
1345
2260
  chains: chainsBlock,
1346
2261
  wallets: walletsBlock,
1347
2262
  contracts: contractsBlock,
@@ -1368,7 +2283,7 @@ const researchSchema = z
1368
2283
  name: safeName,
1369
2284
  version: versionField,
1370
2285
  target: z.literal("research"),
1371
- agent: pooledSingleAgentSchema,
2286
+ agent: pooledSingleAgentWithMaxTokensSchema,
1372
2287
  goal: z.string().min(1),
1373
2288
  branchingFactor: z.number().int().min(1).max(8).default(3),
1374
2289
  maxDurationMs: z.number().int().positive().default(300_000),
@@ -1379,6 +2294,9 @@ const researchSchema = z
1379
2294
  permissions: permissionsBlock,
1380
2295
  compaction: compactionBlock,
1381
2296
  failure_taxonomy: failureTaxonomyBlock,
2297
+ budget: budgetBlock,
2298
+ limits: limitsBlock,
2299
+ hooks: hooksBlock,
1382
2300
  memory: memoryBlock,
1383
2301
  continuity: continuityBlock,
1384
2302
  thredz: thredzBlock,
@@ -1407,7 +2325,7 @@ const batchSchema = z
1407
2325
  name: safeName,
1408
2326
  version: versionField,
1409
2327
  target: z.literal("batch"),
1410
- agent: pooledSingleAgentSchema,
2328
+ agent: pooledSingleAgentWithMaxTokensSchema,
1411
2329
  queue: batchQueueSchema,
1412
2330
  concurrency: z.number().int().min(1).max(64).default(4),
1413
2331
  idempotencyWindowMs: z.number().int().positive().default(60_000),
@@ -1417,9 +2335,15 @@ const batchSchema = z
1417
2335
  permissions: permissionsBlock,
1418
2336
  compaction: compactionBlock,
1419
2337
  failure_taxonomy: failureTaxonomyBlock,
2338
+ budget: budgetBlock,
2339
+ limits: limitsBlock,
2340
+ hooks: hooksBlock,
1420
2341
  // v0.3.0 — carried but not emit-wired in 0.3.0 (ignored-note comment in
1421
2342
  // the generated bundle; NOT default-on here).
1422
2343
  continuity: continuityBlock,
2344
+ // Loop contract 0.4 (Batch F) — cron/interval wake trigger for the queue
2345
+ // worker daemon.
2346
+ schedule: scheduleBlock,
1423
2347
  chains: chainsBlock,
1424
2348
  wallets: walletsBlock,
1425
2349
  contracts: contractsBlock,
@@ -1477,6 +2401,22 @@ const browserDriverSchema = z
1477
2401
  .strict()
1478
2402
  .default({ width: 1280, height: 720 }),
1479
2403
  startUrl: z.string().url().optional(),
2404
+ /**
2405
+ * SECURITY — opt in to private/loopback navigation targets. Default false:
2406
+ * the Navigate tool refuses private/loopback/link-local/metadata hosts
2407
+ * before `driver.goto`, and the chromium backend routes every request
2408
+ * through a DNS-pinning proxy that refuses the same floor at the
2409
+ * connection layer. Together they stop a prompt-injected page from
2410
+ * reaching the host's own services.
2411
+ *
2412
+ * Set true ONLY when the browser legitimately must reach a private target
2413
+ * the operator controls AND the page content is trusted — an intranet app
2414
+ * under test, or a locally-served fixture page (what the browser runtime
2415
+ * smoke does). It relaxes BOTH layers for this spec, so it stays a
2416
+ * per-spec reviewed decision and never a global switch. The http/https
2417
+ * scheme allowlist is NOT waived.
2418
+ */
2419
+ allowPrivateTargets: z.boolean().default(false),
1480
2420
  })
1481
2421
  .strict();
1482
2422
  const browserSchema = z
@@ -1484,7 +2424,7 @@ const browserSchema = z
1484
2424
  name: safeName,
1485
2425
  version: versionField,
1486
2426
  target: z.literal("browser"),
1487
- agent: pooledSingleAgentSchema,
2427
+ agent: pooledSingleAgentWithMaxTokensSchema,
1488
2428
  driver: browserDriverSchema.default({}),
1489
2429
  /** Vision-grounding model. Defaults to the agent's primary model. */
1490
2430
  groundingModel: z.string().min(1).optional(),
@@ -1494,6 +2434,9 @@ const browserSchema = z
1494
2434
  permissions: permissionsBlock,
1495
2435
  compaction: compactionBlock,
1496
2436
  failure_taxonomy: failureTaxonomyBlock,
2437
+ budget: budgetBlock,
2438
+ limits: limitsBlock,
2439
+ hooks: hooksBlock,
1497
2440
  // v0.3.0 — carried but not emit-wired in 0.3.0 (ignored-note comment in
1498
2441
  // the generated bundle; NOT default-on here).
1499
2442
  continuity: continuityBlock,
@@ -1660,70 +2603,274 @@ export const Spec = z.discriminatedUnion("target", [
1660
2603
  onchainGameSchema,
1661
2604
  ]);
1662
2605
  export { SpecParseError };
1663
- export function parseSpec(yamlText) {
1664
- let raw;
1665
- try {
1666
- raw = parseYaml(yamlText);
1667
- }
1668
- catch (err) {
1669
- throw new SpecParseError("invalid YAML", err);
1670
- }
1671
- // Friendly early-rejection for `permissions.mode: bypass` so the error
1672
- // message names the actual security policy rather than a Zod enum mismatch.
1673
- // The Zod schema also excludes "bypass" from its enum (defense in depth).
2606
+ /**
2607
+ * Friendly early-rejection for `permissions.mode: bypass` so the error
2608
+ * names the actual security policy rather than a Zod enum mismatch. The
2609
+ * Zod schema also excludes "bypass" from its enum (defense in depth).
2610
+ */
2611
+ function bypassModeIssue(raw) {
1674
2612
  if (typeof raw === "object" && raw !== null && "permissions" in raw) {
1675
2613
  const perms = raw.permissions;
1676
2614
  if (typeof perms === "object" && perms !== null && "mode" in perms) {
1677
2615
  const mode = perms.mode;
1678
2616
  if (mode === "bypass") {
1679
- throw new SpecParseError("permissions.mode: bypass is rejected — bypass mode is only available via the --permission-mode CLI flag, never from a spec file");
2617
+ return {
2618
+ path: ["permissions", "mode"],
2619
+ code: "custom",
2620
+ message: "permissions.mode: bypass is rejected — bypass mode is only available via the --permission-mode CLI flag, never from a spec file",
2621
+ };
1680
2622
  }
1681
2623
  }
1682
2624
  }
1683
- const result = Spec.safeParse(raw);
1684
- if (!result.success) {
1685
- throw new SpecParseError(`spec validation failed:\n${result.error.issues
1686
- .map((i) => ` ${i.path.join(".") || "<root>"}: ${i.message}`)
1687
- .join("\n")}`, result.error);
1688
- }
1689
- // Section 22 crew cross-field invariants. Kept here rather than as
1690
- // `.refine()`s on the schema so the discriminated-union member stays
1691
- // a plain ZodObject (Zod's discriminatedUnion rejects ZodEffects).
1692
- const data = result.data;
2625
+ return undefined;
2626
+ }
2627
+ /**
2628
+ * The cross-field invariants `parseSpec` enforces beyond the zod schema,
2629
+ * as a structured issue list (empty = all invariants hold). Kept as data
2630
+ * checks rather than `.refine()`s so every discriminated-union member
2631
+ * stays a plain ZodObject (Zod's discriminatedUnion rejects ZodEffects).
2632
+ * `parseSpec` throws the FIRST issue's message (its historical behaviour);
2633
+ * `parseSpecIssues` returns them all. Check order is load-bearing for
2634
+ * `parseSpec`'s error messages — append, don't reorder.
2635
+ */
2636
+ function crossFieldIssues(data) {
2637
+ const issues = [];
2638
+ const custom = (path, message) => {
2639
+ issues.push({ path, message, code: "custom" });
2640
+ };
2641
+ // Section 22 — crew cross-field invariants.
1693
2642
  if (data.target === "crew") {
1694
2643
  const roleNames = Object.keys(data.roles);
1695
2644
  if (roleNames.length === 0) {
1696
- throw new SpecParseError("crew target requires at least one role");
2645
+ custom(["roles"], "crew target requires at least one role");
1697
2646
  }
1698
- if (!roleNames.includes(data.entry)) {
1699
- throw new SpecParseError(`crew.entry "${data.entry}" must name one of crew.roles (got: ${roleNames.join(", ")})`);
2647
+ if (roleNames.length > 0 && !roleNames.includes(data.entry)) {
2648
+ custom(["entry"], `crew.entry "${data.entry}" must name one of crew.roles (got: ${roleNames.join(", ")})`);
1700
2649
  }
1701
2650
  if (data.routing !== undefined && data.routing.kind === "match" && data.routing.match) {
1702
2651
  for (const [from, rules] of Object.entries(data.routing.match)) {
1703
2652
  if (!roleNames.includes(from)) {
1704
- throw new SpecParseError(`crew.routing.match["${from}"]: source role not in crew.roles`);
2653
+ custom(["routing", "match", from], `crew.routing.match["${from}"]: source role not in crew.roles`);
1705
2654
  }
1706
- for (const rule of rules) {
2655
+ for (const [ri, rule] of rules.entries()) {
1707
2656
  if (!roleNames.includes(rule.to)) {
1708
- throw new SpecParseError(`crew.routing.match["${from}"].to = "${rule.to}" — target role not in crew.roles`);
2657
+ custom(["routing", "match", from, ri, "to"], `crew.routing.match["${from}"].to = "${rule.to}" — target role not in crew.roles`);
1709
2658
  }
1710
2659
  }
1711
2660
  }
1712
2661
  }
1713
2662
  }
2663
+ // Loop contract 0.4 (Batch B, G02) — a judge step gates the PREVIOUS
2664
+ // step's output, so the first step can never be one.
2665
+ if (data.target === "workflow") {
2666
+ const first = data.steps[0];
2667
+ if (first !== undefined && "kind" in first && first.kind === "judge") {
2668
+ custom(["steps", 0], `workflow steps[0] "${first.name}" cannot be a judge step — a judge gates the previous step's output and no step precedes it`);
2669
+ }
2670
+ }
2671
+ // Loop contract 0.4 (Batch A) — graph cross-field invariants:
2672
+ // - every `edges[].when.key` must name a declared node — the generated
2673
+ // graph state records each node's reply under its own name, so a key
2674
+ // that names nothing can never match;
2675
+ // - every `parallel` group member must name a declared node (mirrors
2676
+ // graph-engine's own compile-time check, surfaced at parse time);
2677
+ // - (Batch B) the entry cannot be a judge node — a judge gates its
2678
+ // upstream node's output and the entry has none.
2679
+ if (data.target === "graph") {
2680
+ const nodeNames = Object.keys(data.nodes);
2681
+ for (const [i, edge] of data.edges.entries()) {
2682
+ if (edge.when !== undefined && !nodeNames.includes(edge.when.key)) {
2683
+ custom(["edges", i, "when", "key"], `graph.edges[${i}].when.key "${edge.when.key}" must name a declared node — the shared state records each node's output under its name (nodes: ${nodeNames.join(", ")})`);
2684
+ }
2685
+ }
2686
+ if (data.parallel !== undefined) {
2687
+ for (const [gi, group] of data.parallel.entries()) {
2688
+ for (const nodeName of group) {
2689
+ if (!nodeNames.includes(nodeName)) {
2690
+ custom(["parallel", gi], `graph.parallel[${gi}] references "${nodeName}" which is not a declared node (nodes: ${nodeNames.join(", ")})`);
2691
+ }
2692
+ }
2693
+ }
2694
+ }
2695
+ const entryNode = data.nodes[data.entry];
2696
+ if (entryNode !== undefined && "kind" in entryNode && entryNode.kind === "judge") {
2697
+ custom(["entry"], `graph entry "${data.entry}" cannot be a judge node — a judge gates its upstream node's output and the entry has none`);
2698
+ }
2699
+ }
2700
+ // Item 1 (G30) — `expose.mcp.tools: "per-subagent"` projects EACH declared
2701
+ // sub-agent as its own MCP tool, so it needs sub-agents to project. cli and
2702
+ // channel carry `agent.sub_agents`; the managed shape has none at all, so
2703
+ // per-subagent is always a mistake there. Load-bearing: no ir-pass mirrors
2704
+ // this, and the emitter would otherwise ship an MCP server exposing only the
2705
+ // primary tool while the author expected per-sub-agent ones.
2706
+ if (data.target === "cli" || data.target === "channel" || data.target === "managed") {
2707
+ const exposeTools = data.expose?.mcp?.tools;
2708
+ if (exposeTools === "per-subagent") {
2709
+ const subAgents = data.target === "managed"
2710
+ ? undefined
2711
+ : data.agent.sub_agents;
2712
+ const count = subAgents === undefined ? 0 : Object.keys(subAgents).length;
2713
+ if (count === 0) {
2714
+ custom(["expose", "mcp", "tools"], `expose.mcp.tools: "per-subagent" projects each sub-agent as its own MCP tool, but the ${data.target} shape declares no sub_agents — use tools: "chat" (the default), or add sub_agents`);
2715
+ }
2716
+ }
2717
+ }
1714
2718
  // Section 21 — pipeline HTTP-backend invariants. qdrant/pinecone/weaviate
1715
2719
  // throw at construction without a url + collection, so selecting one
1716
- // without both would emit an unrunnable bundle. Reject at parse time with
1717
- // a message naming the missing field (kept here, not as a `.refine()`, so
1718
- // the discriminated-union member stays a plain ZodObject).
2720
+ // without both would emit an unrunnable bundle.
1719
2721
  if (data.target === "pipeline" && HTTP_VECTOR_BACKENDS.has(data.retrieve.vectorBackend)) {
1720
2722
  const { vectorBackend, url, collection } = data.retrieve;
1721
2723
  if (!url) {
1722
- throw new SpecParseError(`pipeline retrieve.vectorBackend "${vectorBackend}" requires retrieve.url (the remote service base URL)`);
2724
+ custom(["retrieve", "url"], `pipeline retrieve.vectorBackend "${vectorBackend}" requires retrieve.url (the remote service base URL)`);
1723
2725
  }
1724
2726
  if (!collection) {
1725
- throw new SpecParseError(`pipeline retrieve.vectorBackend "${vectorBackend}" requires retrieve.collection`);
2727
+ custom(["retrieve", "collection"], `pipeline retrieve.vectorBackend "${vectorBackend}" requires retrieve.collection`);
1726
2728
  }
1727
2729
  }
1728
- return data;
2730
+ // "Watch me" (design/watch-me.md §4.2) — `watchme.share: true` publishes
2731
+ // co-learning articles, so it conflicts with a thredz OBJECT that declares
2732
+ // an EXPLICIT `visibility: "private"`. The boolean/string shorthands
2733
+ // (default-private) get NO issue in v1 — publishing then lands
2734
+ // private-visibility articles, legal single-agent behaviour — and an
2735
+ // absent `thredz:` block is fine (publish degrades to the local wiki
2736
+ // store, a feature not an error).
2737
+ if (data.target === "cli" || data.target === "channel" || data.target === "managed") {
2738
+ const thredz = data.thredz;
2739
+ if (data.watchme?.share === true &&
2740
+ typeof thredz === "object" &&
2741
+ thredz.visibility === "private") {
2742
+ custom(["watchme", "share"], "watchme.share publishes co-learning articles; thredz.visibility: private blocks cross-agent sharing — set visibility: shared or drop watchme.share");
2743
+ }
2744
+ }
2745
+ return issues;
2746
+ }
2747
+ export function parseSpec(yamlText) {
2748
+ let raw;
2749
+ try {
2750
+ raw = parseYaml(yamlText);
2751
+ }
2752
+ catch (err) {
2753
+ throw new SpecParseError("invalid YAML", err);
2754
+ }
2755
+ const bypass = bypassModeIssue(raw);
2756
+ if (bypass !== undefined) {
2757
+ throw new SpecParseError(bypass.message);
2758
+ }
2759
+ const result = Spec.safeParse(raw);
2760
+ if (!result.success) {
2761
+ throw new SpecParseError(`spec validation failed:\n${result.error.issues
2762
+ .map((i) => ` ${i.path.join(".") || "<root>"}: ${i.message}`)
2763
+ .join("\n")}`, result.error);
2764
+ }
2765
+ const issues = crossFieldIssues(result.data);
2766
+ const firstIssue = issues[0];
2767
+ if (firstIssue !== undefined) {
2768
+ throw new SpecParseError(firstIssue.message);
2769
+ }
2770
+ return result.data;
2771
+ }
2772
+ /** yaml's parse errors carry 1-indexed line/column in `linePos`. */
2773
+ function yamlSyntaxIssue(err) {
2774
+ const linePos = err.linePos;
2775
+ const pos = Array.isArray(linePos) && linePos[0] !== undefined ? linePos[0] : undefined;
2776
+ const raw = err instanceof Error ? err.message : String(err);
2777
+ // yaml's message embeds a multi-line code frame; keep the first line and
2778
+ // move its trailing position marker into a uniform suffix.
2779
+ const firstLine = (raw.split("\n")[0] ?? raw).trim();
2780
+ const cleaned = pos !== undefined ? firstLine.replace(/ at line \d+, column \d+:?$/, "") : firstLine;
2781
+ const where = pos !== undefined ? ` (line ${pos.line}, column ${pos.col})` : "";
2782
+ return { path: [], code: "yaml_syntax", message: `invalid YAML: ${cleaned}${where}` };
2783
+ }
2784
+ /**
2785
+ * Flatten zod issues to `SpecIssue`s. `invalid_union` issues (the workflow
2786
+ * step / graph node / continuity / thredz unions) are replaced by the most
2787
+ * plausible branch's issues — the branch that failed with the FEWEST
2788
+ * problems, ties broken by the fewest unrecognized KEYS (so a step that
2789
+ * declares `kind: judge` with a typo inside `judge:` reports the judge
2790
+ * branch's problem, not the regular branch's "unrecognized 'kind'") — so a
2791
+ * malformed union member reports its actual problem instead of an opaque
2792
+ * "Invalid input". Union sub-issues carry document-absolute paths in zod
2793
+ * v3, so no re-prefixing is needed.
2794
+ */
2795
+ function unrecognizedKeyCount(err) {
2796
+ let count = 0;
2797
+ for (const issue of err.issues) {
2798
+ if (issue.code === z.ZodIssueCode.unrecognized_keys)
2799
+ count += issue.keys.length;
2800
+ }
2801
+ return count;
2802
+ }
2803
+ function zodIssuesToSpecIssues(zodIssues) {
2804
+ const out = [];
2805
+ for (const issue of zodIssues) {
2806
+ if (issue.code === z.ZodIssueCode.invalid_union && issue.unionErrors.length > 0) {
2807
+ const best = [...issue.unionErrors].sort((a, b) => a.issues.length - b.issues.length || unrecognizedKeyCount(a) - unrecognizedKeyCount(b))[0];
2808
+ if (best !== undefined && best.issues.length > 0) {
2809
+ out.push(...zodIssuesToSpecIssues(best.issues));
2810
+ continue;
2811
+ }
2812
+ }
2813
+ out.push({ path: [...issue.path], message: issue.message, code: issue.code });
2814
+ }
2815
+ return out;
2816
+ }
2817
+ /**
2818
+ * Loop contract 0.4 (Batch B, G04) — the non-throwing sibling of
2819
+ * {@link parseSpec}: parse `yamlText` and return EVERY diagnostic as a
2820
+ * structured issue list (`[]` when the spec is valid). Built on the same
2821
+ * internals as `parseSpec` — which keeps its throw behaviour — so the two
2822
+ * can never disagree about validity:
2823
+ *
2824
+ * - YAML syntax errors → one issue, `path: []`, `code: "yaml_syntax"`,
2825
+ * line/column in the message.
2826
+ * - schema failures → one issue per zod issue (zod's own `code`s),
2827
+ * with `invalid_union` flattened to the most plausible branch.
2828
+ * - cross-field checks → `code: "custom"` with a best-effort path.
2829
+ */
2830
+ export function parseSpecIssues(yamlText) {
2831
+ let raw;
2832
+ try {
2833
+ raw = parseYaml(yamlText);
2834
+ }
2835
+ catch (err) {
2836
+ return [yamlSyntaxIssue(err)];
2837
+ }
2838
+ const bypass = bypassModeIssue(raw);
2839
+ if (bypass !== undefined)
2840
+ return [bypass];
2841
+ const result = Spec.safeParse(raw);
2842
+ if (!result.success)
2843
+ return zodIssuesToSpecIssues(result.error.issues);
2844
+ return crossFieldIssues(result.data);
2845
+ }
2846
+ /**
2847
+ * Loop contract 0.4 (Batch B, G03) — the whole Spec union as a JSON-Schema
2848
+ * document. The document root is a `$ref` to `#/definitions/CrewhausSpec`
2849
+ * (the target-discriminated union); every target shape additionally gets
2850
+ * its own named definition (`#/definitions/cli`, `#/definitions/workflow`,
2851
+ * …) so tooling (editors, the compiler-worker `GET /schema` endpoint, the
2852
+ * studio) can link straight to one shape. Zod `.describe()` annotations
2853
+ * surface as JSON-Schema `description` keys. Pure function of this module
2854
+ * — no I/O, deterministic output.
2855
+ */
2856
+ export function specJsonSchema() {
2857
+ return zodToJsonSchema(Spec, {
2858
+ name: "CrewhausSpec",
2859
+ definitions: {
2860
+ cli: cliSchema,
2861
+ workflow: workflowSchema,
2862
+ channel: channelSchema,
2863
+ graph: graphSchema,
2864
+ managed: managedSchema,
2865
+ pipeline: pipelineSchema,
2866
+ crew: crewSchema,
2867
+ research: researchSchema,
2868
+ batch: batchSchema,
2869
+ voice: voiceSchema,
2870
+ browser: browserSchema,
2871
+ eval: evalSchema,
2872
+ onchain: onchainSchema,
2873
+ "onchain-game": onchainGameSchema,
2874
+ },
2875
+ });
1729
2876
  }