@crewhaus/ir-passes 0.5.7 → 0.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.d.ts CHANGED
@@ -26,6 +26,12 @@
26
26
  * - `memoryIntegrityPass` — v0.3.0 validating pass: memory/continuity
27
27
  * block integrity (wiki.recallK bounds, ttl floor, session-scope only
28
28
  * on session-routed shapes, fragment JSON-serializability).
29
+ * - `modelPlanIntegrity` — 0.6.0 validating pass: the `models:` registry
30
+ * and every `model_pool` (candidate `tools ⊆ block tools`, restricted
31
+ * profile permissions, `enabled` leaves a routable candidate, strategy /
32
+ * rule / floor role slots name declared tags or arms, classifier ⇔
33
+ * policy, pool blob JSON round-trip). Returns the IR untouched when no
34
+ * model keys are present.
29
35
  * - `promptCachePrefixSort` — TODO: re-orders system-block segments so
30
36
  * the cache prefix is maximised. v0 stub returns IR unchanged so the
31
37
  * pipeline contract holds; v1 follow-up wires this once we land
@@ -34,11 +40,12 @@
34
40
  * Pipeline order in `applyPasses` (the safe default):
35
41
  * deadToolElimination → redundantMcpServerCollapse →
36
42
  * permissionRuleCanonicalize → transactionPolicyEnforcement →
37
- * wellFormednessCheck → memoryIntegrityPass → promptCachePrefixSort
43
+ * wellFormednessCheck → memoryIntegrityPass → modelPlanIntegrity →
44
+ * promptCachePrefixSort
38
45
  *
39
46
  * G45 (loop contract 0.4) — the passes split into two families:
40
47
  * - VALIDATING (transactionPolicyEnforcement, wellFormednessCheck,
41
- * memoryIntegrityPass): pure pass-throughs that throw `IrPassError` on
48
+ * memoryIntegrityPass, modelPlanIntegrity): pure pass-throughs that throw `IrPassError` on
42
49
  * a violation. Exported as `VALIDATING_PASSES`; the compiler runs them
43
50
  * UNCONDITIONALLY inside `compile()` (they cannot drift bundle bytes).
