@polderlabs/bizar 10.21.0 → 10.22.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.
@@ -3,7 +3,7 @@
3
3
  *
4
4
  * `bizar model` subcommands:
5
5
  * list — fetch all models from the configured provider gateway and group
6
- * by provider prefix.
6
+ * by provider family.
7
7
  *
8
8
  * The gateway URL is read from `BIZAR_MODEL_ROUTER_URL` or
9
9
  * `ANTHROPIC_BASE_URL`. If neither is set, the command errors out with a
@@ -14,18 +14,24 @@
14
14
  * makes the /model picker surface only IDs prefixed with "claude" or
15
15
  * "anthropic". This command shows the FULL set including cx/, oc/, and
16
16
  * unprefixed IDs.
17
+ *
18
+ * 10.22.0 / Phase 4 spirit-of-constraint fix: this command used to carry a
19
+ * hardcoded `PROVIDER_GROUPS` prefix list that duplicated the canonical
20
+ * provider family detection in `cli/commands/models.mjs#classifyKind`.
21
+ * It now imports `classifyKind` from there (single source of truth) and
22
+ * filters the operator's `disabledProviders` list using the same
23
+ * case-sensitive prefix match as every other read site — adding a new
24
+ * provider family no longer requires touching this file.
17
25
  */
18
26
  import chalk from 'chalk';
19
27
 
28
+ import { classifyKind, filterCandidatesByDisabledProviders, readDisabledProviders } from './models.mjs';
29
+
20
30
  const BIZAR_MODEL_ROUTER_URL = process.env.BIZAR_MODEL_ROUTER_URL
21
31
  || process.env.ANTHROPIC_BASE_URL
22
32
  || null;
23
33
  const ANTHROPIC_AUTH_TOKEN = process.env.ANTHROPIC_AUTH_TOKEN || null;
24
34
 
25
- const PROVIDER_GROUPS = [
26
- 'cx/', 'claude-minimax/', 'claude-qwen/', 'oc/', 'claude/', 'anthropic/',
27
- ];
28
-
29
35
  function showHelp() {
30
36
  const gatewayDisplay = BIZAR_MODEL_ROUTER_URL ?? '(not configured)';
31
37
  console.log(`
@@ -41,13 +47,17 @@ function showHelp() {
41
47
  The /model picker inside Claude Code shows only "claude"/"anthropic" prefixed
42
48
  IDs. This command exposes the full set (cx/, claude-minimax/, claude-qwen/, oc/, etc.).
43
49
 
50
+ The list honours the operator's \`disabledProviders\` list from
51
+ model-router.json (10.22.0 / Phase 4) — same source of truth as
52
+ \`bizar models\`.
53
+
44
54
  Gateway configuration:
45
55
  Set BIZAR_MODEL_ROUTER_URL or ANTHROPIC_BASE_URL to your provider gateway
46
56
  (e.g. https://router.example.com/v1). If neither is set, this command
47
57
  exits with a configuration error.
48
58
 
49
59
  Flags:
50
- --json Emit { providers: { "cx/": [...], ... }, total: N }
60
+ --json Emit { providers: { "<family>": [...], ... }, total: N }
51
61
  `);
52
62
  }
53
63
 
@@ -94,19 +104,28 @@ async function fetchModels() {
94
104
  }
95
105
 
96
106
  function groupByProvider(models) {
107
+ // 10.22.0 / Phase 4: filter the operator's `disabledProviders` first,
108
+ // then group by the canonical `classifyKind` family name. No
109
+ // hardcoded prefix list lives in this file.
110
+ const ids = models.map((m) => (typeof m?.id === 'string' ? m.id : ''));
111
+ const disabled = readDisabledProviders();
112
+ const { kept } = filterCandidatesByDisabledProviders(ids, disabled);
113
+ const keptSet = new Set(kept);
114
+
97
115
  const groups = {};
98
116
  for (const m of models) {
99
- const id = m.id ?? '';
100
- const prefix = PROVIDER_GROUPS.find(p => id.startsWith(p)) || null;
101
- const key = prefix !== null ? prefix : '(no prefix)';
117
+ const id = typeof m?.id === 'string' ? m.id : '';
118
+ if (!id || !keptSet.has(id)) continue;
119
+ const family = classifyKind(id);
120
+ const key = family || '(unclassified)';
102
121
  if (!groups[key]) groups[key] = [];
103
- groups[key].push({ id, display_name: m.display_name ?? m.id });
122
+ groups[key].push({ id, display_name: m.display_name ?? id });
104
123
  }
105
124
  return groups;
106
125
  }
107
126
 
