@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.
- package/dist/index.d.ts +22836 -8828
- package/dist/index.js +1221 -74
- 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
|
-
|
|
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
|
-
*
|
|
892
|
-
*
|
|
893
|
-
*
|
|
894
|
-
*
|
|
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
|
|
908
|
-
*
|
|
909
|
-
* (
|
|
910
|
-
*
|
|
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
|
|
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(
|
|
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
|
-
*
|
|
1142
|
-
*
|
|
1143
|
-
*
|
|
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,
|
|
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
|
|
1244
|
-
*
|
|
1245
|
-
* `
|
|
1246
|
-
*
|
|
1247
|
-
*
|
|
1248
|
-
*
|
|
1249
|
-
*
|
|
1250
|
-
*
|
|
1251
|
-
*
|
|
1252
|
-
*
|
|
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
|
|
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:
|
|
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:
|
|
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:
|
|
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
|
-
|
|
1664
|
-
|
|
1665
|
-
|
|
1666
|
-
|
|
1667
|
-
|
|
1668
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1684
|
-
|
|
1685
|
-
|
|
1686
|
-
|
|
1687
|
-
|
|
1688
|
-
|
|
1689
|
-
|
|
1690
|
-
|
|
1691
|
-
|
|
1692
|
-
|
|
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
|
-
|
|
2645
|
+
custom(["roles"], "crew target requires at least one role");
|
|
1697
2646
|
}
|
|
1698
|
-
if (!roleNames.includes(data.entry)) {
|
|
1699
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
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
|
-
|
|
2724
|
+
custom(["retrieve", "url"], `pipeline retrieve.vectorBackend "${vectorBackend}" requires retrieve.url (the remote service base URL)`);
|
|
1723
2725
|
}
|
|
1724
2726
|
if (!collection) {
|
|
1725
|
-
|
|
2727
|
+
custom(["retrieve", "collection"], `pipeline retrieve.vectorBackend "${vectorBackend}" requires retrieve.collection`);
|
|
1726
2728
|
}
|
|
1727
2729
|
}
|
|
1728
|
-
|
|
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
|
}
|