44
51
  * - REWRITING (deadToolElimination, redundantMcpServerCollapse,
@@ -112,6 +119,7 @@ export declare function transactionPolicyEnforcement(ir: IrNode): IrNode;
112
119
  */
113
120
  export declare function wellFormednessCheck(ir: IrNode): IrNode;
114
121
  export declare function memoryIntegrityPass(ir: IrNode): IrNode;
122
+ export declare function modelPlanIntegrity(ir: IrNode): IrNode;
115
123
  /**
116
124
  * G45 (loop contract 0.4) — the VALIDATING passes: pure pass-throughs that
117
125
  * throw `IrPassError` on a violation and never rewrite the IR. The compiler
package/dist/index.js CHANGED
@@ -26,6 +26,12 @@
26
26
  * - `memoryIntegrityPass` — v0.3.0 validating pass: memory/continuity
27
27
  * block integrity (wiki.recallK bounds, ttl floor, session-scope only
28
28
  * on session-routed shapes, fragment JSON-serializability).
29
+ * - `modelPlanIntegrity` — 0.6.0 validating pass: the `models:` registry
30
+ * and every `model_pool` (candidate `tools ⊆ block tools`, restricted
31
+ * profile permissions, `enabled` leaves a routable candidate, strategy /
32
+ * rule / floor role slots name declared tags or arms, classifier ⇔
33
+ * policy, pool blob JSON round-trip). Returns the IR untouched when no
34
+ * model keys are present.
29
35
  * - `promptCachePrefixSort` — TODO: re-orders system-block segments so
30
36
  * the cache prefix is maximised. v0 stub returns IR unchanged so the
31
37
  * pipeline contract holds; v1 follow-up wires this once we land
@@ -34,11 +40,12 @@
34
40
  * Pipeline order in `applyPasses` (the safe default):
35
41
  * deadToolElimination → redundantMcpServerCollapse →
36
42
  * permissionRuleCanonicalize → transactionPolicyEnforcement →
37
- * wellFormednessCheck → memoryIntegrityPass → promptCachePrefixSort
43
+ * wellFormednessCheck → memoryIntegrityPass → modelPlanIntegrity →
44
+ * promptCachePrefixSort
38
45
  *
39
46
  * G45 (loop contract 0.4) — the passes split into two families:
40
47
  * - VALIDATING (transactionPolicyEnforcement, wellFormednessCheck,
41
- * memoryIntegrityPass): pure pass-throughs that throw `IrPassError` on
48
+ * memoryIntegrityPass, modelPlanIntegrity): pure pass-throughs that throw `IrPassError` on
42
49
  * a violation. Exported as `VALIDATING_PASSES`; the compiler runs them
43
50
  * UNCONDITIONALLY inside `compile()` (they cannot drift bundle bytes).
44
51
  * - REWRITING (deadToolElimination, redundantMcpServerCollapse,
@@ -91,6 +98,17 @@ export function deadToolElimination(ir) {
91
98
  for (const sa of subAgentRefs) {
92
99
  for (const t of sa.tools)
93
100
  used.add(t);
101
+ // 0.6.0 §4.3 — a sub-agent's own pool candidates may subset its tools.
102
+ for (const c of sa.modelPool?.candidates ?? [])
103
+ for (const t of c.tools ?? [])
104
+ used.add(t);
105
+ }
106
+ // 0.6.0 §4.3 — a pool candidate's `tools` subset is a reference too:
107
+ // dropping a tool only a candidate names would silently shrink that
108
+ // candidate's advertised toolset.
109
+ for (const c of cli.agent.modelPool?.candidates ?? []) {
110
+ for (const t of c.tools ?? [])
111
+ used.add(t);
94
112
  }
95
113
  // Always-allow defaults: if any rule references a tool by exact name we
96
114
  // count it; otherwise the original tool list serves as the
@@ -531,6 +549,252 @@ export function memoryIntegrityPass(ir) {
531
549
  }
532
550
  return ir;
533
551
  }
552
+ /**
553
+ * 0.6.0 §4.3 — model-plan integrity check. VALIDATION-ONLY (no structural
554
+ * rewrite), mirroring `memoryIntegrityPass`'s posture, appended LAST in
555
+ * `VALIDATING_PASSES` so it runs unconditionally through `compile()` and in
556
+ * `crewhaus lint` via `DEFAULT_PIPELINE`. It returns the IR untouched when
557
+ * no model keys are present, so it cannot drift bytes. For every routed
558
+ * block that carries a `modelPool` (the agent block, workflow steps, graph
559
+ * nodes, crew roles, and the sub-agents hanging off any of them) and for
560
+ * the `models:` registry:
561
+ *
562
+ * - every candidate `tools` entry is SUBSET-ONLY: a builtin key must be
563
+ * one of the block's own `tools` (case-insensitive — tool registration
564
+ * lowercases), an `mcp__<server>__<tool|*>` selector must name a server
565
+ * the shape's `mcp_servers` declares, and `Consult` / `Escalate` exist
566
+ * only when some pool declares `strategy.modelDirected`; a block that
567
+ * carries no tool catalog admits no candidate `tools` at all;
568
+ * - candidate / profile `permissions` carry ONLY `deny` / `ask` (the §5.4
569
+ * restricted schema — a profile can narrow the shape's decisions, never
570
+ * widen them; the runtime's `narrowRuleSet` implements the decision-level
571
+ * meet, this pass pins the shape it is fed);
572
+ * - `enabled: false` leaves at least one routable candidate;
573
+ * - every strategy / rule / floor ROLE slot (`cascade.draft`,
574
+ * `cascade.escalateTo`, `committee.members[]`,
575
+ * `committee.escalateOnDisagreement`, `rules[].use`, `reward.floor.arm`)
576
+ * names a declared candidate tag or a candidate's arm id (its profile
577
+ * name, else its model string); `classifier.labels` keys are candidate
578
+ * tags; `policy: "classifier"` and a `classifier` block go together;
579
+ * - profile names match the registry grammar and every profile / pool
580
+ * blob survives a JSON round-trip UNCHANGED (emitters `JSON.stringify`
581
+ * the pool into bundles — the `memoryIntegrityPass` template).
582
+ *
583
+ * The "judge ≠ serving arm" check is deliberately NOT here (it is a
584
+ * `compile()` warning scoped to specs that opt into `models:` or a pool
585
+ * `strategy`, silenced by `allow_self_judge`), so a 0.5.8-valid
586
+ * self-judging pooled spec still compiles.
587
+ */
588
+ const MODEL_PLAN_PROFILE_NAME_RE = /^[a-z][a-z0-9_-]{0,63}$/;
589
+ const MODEL_DIRECTED_TOOLS = new Set(["Consult", "Escalate"]);
590
+ const MCP_TOOL_SELECTOR_RE = /^mcp__([^_].*?)__(.+)$/;
591
+ function modelPlanBlocks(ir) {
592
+ const out = [];
593
+ const withSubAgents = (path, block) => {
594
+ out.push([path, block]);
595
+ for (const sa of block.subAgents ?? []) {
596
+ out.push([
597
+ `${path}.sub_agents.${sa.name}`,
598
+ { tools: sa.tools, toolCatalog: true, modelPool: sa.modelPool },
599
+ ]);
600
+ }
601
+ };
602
+ switch (ir.target) {
603
+ case "cli":
604
+ withSubAgents("agent", {
605
+ tools: ir.tools,
606
+ toolCatalog: true,
607
+ modelPool: ir.agent.modelPool,
608
+ subAgents: ir.subAgents,
609
+ });
610
+ break;
611
+ case "channel":
612
+ withSubAgents("agent", {
613
+ tools: ir.tools,
614
+ toolCatalog: true,
615
+ modelPool: ir.agent.modelPool,
616
+ subAgents: ir.subAgents,
617
+ });
618
+ break;
619
+ case "managed":
620
+ withSubAgents("agent", {
621
+ tools: ir.tools ?? [],
622
+ toolCatalog: true,
623
+ modelPool: ir.agent.modelPool,
624
+ });
625
+ break;
626
+ case "pipeline":
627
+ withSubAgents("agent", {
628
+ tools: undefined,
629
+ toolCatalog: false,
630
+ modelPool: ir.agent.modelPool,
631
+ });
632
+ break;
633
+ case "research":
634
+ case "batch":
635
+ case "browser":
636
+ withSubAgents("agent", { tools: ir.tools, toolCatalog: true, modelPool: ir.agent.modelPool });
637
+ break;
638
+ case "workflow":
639
+ ir.steps.forEach((s, i) => withSubAgents(`steps[${i}]`, { tools: s.tools, toolCatalog: true, modelPool: s.modelPool }));
640
+ break;
641
+ case "graph":
642
+ for (const n of ir.nodes) {
643
+ withSubAgents(`nodes.${n.name}`, {
644
+ tools: n.tools,
645
+ toolCatalog: true,
646
+ modelPool: n.modelPool,
647
+ });
648
+ }
649
+ break;
650
+ case "crew":
651
+ for (const r of ir.roles) {
652
+ withSubAgents(`roles.${r.name}`, {
653
+ tools: r.tools,
654
+ toolCatalog: true,
655
+ modelPool: r.modelPool,
656
+ subAgents: r.subAgents,
657
+ });
658
+ }
659
+ break;
660
+ default:
661
+ break;
662
+ }
663
+ return out;
664
+ }
665
+ /** A candidate's arm identity: its profile name, else its model string (§7.9). */
666
+ function armIdOf(c) {
667
+ return c.profile ?? c.model;
668
+ }
669
+ function checkProfilePermissions(path, permissions) {
670
+ if (permissions === undefined)
671
+ return;
672
+ for (const key of Object.keys(permissions)) {
673
+ if (key !== "deny" && key !== "ask") {
674
+ throw new IrPassError(`${path}.permissions.${key}: a model profile's permissions may only NARROW the shape's — deny and ask lists only (a profile can never widen what the shape allows)`);
675
+ }
676
+ }
677
+ }
678
+ function checkCandidateTools(path, tools, block, mcpServers, modelDirected) {
679
+ if (tools === undefined)
680
+ return;
681
+ if (!block.toolCatalog) {
682
+ throw new IrPassError(`${path}.tools: this block registers no tool catalog, so a per-model tools list has nothing to narrow`);
683
+ }
684
+ const blockTools = new Set((block.tools ?? []).map((t) => t.toLowerCase()));
685
+ for (const [i, tool] of tools.entries()) {
686
+ if (tool.startsWith("mcp__")) {
687
+ const server = tool.match(MCP_TOOL_SELECTOR_RE)?.[1];
688
+ if (server === undefined) {
689
+ throw new IrPassError(`${path}.tools[${i}]: "${tool}" is not a valid MCP tool selector — use mcp__<server>__<tool> or mcp__<server>__*`);
690
+ }
691
+ if (!mcpServers.has(server)) {
692
+ throw new IrPassError(`${path}.tools[${i}]: "${tool}" names MCP server "${server}", which mcp_servers does not declare`);
693
+ }
694
+ continue;
695
+ }
696
+ if (MODEL_DIRECTED_TOOLS.has(tool)) {
697
+ if (!modelDirected) {
698
+ throw new IrPassError(`${path}.tools[${i}]: "${tool}" is registered only when a model_pool declares strategy.model_directed: true — no pool on this IR does`);
699
+ }
700
+ continue;
701
+ }
702
+ if (!blockTools.has(tool.toLowerCase())) {
703
+ throw new IrPassError(`${path}.tools[${i}]: "${tool}" is not one of the block's tools (${(block.tools ?? []).join(", ") || "none"}) — a per-model tools list can only narrow the block's toolset, never add to it`);
704
+ }
705
+ }
706
+ }
707
+ function checkRoleSlot(path, value, tags, arms) {
708
+ if (tags.has(value) || arms.has(value))
709
+ return;
710
+ throw new IrPassError(`${path}: "${value}" is neither a candidate tag (${[...tags].join(", ") || "none"}) nor a candidate arm id (${[...arms].join(", ")}) of this model_pool`);
711
+ }
712
+ function checkModelPool(path, pool, block, mcpServers, modelDirected) {
713
+ const poolPath = `${path}.model_pool`;
714
+ if (!pool.candidates.some((c) => c.enabled !== false)) {
715
+ throw new IrPassError(`${poolPath}.candidates: every candidate is enabled: false — at least one must stay routable`);
716
+ }
717
+ const tags = new Set();
718
+ const arms = new Set();
719
+ pool.candidates.forEach((c, i) => {
720
+ const cpath = `${poolPath}.candidates[${i}]`;
721
+ for (const t of c.tags)
722
+ tags.add(t);
723
+ arms.add(armIdOf(c));
724
+ checkCandidateTools(cpath, c.tools, block, mcpServers, modelDirected);
725
+ checkProfilePermissions(cpath, c.permissions);
726
+ if (c.model.startsWith("$")) {
727
+ throw new IrPassError(`${cpath}.model: "${c.model}" is an unresolved profile reference — the compiler resolves every $ref at lower time; a direct-IR builder must carry the concrete model`);
728
+ }
729
+ });
730
+ if (pool.policy === "classifier" && pool.classifier === undefined) {
731
+ throw new IrPassError(`${poolPath}.policy: "classifier" requires a classifier block (model + labels) on the same model_pool`);
732
+ }
733
+ if (pool.classifier !== undefined) {
734
+ if (pool.policy !== "classifier") {
735
+ throw new IrPassError(`${poolPath}.classifier is declared but policy is "${pool.policy}" — the classifier runs only under policy: "classifier"`);
736
+ }
737
+ for (const label of Object.keys(pool.classifier.labels)) {
738
+ if (!tags.has(label)) {
739
+ throw new IrPassError(`${poolPath}.classifier.labels["${label}"]: every label must be a candidate tag (${[...tags].join(", ") || "none declared"})`);
740
+ }
741
+ }
742
+ }
743
+ for (const [i, rule] of (pool.rules ?? []).entries()) {
744
+ if (typeof rule.use === "string") {
745
+ checkRoleSlot(`${poolPath}.rules[${i}].use`, rule.use, tags, arms);
746
+ }
747
+ }
748
+ const st = pool.strategy;
749
+ if (st !== undefined) {
750
+ const sp = `${poolPath}.strategy`;
751
+ if (st.cascade !== undefined) {
752
+ checkRoleSlot(`${sp}.cascade.draft`, st.cascade.draft, tags, arms);
753
+ checkRoleSlot(`${sp}.cascade.escalateTo`, st.cascade.escalateTo, tags, arms);
754
+ }
755
+ if (st.committee !== undefined) {
756
+ st.committee.members.forEach((m, i) => checkRoleSlot(`${sp}.committee.members[${i}]`, m, tags, arms));
757
+ if (st.committee.escalateOnDisagreement !== undefined) {
758
+ checkRoleSlot(`${sp}.committee.escalateOnDisagreement`, st.committee.escalateOnDisagreement, tags, arms);
759
+ }
760
+ }
761
+ }
762
+ if (pool.reward?.floor?.arm !== undefined) {
763
+ checkRoleSlot(`${poolPath}.reward.floor.arm`, pool.reward.floor.arm, tags, arms);
764
+ }
765
+ if (!jsonRoundTrips(pool)) {
766
+ throw new IrPassError(`${poolPath} is not JSON-serializable — emitters stringify the pool into bundles, so it must survive a JSON round-trip unchanged`);
767
+ }
768
+ }
769
+ export function modelPlanIntegrity(ir) {
770
+ const registry = ir.models;
771
+ const blocks = modelPlanBlocks(ir);
772
+ const pooled = blocks.filter(([, b]) => b.modelPool !== undefined);
773
+ if (registry === undefined && pooled.length === 0)
774
+ return ir;
775
+ if (registry !== undefined) {
776
+ for (const [name, profile] of Object.entries(registry)) {
777
+ if (!MODEL_PLAN_PROFILE_NAME_RE.test(name)) {
778
+ throw new IrPassError(`models.${name}: profile names must match /^[a-z][a-z0-9_-]{0,63}$/`);
779
+ }
780
+ checkProfilePermissions(`models.${name}`, profile.permissions);
781
+ if (profile.model.startsWith("$")) {
782
+ throw new IrPassError(`models.${name}.model: "${profile.model}" is an unresolved profile reference — profiles do not inherit from profiles`);
783
+ }
784
+ if (!jsonRoundTrips(profile)) {
785
+ throw new IrPassError(`models.${name} is not JSON-serializable — it must survive a JSON round-trip unchanged`);
786
+ }
787
+ }
788
+ }
789
+ const mcpServers = new Set(Object.keys(ir.mcp_servers ?? {}));
790
+ const modelDirected = pooled.some(([, b]) => b.modelPool?.strategy?.modelDirected === true);
791
+ for (const [path, block] of pooled) {
792
+ if (block.modelPool !== undefined) {
793
+ checkModelPool(path, block.modelPool, block, mcpServers, modelDirected);
794
+ }
795
+ }
796
+ return ir;
797
+ }
534
798
  /**
535
799
  * G45 (loop contract 0.4) — the VALIDATING passes: pure pass-throughs that
536
800
  * throw `IrPassError` on a violation and never rewrite the IR. The compiler
@@ -544,6 +808,7 @@ export const VALIDATING_PASSES = Object.freeze([
544
808
  transactionPolicyEnforcement,
545
809
  wellFormednessCheck,
546
810
  memoryIntegrityPass,
811
+ modelPlanIntegrity,
547
812
  ]);
548
813
  export const DEFAULT_PIPELINE = Object.freeze([
549
814
  deadToolElimination,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@crewhaus/ir-passes",
3
- "version": "0.5.7",
3
+ "version": "0.6.0",
4
4
  "type": "module",
5
5
  "description": "Idempotent IR optimization passes (dead-tool-elimination, prompt-cache-prefix-sort, redundant-mcp-server-collapse, permission-rule-canonicalize)",
6
6
  "main": "dist/index.js",
@@ -15,8 +15,8 @@
15
15
  "test": "bun test src"
16
16
  },
17
17
  "dependencies": {
18
- "@crewhaus/errors": "0.5.7",
19
- "@crewhaus/ir": "0.5.7"
18
+ "@crewhaus/errors": "0.6.0",
19
+ "@crewhaus/ir": "0.6.0"
20
20
  },
21
21
  "license": "Apache-2.0",
22
22
  "author": {