108
127
  function printTable(groups) {
109
- const colWidths = { provider: 14, id: 50, display_name: 30 };
128
+ const colWidths = { provider: 16, id: 50, display_name: 30 };
110
129
 
111
130
  const header = [
112
131
  'provider'.padEnd(colWidths.provider),
@@ -670,6 +670,134 @@ export function classifyKind(modelId) {
670
670
  return 'other';
671
671
  }
672
672
 
673
+ // ── Disabled providers (10.22.0 / Phase 4) ────────────────────────────────────
674
+ //
675
+ // Operator-controllable provider disable list. Read from the top-level
676
+ // `disabledProviders: string[]` key on the model-router config. There is NO
677
+ // in-code hardcoded list — adding or removing a blocked provider is a
678
+ // single JSON edit.
679
+ //
680
+ // Dual-path read: the Bizar path (`~/.config/bizar/config/claude/model-router.json`)
681
+ // WINS when both exist, including an explicit `[]` (operators may pin
682
+ // "no providers disabled" without deleting the legacy mirror). Whitespace
683
+ // trim + lowercase normalization happens at read time so operators may
684
+ // write `" Anthropic "` in JSON and still match `anthropic/...` model
685
+ // ids. The comparison itself is a case-sensitive prefix filter against
686
+ // the (lowercase) disabled prefixes, so `Anthropic/claude-X` (capital A)
687
+ // is intentionally NOT stripped — pin test covers that.
688
+
689
+ /**
690
+ * Normalize a single disabled-provider prefix: trim whitespace, lowercase.
691
+ * Returns empty string for non-string / empty input.
692
+ * @param {unknown} raw
693
+ * @returns {string}
694
+ */
695
+ function normalizeDisabledPrefix(raw) {
696
+ if (typeof raw !== 'string') return '';
697
+ const trimmed = raw.trim();
698
+ if (!trimmed) return '';
699
+ return trimmed.toLowerCase();
700
+ }
701
+
702
+ /**
703
+ * Read `disabledProviders` from a parsed router object. Returns the
704
+ * normalized prefix array (or empty array when missing / malformed).
705
+ * @param {unknown} router
706
+ * @returns {string[]}
707
+ */
708
+ function extractDisabledProviders(router) {
709
+ if (!router || typeof router !== 'object' || Array.isArray(router)) return [];
710
+ const raw = router.disabledProviders;
711
+ if (!Array.isArray(raw)) return [];
712
+ const out = [];
713
+ for (const v of raw) {
714
+ const norm = normalizeDisabledPrefix(v);
715
+ if (norm) out.push(norm);
716
+ }
717
+ return out;
718
+ }
719
+
720
+ /**
721
+ * Read the operator's `disabledProviders` list from the model-router
722
+ * config. Dual-path: the Bizar path wins when present (even with an
723
+ * explicit empty array); the legacy `~/.claude/model-router.json` mirror
724
+ * is the fallback. Both paths are normalized (trim + lowercase) at read.
725
+ *
726
+ * Pure function over the filesystem; returns an empty array when neither
727
+ * file exists, when both files lack the key, or when both reads fail.
728
+ *
729
+ * @param {{ routerPath?: string, legacyPath?: string }} [opts]
730
+ * - `routerPath` defaults to the Bizar home path
731
+ * (`~/.config/bizar/config/claude/model-router.json`).
732
+ * - `legacyPath` defaults to the Claude Code mirror
733
+ * (`~/.claude/model-router.json`).
734
+ * @returns {string[]} normalized disabled-provider prefixes
735
+ */
736
+ export function readDisabledProviders({ routerPath, legacyPath } = {}) {
737
+ const bizarPath = routerPath || join(homedir(), '.config', 'bizar', 'config', 'claude', 'model-router.json');
738
+ const fallPath = legacyPath || join(homedir(), '.claude', 'model-router.json');
739
+ if (existsSync(bizarPath)) {
740
+ try {
741
+ const parsed = JSON.parse(readFileSync(bizarPath, 'utf8'));
742
+ const extracted = extractDisabledProviders(parsed);
743
+ // Bizar path exists — its `disabledProviders` is authoritative even
744
+ // when explicitly `[]` (operators may pin "no providers disabled"
745
+ // without deleting the legacy mirror). Missing key still falls back.
746
+ if (Array.isArray(parsed && typeof parsed === 'object' ? parsed.disabledProviders : undefined)) {
747
+ return extracted;
748
+ }
749
+ } catch {
750
+ // Corrupt Bizar file — fall through to the legacy mirror.
751
+ }
752
+ }
753
+ if (existsSync(fallPath)) {
754
+ try {
755
+ const parsed = JSON.parse(readFileSync(fallPath, 'utf8'));
756
+ return extractDisabledProviders(parsed);
757
+ } catch {
758
+ return [];
759
+ }
760
+ }
761
+ return [];
762
+ }
763
+
764
+ /**
765
+ * Filter a candidate id list against the disabled-providers prefix list.
766
+ *
767
+ * The comparison is a strict, case-sensitive prefix match against the
768
+ * NORMALIZED (lowercase) disabled prefixes. Candidate ids are checked
769
+ * as-given, so a candidate like `Anthropic/claude-X` (capital A) is
770
+ * intentionally NOT stripped when `disabledProviders` is `["anthropic"]`
771
+ * — pin test covers that. Operators who want to block mixed-case ids
772
+ * should write the lowercase form.
773
+ *
774
+ * Empty / missing `disabledProviders` is a no-op (returns the input list).
775
+ *
776
+ * @template T extends string
777
+ * @param {T[] | Iterable<T>} candidates
778
+ * @param {string[]} disabledProviders already-normalized prefixes
779
+ * @returns {{ kept: T[], stripped: T[] }}
780
+ */
781
+ export function filterCandidatesByDisabledProviders(candidates, disabledProviders) {
782
+ const list = Array.isArray(candidates) ? candidates : Array.from(candidates || []);
783
+ const prefixes = Array.isArray(disabledProviders)
784
+ ? disabledProviders.filter((p) => typeof p === 'string' && p.length > 0)
785
+ : [];
786
+ if (prefixes.length === 0) return { kept: [...list], stripped: [] };
787
+ const kept = [];
788
+ const stripped = [];
789
+ for (const id of list) {
790
+ const s = typeof id === 'string' ? id : '';
791
+ let blocked = false;
792
+ for (const p of prefixes) {
793
+ if (s && s.startsWith(p)) { blocked = true; break; }
794
+ }
795
+ if (blocked) stripped.push(id);
796
+ else kept.push(id);
797
+ }
798
+ return { kept, stripped };
799
+ }
800
+
673
801
  // ── Persistence ──────────────────────────────────────────────────────────────
674
802
 
675
803
  /**
@@ -692,8 +820,16 @@ export function loadRouter(routerPath) {
692
820
  * @param {{ routerPath: string, models: string[], tierHints?: Record<string,string>, profiles?: Record<string,object>, source?: string }} opts
693
821
  * @returns {{ models: string[], lastUpdated: string, source: string, tierHints: Record<string,string>, profiles: Record<string,object> }}
694
822
  */
695
- export function applyModels({ routerPath, models, tierHints = {}, profiles = {}, source = 'live-pick' }) {
696
- const list = Array.isArray(models) ? models.filter((m) => typeof m === 'string' && m.trim()) : [];
823
+ export function applyModels({ routerPath, models, tierHints = {}, profiles = {}, source = 'live-pick', disabledProviders }) {
824
+ const incoming = Array.isArray(models) ? models.filter((m) => typeof m === 'string' && m.trim()) : [];
825
+ // 10.22.0 / Phase 4: strip any id whose provider prefix is on the
826
+ // operator's disabled-providers list. The router file owns the list —
827
+ // no in-code hardcoded families. Stripped ids never reach the picker,
828
+ // the settings.json sync, the SessionStart hook, or the Agent guard.
829
+ // Tests pass an explicit `disabledProviders` array to keep the contract
830
+ // deterministic; production callers omit it and read from disk.
831
+ const disabled = Array.isArray(disabledProviders) ? disabledProviders : readDisabledProviders();
832
+ const { kept: list } = filterCandidatesByDisabledProviders(incoming, disabled);
697
833
  const hints = { ...(tierHints || {}) };
698
834
  for (const id of list) if (!hints[id]) hints[id] = defaultTierHint(id);
699
835
  const block = {
@@ -739,9 +875,14 @@ function writeAtomic(path, body) {
739
875
  * - `staleIds` — picks that were never returned by the gateway in this run.
740
876
  * - `unknownIds` — picks whose live status is unknown (no live pool yet).
741
877
  */
742
- export function partitionStalePicks({ liveIds, pickedIds }) {
878
+ export function partitionStalePicks({ liveIds, pickedIds, disabledProviders }) {
743
879
  const live = Array.isArray(liveIds) ? new Set(liveIds) : null;
744
- const picks = Array.isArray(pickedIds) ? pickedIds.filter((id) => typeof id === 'string' && id.trim()) : [];
880
+ const incoming = Array.isArray(pickedIds) ? pickedIds.filter((id) => typeof id === 'string' && id.trim()) : [];
881
+ // 10.22.0 / Phase 4: strip disabled-provider ids before partition so
882
+ // they never reach the live pool OR the stale list — they were never
883
+ // the operator's intent once the disable list landed.
884
+ const disabled = Array.isArray(disabledProviders) ? disabledProviders : readDisabledProviders();
885
+ const { kept: picks } = filterCandidatesByDisabledProviders(incoming, disabled);
745
886
  if (!live || live.size === 0) {
746
887
  return { liveIds: [], staleIds: [], unknownIds: [...new Set(picks)] };
747
888
  }
@@ -782,18 +923,25 @@ export function partitionStalePicks({ liveIds, pickedIds }) {
782
923
  * wrote: boolean,
783
924
  * syncedIds: string[],
784
925
  * skippedStale: string[],
926
+ * skippedDisabled: string[],
785
927
  * settingsPath: string|null,
786
928
  * }}
787
929
  */
788
- export function applyModelOverrides({ settingsJsonPath, pickedIds, liveIds = [] }) {
930
+ export function applyModelOverrides({ settingsJsonPath, pickedIds, liveIds = [], disabledProviders }) {
789
931
  const path = settingsJsonPath === undefined
790
932
  ? join(homedir(), '.claude', 'settings.json')
791
933
  : settingsJsonPath;
792
934
  if (path === null) {
793
- return { wrote: false, syncedIds: [], skippedStale: [], settingsPath: null };
935
+ return { wrote: false, syncedIds: [], skippedStale: [], skippedDisabled: [], settingsPath: null };
794
936
  }
795
937
  const live = new Set(Array.isArray(liveIds) ? liveIds : []);
796
- const picks = Array.isArray(pickedIds) ? pickedIds.filter((id) => typeof id === 'string' && id.trim()) : [];
938
+ const incoming = Array.isArray(pickedIds) ? pickedIds.filter((id) => typeof id === 'string' && id.trim()) : [];
939
+ // 10.22.0 / Phase 4: strip disabled-provider ids BEFORE the self-map
940
+ // so Claude Code never sees an `anthropic/*` self-map entry. The
941
+ // skipped ids are reported back so the operator can see what was
942
+ // dropped (without crashing on the disabled list).
943
+ const disabled = Array.isArray(disabledProviders) ? disabledProviders : readDisabledProviders();
944
+ const { kept: picks, stripped: skippedDisabled } = filterCandidatesByDisabledProviders(incoming, disabled);
797
945
  // Self-map only IDs the live gateway serves. Stale IDs intentionally stay
798
946
  // out so the `[claude-code:unrecognized_model]` diagnostic still fires
799
947
  // for them — the operator should re-run `bizar models` to drop them.
@@ -810,14 +958,14 @@ export function applyModelOverrides({ settingsJsonPath, pickedIds, liveIds = []
810
958
  } catch {
811
959
  // Corrupt settings.json — refuse to overwrite it; surface the sync
812
960
  // as a no-op so `bizar models` still completes.
813
- return { wrote: false, syncedIds: [], skippedStale: skipped, settingsPath: path };
961
+ return { wrote: false, syncedIds: [], skippedStale: skipped, skippedDisabled, settingsPath: path };
814
962
  }
815
963
  }
816
964
  // Self-map pattern — Claude Code uses modelOverrides to suppress
817
965
  // `[claude-code:unrecognized_model]` for any ID that maps to itself.
818
966
  settings.modelOverrides = Object.fromEntries(synced.map((id) => [id, id]));
819
967
  writeAtomic(path, JSON.stringify(settings, null, 2) + '\n');
820
- return { wrote: true, syncedIds: synced, skippedStale: skipped, settingsPath: path };
968
+ return { wrote: true, syncedIds: synced, skippedStale: skipped, skippedDisabled, settingsPath: path };
821
969
  }
822
970
 
823
971
  /**
@@ -893,18 +1041,24 @@ export function deriveModelLabel(modelId, profile) {
893
1041
  * wrote: boolean,
894
1042
  * options: Array<{model: string, label: string, description?: string}>,
895
1043
  * skippedStale: string[],
1044
+ * skippedDisabled: string[],
896
1045
  * settingsPath: string|null,
897
1046
  * }}
898
1047
  */
899
- export function applyModelPicker({ settingsJsonPath, pickedIds, profiles = {}, liveIds = [] }) {
1048
+ export function applyModelPicker({ settingsJsonPath, pickedIds, profiles = {}, liveIds = [], disabledProviders }) {
900
1049
  const path = settingsJsonPath === undefined
901
1050
  ? join(homedir(), '.claude', 'settings.json')
902
1051
  : settingsJsonPath;
903
1052
  if (path === null) {
904
- return { wrote: false, options: [], skippedStale: [], settingsPath: null };
1053
+ return { wrote: false, options: [], skippedStale: [], skippedDisabled: [], settingsPath: null };
905
1054
  }
906
1055
  const live = new Set(Array.isArray(liveIds) ? liveIds : []);
907
- const picks = Array.isArray(pickedIds) ? pickedIds.filter((id) => typeof id === 'string' && id.trim()) : [];
1056
+ const incoming = Array.isArray(pickedIds) ? pickedIds.filter((id) => typeof id === 'string' && id.trim()) : [];
1057
+ // 10.22.0 / Phase 4: drop disabled-provider ids BEFORE the live-id
1058
+ // gate so Claude Code's `/model` picker never surfaces an
1059
+ // `anthropic/*` (or any other disabled) option.
1060
+ const disabled = Array.isArray(disabledProviders) ? disabledProviders : readDisabledProviders();
1061
+ const { kept: picks, stripped: skippedDisabled } = filterCandidatesByDisabledProviders(incoming, disabled);
908
1062
  const surviving = live.size === 0 ? picks : picks.filter((id) => live.has(id));
909
1063
  const skipped = live.size === 0 ? [] : picks.filter((id) => !live.has(id));
910
1064
  const options = surviving.map((id) => {
@@ -922,12 +1076,12 @@ export function applyModelPicker({ settingsJsonPath, pickedIds, profiles = {}, l
922
1076
  const parsed = JSON.parse(readFileSync(path, 'utf8'));
923
1077
  if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) settings = parsed;
924
1078
  } catch {
925
- return { wrote: false, options: [], skippedStale: skipped, settingsPath: path };
1079
+ return { wrote: false, options: [], skippedStale: skipped, skippedDisabled, settingsPath: path };
926
1080
  }
927
1081
  }
928
1082
  settings.modelPicker = { options };
929
1083
  writeAtomic(path, JSON.stringify(settings, null, 2) + '\n');
930
- return { wrote: true, options, skippedStale: skipped, settingsPath: path };
1084
+ return { wrote: true, options, skippedStale: skipped, skippedDisabled, settingsPath: path };
931
1085
  }
932
1086
 
933
1087
  // ── F-190 / IMP-017 refresh + operator-override preservation ────────────────
@@ -956,7 +1110,7 @@ function profileNeedsRefresh(profile, now = new Date()) {
956
1110
  * `bizar models --refresh` prints in non-interactive mode.
957
1111
  *
958
1112
  * @param {{ routerPath: string, candidates: Array<{ id: string, profile?: object }>, catalog: object, aliasMap?: object, now?: Date }} opts
959
- * @returns {{ refreshed: string[], preservedOperator: string[], skippedFresh: string[], error?: string }}
1113
+ * @returns {{ refreshed: string[], preservedOperator: string[], skippedFresh: string[], skippedDisabled: string[], error?: string }}
960
1114
  */
961
1115
  export function applyRefresh({
962
1116
  routerPath,
@@ -964,6 +1118,7 @@ export function applyRefresh({
964
1118
  catalog,
965
1119
  aliasMap = {},
966
1120
  now = new Date(),
1121
+ disabledProviders,
967
1122
  } = {}) {
968
1123
  if (!routerPath || typeof routerPath !== 'string') throw new Error('routerPath is required');
969
1124
  if (!Array.isArray(candidates)) throw new Error('candidates is required (array)');
@@ -979,15 +1134,29 @@ export function applyRefresh({
979
1134
  const existingProfiles = userSelected.profiles && typeof userSelected.profiles === 'object'
980
1135
  ? userSelected.profiles
981
1136
  : {};
982
- const existingModels = Array.isArray(userSelected.models) ? userSelected.models : [];
1137
+ const rawExistingModels = Array.isArray(userSelected.models) ? userSelected.models : [];
983
1138
  const existingTierHints = userSelected.tierHints && typeof userSelected.tierHints === 'object'
984
1139
  ? userSelected.tierHints
985
1140
  : {};
986
1141
 
1142
+ // 10.22.0 / Phase 4: filter disabled-provider ids off the existing
1143
+ // list BEFORE the refresh loop so the disabled ids never get a
1144
+ // refresh attempt, are not in the returned counts, and the persisted
1145
+ // userSelected.models reflects the operator's intent. Dropped profiles
1146
+ // are also pruned so the on-disk state does not keep growing on every
1147
+ // refresh.
1148
+ const disabled = Array.isArray(disabledProviders) ? disabledProviders : readDisabledProviders();
1149
+ const { kept: existingModels, stripped: droppedDisabledIds } = filterCandidatesByDisabledProviders(
1150
+ rawExistingModels.filter((id) => typeof id === 'string' && id.trim()),
1151
+ disabled,
1152
+ );
1153
+ const newProfiles = { ...existingProfiles };
1154
+ for (const dropped of droppedDisabledIds) delete newProfiles[dropped];
1155
+
987
1156
  const refreshed = [];
988
1157
  const preservedOperator = [];
989
1158
  const skippedFresh = [];
990
- const newProfiles = { ...existingProfiles };
1159
+ const skippedDisabled = [...droppedDisabledIds];
991
1160
 
992
1161
  for (const id of existingModels) {
993
1162
  if (typeof id !== 'string' || !id.trim()) continue;
@@ -1050,7 +1219,7 @@ export function applyRefresh({
1050
1219
  // the picker write path above.
1051
1220
  writeAtomic(routerPath, JSON.stringify(router, null, 2) + '\n');
1052
1221
 
1053
- return { refreshed, preservedOperator, skippedFresh };
1222
+ return { refreshed, preservedOperator, skippedFresh, skippedDisabled };
1054
1223
  }
1055
1224
 
1056
1225
  function safeParseRouter(path) {
@@ -1149,11 +1318,17 @@ function stampProvenance(profile, now) {
1149
1318
  * Read the user-selected block off the router. Returns the models array
1150
1319
  * (possibly empty) and the tier hints.
1151
1320
  */
1152
- export function currentSelection(router) {
1321
+ export function currentSelection(router, { disabledProviders } = {}) {
1153
1322
  if (!router || typeof router !== 'object') return { models: [], tierHints: {} };
1154
1323
  const us = router.userSelected;
1155
1324
  if (!us || typeof us !== 'object') return { models: [], tierHints: {} };
1156
- const models = Array.isArray(us.models) ? us.models.filter((m) => typeof m === 'string') : [];
1325
+ const incoming = Array.isArray(us.models) ? us.models.filter((m) => typeof m === 'string') : [];
1326
+ // 10.22.0 / Phase 4: also strip disabled-provider ids on every read.
1327
+ // Callers (interactive picker, JSON envelope, audit tools) all consume
1328
+ // the filtered list so the operator's disable intent is honoured even
1329
+ // for ids already persisted in `userSelected.models` from a prior run.
1330
+ const disabled = Array.isArray(disabledProviders) ? disabledProviders : readDisabledProviders();
1331
+ const { kept: models } = filterCandidatesByDisabledProviders(incoming, disabled);
1157
1332
  const tierHints = us.tierHints && typeof us.tierHints === 'object' ? us.tierHints : {};
1158
1333
  return { models, tierHints };
1159
1334
  }
@@ -1770,6 +1945,16 @@ function showHelp() {
1770
1945
  a default. Bizar is provider-agnostic and ships no default gateway.
1771
1946
 
1772
1947
  Auth: $ANTHROPIC_AUTH_TOKEN -> settings.json#env.ANTHROPIC_AUTH_TOKEN.\n\n Discovered models are enriched from https://models.dev/models.json.\n Metadata lookup is best-effort and never hides gateway-reported models.
1948
+
1949
+ Disable providers (10.22.0 / Phase 4): the operator's
1950
+ model-router.json#disabledProviders: string[]
1951
+ list filters out every id whose provider prefix matches (case-sensitive,
1952
+ trimmed + lowercased at read time). The filter is consulted at every
1953
+ reader site: the picker (interactive + --list), settings.json#model
1954
+ and #modelOverrides, settings.json#modelPicker.options, the
1955
+ SessionStart sync, and the Agent model guard. Empty / missing list is
1956
+ a no-op (every id passes through). Add or remove a blocked provider
1957
+ with a single JSON edit; no in-code list exists.
1773
1958
  `;
1774
1959
  console.log(help);
1775
1960
  }
@@ -11,7 +11,7 @@
11
11
  * 2. Sets the permissive defaults the Bizar user typically wants:
12
12
  * - permissions.defaultMode → "bypassPermissions"
13
13
  * - worktree.bgIsolation → "worktree"
14
- * - model → "claude/minimax/MiniMax-M3"
14
+ * - model → userSelected.models[0] (10.22.0 / Phase 4)
15
15
  * - alwaysThinkingEnabled → true
16
16
  * - effortLevel → "high"
17
17
  * - skipDangerousModePermissionPrompt → true
@@ -21,6 +21,9 @@
21
21
  * a more restrictive mode (e.g. a user who set
22
22
  * defaultMode="default" gets to keep it).
23
23
  * 4. Preserves every unrelated key (mcpServers, hooks, env, etc.).
24
+ * 5. Skips `model` entirely when userSelected is empty — Claude Code
25
+ * inherits its session default, still gated by the operator's
26
+ * `disabledProviders` list (10.22.0 / Phase 4).
24
27
  *
25
28
  * Why this exists: the canonical config/claude/settings.json ships with
26
29
  * these defaults and `cli/provision.mjs` (F-163 scope-owned) is the
@@ -37,11 +40,39 @@ import { join } from 'node:path';
37
40
  const HOME = homedir();
38
41
  const CLAUDE_DIR = process.env.CLAUDE_CONFIG_DIR?.trim() || join(HOME, '.claude');
39
42
  const SETTINGS_PATH = join(CLAUDE_DIR, 'settings.json');
43
+ // 10.22.0 / Phase 4 spirit-of-constraint: the install model id comes
44
+ // from the operator's `userSelected.models[0]`, not a hardcoded literal.
45
+ // Dual-path read matches `cli/commands/models.mjs#readDisabledProviders`:
46
+ // the Bizar path wins when present; the Claude Code mirror is fallback.
47
+ const BIZAR_ROUTER_PATH = process.env.BIZAR_MODEL_ROUTER_CONFIG?.trim()
48
+ || join(HOME, '.config', 'bizar', 'config', 'claude', 'model-router.json');
49
+ const LEGACY_ROUTER_PATH = join(HOME, '.claude', 'model-router.json');
50
+
51
+ function readUserSelectedModels() {
52
+ for (const path of [BIZAR_ROUTER_PATH, LEGACY_ROUTER_PATH]) {
53
+ if (!existsSync(path)) continue;
54
+ try {
55
+ const parsed = JSON.parse(readFileSync(path, 'utf8'));
56
+ const list = Array.isArray(parsed?.userSelected?.models)
57
+ ? parsed.userSelected.models.filter((id) => typeof id === 'string' && id.trim())
58
+ : [];
59
+ if (list.length > 0) return list;
60
+ // Bizar path exists with an explicit empty `userSelected.models` —
61
+ // honour that intent (do NOT fall through to the legacy mirror).
62
+ if (path === BIZAR_ROUTER_PATH && parsed && typeof parsed === 'object'
63
+ && Array.isArray(parsed.userSelected?.models)) {
64
+ return [];
65
+ }
66
+ } catch { /* keep falling through */ }
67
+ }
68
+ return [];
69
+ }
40
70
 
41
- const FAVORED = {
71
+ const STATIC_FAVORED = {
42
72
  permissions: { defaultMode: 'bypassPermissions' },
43
73
  worktree: { bgIsolation: 'worktree' },
44
- model: 'claude/minimax/MiniMax-M3',
74
+ // `model` is intentionally absent — `buildFavored()` derives it from
75
+ // userSelected.models[0] (or omits it when empty).
45
76
  alwaysThinkingEnabled: true,
46
77
  effortLevel: 'high',
47
78
  skipDangerousModePermissionPrompt: true,
@@ -49,6 +80,13 @@ const FAVORED = {
49
80
  askUserQuestionTimeout: '5m',
50
81
  };
51
82
 
83
+ function buildFavored() {
84
+ const out = { ...STATIC_FAVORED };
85
+ const picks = readUserSelectedModels();
86
+ if (picks[0]) out.model = picks[0];
87
+ return out;
88
+ }
89
+
52
90
  function isAtLeastAsPermissive(existing, desired) {
53
91
  // Don't downgrade: if the user picked a stricter mode, keep theirs.
54
92
  const order = { default: 0, acceptEdits: 1, bypassPermissions: 2 };
@@ -86,10 +124,11 @@ function load() {
86
124
 
87
125
  function diff(existing) {
88
126
  const proposed = JSON.parse(JSON.stringify(existing));
89
- deepAssign(proposed, FAVORED);
127
+ const favored = buildFavored();
128
+ deepAssign(proposed, favored);
90
129
  // Permission-mode guard.
91
130
  const existingMode = existing?.permissions?.defaultMode;
92
- if (existingMode && !isAtLeastAsPermissive(existingMode, FAVORED.permissions.defaultMode)) {
131
+ if (existingMode && !isAtLeastAsPermissive(existingMode, favored.permissions.defaultMode)) {
93
132
  proposed.permissions.defaultMode = existingMode;
94
133
  }
95
134
  return proposed;
package/cli/provision.mjs CHANGED
@@ -813,6 +813,13 @@ export function writeClaudeSettings({ dryRun = false, force = false } = {}) {
813
813
  // source of truth for defaultMode, worktree, enableWorkflows, etc. We
814
814
  // overlay Bizar-owned keys (mcpServers, hooks, env) on top so the installer
815
815
  // honors user preferences without having to fork the template here.
816
+ // 10.22.0 / Phase 4 spirit-of-constraint fix: `model` and
817
+ // `modelOverrides` are no longer hardcoded in the shipped template;
818
+ // they are derived from the operator's persisted
819
+ // `userSelected.models[0]`. When the operator has not picked anything
820
+ // yet, both keys are omitted entirely — Claude Code inherits its
821
+ // session default, still gated by the operator's `disabledProviders`
822
+ // list (10.22.0 / Phase 4).
816
823
  const bizarSettings = {
817
824
  ...shipped,
818
825
  $schema: shipped.$schema || 'https://json.schemastore.org/claude-code-settings.json',
@@ -901,6 +908,37 @@ export function writeClaudeSettings({ dryRun = false, force = false } = {}) {
901
908
  },
902
909
  };
903
910
 
911
+ // 10.22.0 / Phase 4: derive `model` and `modelOverrides` from the
912
+ // operator's `userSelected.models[0]`. Both keys are omitted entirely
913
+ // when the operator has not picked anything — Claude Code inherits its
914
+ // session default. Dual-path read matches
915
+ // `cli/commands/models.mjs#readDisabledProviders` and
916
+ // `cli/commands/upgrade-defaults.mjs#readUserSelectedModels`.
917
+ const bizarRouterPath = process.env.BIZAR_MODEL_ROUTER_CONFIG?.trim()
918
+ || join(BIZAR_HOME(), 'config', 'claude', 'model-router.json');
919
+ const legacyRouterPath = join(CLAUDE_DIR, 'model-router.json');
920
+ let userSelectedModels = [];
921
+ for (const p of [bizarRouterPath, legacyRouterPath]) {
922
+ if (!existsSync(p)) continue;
923
+ const parsed = readJsonSafe(p, null);
924
+ if (!parsed || typeof parsed !== 'object') continue;
925
+ const list = Array.isArray(parsed?.userSelected?.models)
926
+ ? parsed.userSelected.models.filter((id) => typeof id === 'string' && id && id.trim())
927
+ : [];
928
+ if (list.length > 0) { userSelectedModels = list; break; }
929
+ // Bizar path exists with explicit empty `userSelected.models` —
930
+ // honour that intent and stop the fallback walk.
931
+ if (p === bizarRouterPath) break;
932
+ }
933
+ const installModel = userSelectedModels[0];
934
+ if (installModel) {
935
+ bizarSettings.model = installModel;
936
+ bizarSettings.modelOverrides = { [installModel]: installModel };
937
+ } else {
938
+ delete bizarSettings.model;
939
+ delete bizarSettings.modelOverrides;
940
+ }
941
+
904
942
  const merged = { ...existing };
905
943
  if (force) {
906
944
  Object.assign(merged, bizarSettings);
@@ -1108,7 +1146,25 @@ export async function runProvision(opts = {}) {
1108
1146
  if (anyFail) console.log(chalk.yellow(' ⚠ Some steps had issues.'));
1109
1147
  else { console.log(chalk.bold.green(' ✓ Bizar is ready.')); console.log(chalk.dim(' Next: restart your Claude Code session.')); }
1110
1148
  console.log('');
1111
- console.log(chalk.dim(' Premium model: ANTHROPIC_MODEL=claude-qwen/qwen3.8-max claude'));
1149
+ // 10.22.0 / Phase 4 spirit-of-constraint fix: derive the install-banner
1150
+ // premium model id from the operator's persisted
1151
+ // `userSelected.tierHints.premium[0]` instead of hardcoding one
1152
+ // provider's id. When the operator has not picked anything yet, fall
1153
+ // back to a clear hint to run `bizar models`.
1154
+ let premiumPick = null;
1155
+ try {
1156
+ const routerPath = join(BIZAR_HOME(), 'config', 'claude', 'model-router.json');
1157
+ const router = JSON.parse(readFileSync(routerPath, 'utf8'));
1158
+ const picks = Array.isArray(router?.userSelected?.tierHints?.premium)
1159
+ ? router.userSelected.tierHints.premium.filter((id) => typeof id === 'string' && id)
1160
+ : [];
1161
+ premiumPick = picks[0] || null;
1162
+ } catch { /* fresh install — no router file yet */ }
1163
+ if (premiumPick) {
1164
+ console.log(chalk.dim(` Premium model: ANTHROPIC_MODEL=${premiumPick} claude`));
1165
+ } else {
1166
+ console.log(chalk.dim(' Premium model: (no premium pick configured yet — run `bizar models`)'));
1167
+ }
1112
1168
  console.log(chalk.dim(' See /use-premium or .claude/commands/use-premium.md for the full launch snippet.'));
1113
1169
  console.log('');
1114
1170
  return { ok: !anyFail, mode: effectiveMode, state: detectState(), stepResults };
@@ -31,11 +31,13 @@ Default tier classification heuristic (set by the picker, overridable per-model
31
31
  | anything else | `mid` |
32
32
  | `nano`, `mini`, `haiku` (older), `flash`, `lite`, `tiny` | `budget` |
33
33
 
34
- Concrete example: if `userSelected.models = ["claude-minimax/MiniMax-M3", "claude-qwen/qwen3.8-max"]` and `tierHints = { "claude-minimax/MiniMax-M3": "default", "claude-qwen/qwen3.8-max": "premium" }`:
35
- - a `default`-tier dispatch picks `claude-minimax/MiniMax-M3`,
36
- - a `premium`-tier dispatch picks `claude-qwen/qwen3.8-max`,
34
+ Concrete example: if `userSelected.models = ["<pick-from-default-tier>", "<pick-from-premium-tier>"]` and `tierHints = { "<pick-from-default-tier>": "default", "<pick-from-premium-tier>": "premium" }`:
35
+ - a `default`-tier dispatch picks `<pick-from-default-tier>`,
36
+ - a `premium`-tier dispatch picks `<pick-from-premium-tier>`,
37
37
  - anything else (no tier configured) → omit `model` and inherit the session.
38
38
 
39
+ 10.22.0 / Phase 4: agent docs ship with placeholder ids (`<pick-from-default-tier>`, `<pick-from-premium-tier>`) instead of literal model ids so a provider swap never requires editing this file. Operators configure the real ids via `bizar models`.
40
+
39
41
  The SDK resolver (`packages/sdk/src/router/agent-model-registry.ts#rankUserSelectedForRole`) now ranks the `userSelected` pool by capability profile before falling back to the tier default.
40
42
 
41
43
  Do not auto-discover new models. Do not add tier-candidates that are not in `userSelected`. The Agent-model-guard (`config/claude/hooks/agent-model-guard.mjs`) blocks any other model override.
@@ -60,18 +60,62 @@ function advise(reason) {
60
60
  };
61
61
  }
62
62
 
63
+ /**
64
+ * 10.22.0 / Phase 4: extract the operator's `disabledProviders` list from
65
+ * the loaded registry. Whitespace-trimmed + lowercased at read time.
66
+ * Returns `[]` for legacy configs that lack the key (no in-code default).
67
+ * The hook MUST fail open on parse errors — the orchestrator already
68
+ * chose a model; let Claude Code validate it once.
69
+ */
70
+ function readDisabledProvidersFromRegistry(registry) {
71
+ if (!registry || typeof registry !== 'object') return [];
72
+ const raw = registry.disabledProviders;
73
+ if (!Array.isArray(raw)) return [];
74
+ const out = [];
75
+ for (const v of raw) {
76
+ if (typeof v !== 'string') continue;
77
+ const trimmed = v.trim();
78
+ if (!trimmed) continue;
79
+ out.push(trimmed.toLowerCase());
80
+ }
81
+ return out;
82
+ }
83
+
84
+ /**
85
+ * 10.22.0 / Phase 4: case-sensitive prefix filter against the (lowercase)
86
+ * disabled list. Empty / missing prefix list is a no-op (returns input).
87
+ */
88
+ function isDisabledId(id, prefixes) {
89
+ if (!Array.isArray(prefixes) || prefixes.length === 0) return false;
90
+ if (typeof id !== 'string' || !id) return false;
91
+ for (const p of prefixes) {
92
+ if (typeof p === 'string' && p && id.startsWith(p)) return true;
93
+ }
94
+ return false;
95
+ }
96
+
63
97
  /**
64
98
  * Models that pass the configured-tier check: every model in any
65
99
  * `tiers.<x>.models` block, PLUS every model in `userSelected.models`.
100
+ *
101
+ * 10.22.0 / Phase 4: ids whose provider prefix is on the operator's
102
+ * `disabledProviders` list are silently filtered out — the picker IS
103
+ * still the discovery surface for user picks, but the operator's
104
+ * disable intent overrides user intent.
66
105
  */
67
106
  function configuredModels(registry) {
107
+ const disabled = readDisabledProvidersFromRegistry(registry);
68
108
  const out = new Set();
69
109
  for (const tier of Object.values(registry?.tiers || {})) {
70
- if (Array.isArray(tier?.models)) for (const id of tier.models) out.add(id);
110
+ if (Array.isArray(tier?.models)) for (const id of tier.models) {
111
+ if (!isDisabledId(id, disabled)) out.add(id);
112
+ }
71
113
  }
72
114
  const userSelected = registry?.userSelected;
73
115
  if (userSelected && Array.isArray(userSelected.models)) {
74
- for (const id of userSelected.models) out.add(id);
116
+ for (const id of userSelected.models) {
117
+ if (!isDisabledId(id, disabled)) out.add(id);
118
+ }
75
119
  }
76
120
  return out;
77
121
  }
@@ -79,12 +123,17 @@ function configuredModels(registry) {
79
123
  /**
80
124
  * Subset of `configuredModels` that came from the user picker. These bypass
81
125
  * the live-discovery validation (the picker IS the discovery).
126
+ *
127
+ * 10.22.0 / Phase 4: same disabled-prefix filter as `configuredModels`.
82
128
  */
83
129
  function userSelectedModels(registry) {
130
+ const disabled = readDisabledProvidersFromRegistry(registry);
84
131
  const out = new Set();
85
132
  const userSelected = registry?.userSelected;
86
133
  if (userSelected && Array.isArray(userSelected.models)) {
87
- for (const id of userSelected.models) out.add(id);
134
+ for (const id of userSelected.models) {
135
+ if (!isDisabledId(id, disabled)) out.add(id);
136
+ }
88
137
  }
89
138
  return out;
90
139
  }
@@ -127,6 +176,16 @@ export async function guardAgentModel(input, options = {}) {
127
176
  return {};
128
177
  }
129
178
 
179
+ // 10.22.0 / Phase 4: silent-filter contract — if the operator's
180
+ // `disabledProviders` list covers the requested id, fall through
181
+ // without advising. The orchestrator already chose the id; the
182
+ // operator's disable intent overrides user picks at config time, not
183
+ // dispatch time. Pin: see
184
+ // `config/claude/hooks/__tests__/agent-model-guard.test.mjs#Agent
185
+ // model guard filters disabled-provider user picks silently`.
186
+ const disabled = readDisabledProvidersFromRegistry(registry);
187
+ if (isDisabledId(requested, disabled)) return {};
188
+
130
189
  const allowed = configuredModels(registry);
131
190
  const userPicks = userSelectedModels(registry);
132
191
 
@@ -65,6 +65,69 @@ function readRouterPath() {
65
65
  return join(homedir(), '.config', 'bizar', 'config', 'claude', 'model-router.json');
66
66
  }
67
67
 
68
+ /**
69
+ * 10.22.0 / Phase 4: read the operator's `disabledProviders: string[]`
70
+ * list with the same dual-path contract as `cli/commands/models.mjs`.
71
+ * The Bizar path (`~/.config/bizar/config/claude/model-router.json`) wins
72
+ * when both exist (even with explicit `[]`); the legacy
73
+ * `~/.claude/model-router.json` mirror is the fallback. Whitespace +
74
+ * lowercase normalization happens here. Returns `[]` on any failure —
75
+ * the hook is advisory and must NEVER block session start.
76
+ */
77
+ function readDisabledProviders() {
78
+ const bizarPath = readRouterPath();
79
+ const legacyPath = join(homedir(), '.claude', 'model-router.json');
80
+ const extract = (parsed) => {
81
+ if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) return null;
82
+ if (!Array.isArray(parsed.disabledProviders)) return null;
83
+ const out = [];
84
+ for (const v of parsed.disabledProviders) {
85
+ if (typeof v !== 'string') continue;
86
+ const trimmed = v.trim();
87
+ if (!trimmed) continue;
88
+ out.push(trimmed.toLowerCase());
89
+ }
90
+ return out;
91
+ };
92
+ if (existsSync(bizarPath)) {
93
+ const parsed = readJsonIfObject(bizarPath);
94
+ // Bizar path exists — its `disabledProviders` is authoritative even
95
+ // when explicit `[]`. Missing key still falls back to the legacy.
96
+ if (parsed && Array.isArray(parsed.disabledProviders)) {
97
+ const extracted = extract(parsed);
98
+ if (extracted !== null) return extracted;
99
+ }
100
+ }
101
+ const legacy = readJsonIfObject(legacyPath);
102
+ if (legacy && Array.isArray(legacy.disabledProviders)) {
103
+ const extracted = extract(legacy);
104
+ if (extracted !== null) return extracted;
105
+ }
106
+ return [];
107
+ }
108
+
109
+ /**
110
+ * Strip disabled-provider prefixes from an id list. Case-sensitive
111
+ * prefix match against the (lowercase) disabled list. The hook mirrors
112
+ * the CLI's `filterCandidatesByDisabledProviders` contract — see
113
+ * `cli/commands/models.mjs` for the authoritative implementation.
114
+ */
115
+ function filterDisabled(ids, disabled) {
116
+ const list = Array.isArray(ids) ? ids : [];
117
+ const prefixes = Array.isArray(disabled) ? disabled.filter((p) => typeof p === 'string' && p) : [];
118
+ if (prefixes.length === 0) return [...list];
119
+ const kept = [];
120
+ for (const id of list) {
121
+ if (typeof id !== 'string' || !id) { kept.push(id); continue; }
122
+ let blocked = false;
123
+ for (const p of prefixes) {
124
+ if (id.startsWith(p)) { blocked = true; break; }
125
+ }
126
+ if (!blocked) kept.push(id);
127
+ }
128
+ return kept;
129
+ }
130
+
68
131
  function readSettingsPath() {
69
132
  return join(homedir(), '.claude', 'settings.json');
70
133
  }
@@ -129,7 +192,15 @@ function syncOnce() {
129
192
  const profiles = userSelected && userSelected.profiles && typeof userSelected.profiles === 'object'
130
193
  ? userSelected.profiles
131
194
  : {};
132
- const liveIds = models.filter((id) => typeof id === 'string' && id.trim());
195
+ // 10.22.0 / Phase 4: filter the operator's disabled-provider ids out
196
+ // of the SessionStart re-apply so Claude Code's `/model` picker never
197
+ // surfaces e.g. `anthropic/*` after a session restart. Dual-path read
198
+ // matches `cli/commands/models.mjs#readDisabledProviders`.
199
+ const disabled = readDisabledProviders();
200
+ const liveIds = filterDisabled(
201
+ models.filter((id) => typeof id === 'string' && id.trim()),
202
+ disabled,
203
+ );
133
204
  if (liveIds.length === 0) {
134
205
  return { applied: 0, modelAfter: null, skipped: 'no-userSelected' };
135
206
  }
@@ -155,7 +226,7 @@ function syncOnce() {
155
226
  settings.modelOverrides = Object.fromEntries(liveIds.map((id) => [id, id]));
156
227
 
157
228
  let modelChanged = false;
158
- if (typeof settings.model === 'string' && settings.model.startsWith('claude-')) {
229
+ if (typeof settings.model === 'string' && !liveIds.includes(settings.model)) {
159
230
  settings.model = liveIds[0];
160
231
  modelChanged = true;
161
232
  }
@@ -2,7 +2,8 @@
2
2
  "$schema": "https://bizar.dev/schema/model-router.v3.json",
3
3
  "version": "13.0.0",
4
4
  "endpoint": null,
5
- "comment": "Mike selects the cheapest sufficient user-selected model per dispatch. Agent roles never carry fixed models. The orchestrator dispatches ONLY with IDs in `userSelected.models`. When `userSelected` is empty, omit the Agent model override and inherit the active session model instead of retrying aliases. The gateway endpoint is intentionally null in the shipped config — operators MUST configure it via the `ANTHROPIC_BASE_URL` or `BIZAR_MODEL_ROUTER_URL` environment variable. Bizar is provider-agnostic and ships no default provider.",
5
+ "disabledProviders": ["anthropic"],
6
+ "comment": "Mike selects the cheapest sufficient user-selected model per dispatch. Agent roles never carry fixed models. The orchestrator dispatches ONLY with IDs in `userSelected.models`. When `userSelected` is empty, omit the Agent model override and inherit the active session model instead of retrying aliases. The gateway endpoint is intentionally null in the shipped config — operators MUST configure it via the `ANTHROPIC_BASE_URL` or `BIZAR_MODEL_ROUTER_URL` environment variable. Bizar is provider-agnostic and ships no default provider. `disabledProviders` (top-level, optional) is a case-insensitive-at-write, case-sensitive-at-filter list of provider prefixes whose model ids are stripped from every read site (CLI sync, picker, settings.json sync, SessionStart hook, Agent model guard). Defaults to `[]`; ships with `[\"anthropic\"]` so a fresh install never dispatches `anthropic/*` ids even if a gateway reports them.",
6
7
  "userSelected": {
7
8
  "models": [],
8
9
  "lastUpdated": null,
@@ -59,10 +59,6 @@
59
59
  "bgIsolation": "worktree",
60
60
  "cleanupPeriodDays": 7
61
61
  },
62
- "model": "claude-minimax/MiniMax-M3[1m]",
63
- "modelOverrides": {
64
- "claude-minimax/MiniMax-M3": "claude-minimax/MiniMax-M3[1m]"
65
- },
66
62
  "alwaysThinkingEnabled": true,
67
63
  "effortLevel": "high",
68
64
  "skipDangerousModePermissionPrompt": true,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@polderlabs/bizar",
3
- "version": "10.21.0",
3
+ "version": "10.22.0",
4
4
  "description": "Autonomous, human-in-the-loop multi-agent harness for Claude Code with guarded workflows, typed SDK primitives, and MCP tools.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -1,5 +1,5 @@
1
1
  /**
2
2
  * SDK version constant. Keep synchronized with the workspace package versions.
3
3
  */
4
- export declare const SDK_VERSION: "10.19.8";
4
+ export declare const SDK_VERSION: "10.22.0";
5
5
  //# sourceMappingURL=version.d.ts.map
@@ -1,5 +1,5 @@
1
1
  /**
2
2
  * SDK version constant. Keep synchronized with the workspace package versions.
3
3
  */
4
- export const SDK_VERSION = "10.19.8";
4
+ export const SDK_VERSION = "10.22.0";
5
5
  //# sourceMappingURL=version.js.map
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@polderlabs/bizar-sdk",
3
- "version": "10.20.1",
3
+ "version": "10.22.0",
4
4
  "type": "module",
5
5
  "main": "./dist/index.js",
6
6
  "types": "./dist/index.d.ts",