@polderlabs/bizar 10.23.9 → 10.23.11

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.
@@ -716,9 +716,8 @@ export function classifyKind(modelId) {
716
716
  // in-code hardcoded list — adding or removing a blocked provider is a
717
717
  // single JSON edit.
718
718
  //
719
- // Dual-path read: the Bizar path (`~/.config/bizar/config/claude/model-router.json`)
720
- // WINS when both exist, including an explicit `[]` (operators may pin
721
- // "no providers disabled" without deleting the legacy mirror). Whitespace
719
+ // Global-only read: the Bizar path (`~/.config/bizar/config/claude/model-router.json`)
720
+ // is the sole operator policy source. Whitespace
722
721
  // trim + lowercase normalization happens at read time so operators may
723
722
  // write `" Anthropic "` in JSON and still match `anthropic/...` model
724
723
  // ids. The comparison itself is a case-sensitive prefix filter against
@@ -758,44 +757,28 @@ function extractDisabledProviders(router) {
758
757
 
759
758
  /**
760
759
  * Read the operator's `disabledProviders` list from the model-router
761
- * config. Dual-path: the Bizar path wins when present (even with an
762
- * explicit empty array); the legacy `~/.claude/model-router.json` mirror
763
- * is the fallback. Both paths are normalized (trim + lowercase) at read.
760
+ * config. The Bizar global path is the only source. It is normalized (trim +
761
+ * lowercase) at read.
764
762
  *
765
763
  * Pure function over the filesystem; returns an empty array when neither
766
- * file exists, when both files lack the key, or when both reads fail.
764
+ * file exists, lacks the key, or cannot be read.
767
765
  *
768
- * @param {{ routerPath?: string, legacyPath?: string }} [opts]
766
+ * @param {{ routerPath?: string }} [opts]
769
767
  * - `routerPath` defaults to the Bizar home path
770
768
  * (`~/.config/bizar/config/claude/model-router.json`).
771
- * - `legacyPath` defaults to the Claude Code mirror
772
- * (`~/.claude/model-router.json`).
773
769
  * @returns {string[]} normalized disabled-provider prefixes
774
770
  */
775
- export function readDisabledProviders({ routerPath, legacyPath } = {}) {
771
+ export function readDisabledProviders({ routerPath } = {}) {
776
772
  const bizarPath = routerPath || resolveGlobalModelRouter();
777
- const fallPath = legacyPath || join(resolveClaudeConfigDir(), 'model-router.json');
778
773
  if (existsSync(bizarPath)) {
779
774
  try {
780
775
  const parsed = JSON.parse(readFileSync(bizarPath, 'utf8'));
781
776
  const extracted = extractDisabledProviders(parsed);
782
- // Bizar path exists its `disabledProviders` is authoritative even
783
- // when explicitly `[]` (operators may pin "no providers disabled"
784
- // without deleting the legacy mirror). Missing key still falls back.
777
+ // An explicit empty list is a valid global opt-out policy.
785
778
  if (Array.isArray(parsed && typeof parsed === 'object' ? parsed.disabledProviders : undefined)) {
786
779
  return extracted;
787
780
  }
788
- } catch {
789
- // Corrupt Bizar file — fall through to the legacy mirror.
790
- }
791
- }
792
- if (existsSync(fallPath)) {
793
- try {
794
- const parsed = JSON.parse(readFileSync(fallPath, 'utf8'));
795
- return extractDisabledProviders(parsed);
796
- } catch {
797
- return [];
798
- }
781
+ } catch { return []; }
799
782
  }
800
783
  return [];
801
784
  }
@@ -5,7 +5,7 @@
5
5
  * `bizar tier` — automatic task-based model selection.
6
6
  *
7
7
  * Replays the same tier resolution the @mike orchestrator uses:
8
- * 1. Read the role default from ~/.claude/model-router.json.
8
+ * 1. Read the role default from the global Bizar model router.
9
9
  * 2. Adjust the tier from current task risk and complexity.
10
10
  * 3. Print ordered candidates; dispatch uses a candidate only when live
11
11
  * discovery proves it, otherwise Agent omits model and inherits the session.
@@ -42,13 +42,11 @@ const CLAUDE_DIR = resolveClaudeConfigDir();
42
42
  const SETTINGS_PATH = join(CLAUDE_DIR, 'settings.json');
43
43
  // 10.22.0 / Phase 4 spirit-of-constraint: the install model id comes
44
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.
45
+ // The Bizar global router is the sole model-policy source.
47
46
  const BIZAR_ROUTER_PATH = resolveGlobalModelRouter();
48
- const LEGACY_ROUTER_PATH = join(CLAUDE_DIR, 'model-router.json');
49
47
 
50
48
  function readConfiguredModels() {
51
- for (const path of [BIZAR_ROUTER_PATH, LEGACY_ROUTER_PATH]) {
49
+ for (const path of [BIZAR_ROUTER_PATH]) {
52
50
  if (!existsSync(path)) continue;
53
51
  try {
54
52
  const parsed = JSON.parse(readFileSync(path, 'utf8'));
@@ -58,8 +56,7 @@ function readConfiguredModels() {
58
56
  if (list.length > 0) return list;
59
57
  const fallback = configuredFallbackModels(parsed);
60
58
  if (fallback.length > 0) return fallback;
61
- // Bizar path exists with an explicit empty `userSelected.models` —
62
- // honour that intent (do NOT fall through to the legacy mirror).
59
+ // An explicit empty selection is an intentional global policy.
63
60
  if (path === BIZAR_ROUTER_PATH && parsed && typeof parsed === 'object'
64
61
  && Array.isArray(parsed.userSelected?.models)) {
65
62
  return [];
package/cli/provision.mjs CHANGED
@@ -31,7 +31,7 @@ import {
31
31
  import { homedir } from 'node:os';
32
32
  import { dirname, join, resolve, sep } from 'node:path';
33
33
  import { fileURLToPath } from 'node:url';
34
- import { resolveBizarHome } from './config-paths.mjs';
34
+ import { resolveBizarHome, resolveGlobalModelRouter } from './config-paths.mjs';
35
35
  import {
36
36
  buildClaudeModelOverrides,
37
37
  configuredEnabledModels,
@@ -465,9 +465,20 @@ export async function syncAgentFiles({ dryRun = false, force = false } = {}) {
465
465
  return { ok: true, message: `${copied} agent(s) synced (${skipped} kept)${sharedCopied ? `, ${sharedCopied} _shared/*.md` : ''}${tail}`, copied, skipped, pruned };
466
466
  }
467
467
 
468
- // The dynamic model router lives at config/claude/model-router.json and is
469
- // copied to user-level `$CLAUDE_CONFIG_DIR/model-router.json` so Mike and the
470
- // SDK can select tiers without a repository cwd dependency.
468
+ // The dynamic model router is operator-owned state. It is created only under
469
+ // Bizar's global config root; a repository can never supply a runtime model.
470
+
471
+ export const EMPTY_GLOBAL_MODEL_ROUTER = {
472
+ $schema: 'https://bizar.dev/schema/model-router.v3.json',
473
+ version: '13.0.0',
474
+ endpoint: null,
475
+ disabledProviders: ['anthropic'],
476
+ userSelected: { models: [], lastUpdated: null, source: 'default-empty', tierHints: {} },
477
+ gateway: { required: false, endpoint: null, availabilityProbe: '/models', discoveryTimeoutMs: 3000, unavailableBehavior: 'configured-tier-fallback' },
478
+ tiers: Object.fromEntries(['premium', 'high', 'mid-design', 'default', 'mid', 'budget'].map((name) => [name, { models: [] }])),
479
+ roleDefaults: { mike: 'premium', paul: 'premium', carl: 'premium', karen: 'high', linda: 'high', ria: 'mid-design', greg: 'default', steve: 'default', oscar: 'mid', todd: 'mid', susan: 'mid', pam: 'budget', brenda: 'budget', janet: 'budget', kevin: 'budget', brad: 'mid-design' },
480
+ policies: { mainOrchestrator: 'mike', selectionOwner: 'orchestrator', dispatchModelOverride: 'required-configured-candidate', unknownAgent: 'configured-tier-fallback', discoveryFailure: 'configured-tier-fallback', unavailableModel: 'configured-tier-fallback', silentProviderSubstitution: false, retryModelAliases: false, snapshotDecisions: true, maxDispatchModelAttempts: 1 },
481
+ };
471
482
 
472
483
  export function isBizarManagedModelRouter(value) {
473
484
  return Boolean(
@@ -479,16 +490,14 @@ export function isBizarManagedModelRouter(value) {
479
490
  }
480
491
 
481
492
  export async function syncModelRouter({ dryRun = false, force = false } = {}) {
482
- const src = join(REPO_ROOT, 'config', 'claude', 'model-router.json');
483
- const dest = join(CLAUDE_DIR, 'model-router.json');
484
- if (!existsSync(src)) return { ok: true, message: `no model-router at ${src}` };
485
- if (existsSync(dest) && !force && !isBizarManagedModelRouter(readJsonSafe(dest, null))) {
486
- return { ok: true, message: `${dest} is user-managed — pass --force to overwrite`, preserved: true };
493
+ const dest = resolveGlobalModelRouter();
494
+ if (existsSync(dest)) {
495
+ return { ok: true, message: `${dest} is operator-managed preserved`, preserved: true };
487
496
  }
488
- if (dryRun) return { ok: true, message: `[dry-run] would copy ${src} ${dest}` };
489
- ensureDir(CLAUDE_DIR);
490
- copyFileSync(src, dest);
491
- return { ok: true, message: `model-router.json → ${dest}`, path: dest };
497
+ if (dryRun) return { ok: true, message: `[dry-run] would create global model router at ${dest}` };
498
+ ensureDir(dirname(dest));
499
+ writeFileSync(dest, `${JSON.stringify(EMPTY_GLOBAL_MODEL_ROUTER, null, 2)}\n`);
500
+ return { ok: true, message: `global model-router.json → ${dest}`, path: dest };
492
501
  }
493
502
 
494
503
  export async function syncSkillFiles({ dryRun = false, force = false } = {}) {
@@ -949,16 +958,12 @@ export function writeClaudeSettings({ dryRun = false, force = false } = {}) {
949
958
  // wins; otherwise the first enabled configured tier candidate is used.
950
959
  // This prevents Claude Code from falling through to an unconfigured
951
960
  // provider default when `userSelected.models` is intentionally empty.
952
- // Dual-path read matches
953
- // `cli/commands/models.mjs#readDisabledProviders` and
954
- // `cli/commands/upgrade-defaults.mjs#readUserSelectedModels`.
955
- const bizarRouterPath = process.env.BIZAR_MODEL_ROUTER_CONFIG?.trim()
956
- || join(BIZAR_HOME(), 'config', 'claude', 'model-router.json');
957
- const legacyRouterPath = join(CLAUDE_DIR, 'model-router.json');
961
+ // The Bizar global router is the sole source of model policy and picks.
962
+ const bizarRouterPath = resolveGlobalModelRouter();
958
963
  let installModel;
959
964
  let installContextTokens;
960
965
  let installModels = [];
961
- for (const p of [bizarRouterPath, legacyRouterPath]) {
966
+ for (const p of [bizarRouterPath]) {
962
967
  if (!existsSync(p)) continue;
963
968
  const parsed = readJsonSafe(p, null);
964
969
  if (!parsed || typeof parsed !== 'object') continue;
@@ -966,8 +971,6 @@ export function writeClaudeSettings({ dryRun = false, force = false } = {}) {
966
971
  installModels = configuredEnabledModels(parsed);
967
972
  installContextTokens = installModel ? configuredModelContextTokens(parsed, installModel) : undefined;
968
973
  if (installModel) break;
969
- // The Bizar path is authoritative even when it has no enabled candidates.
970
- if (p === bizarRouterPath) break;
971
974
  }
972
975
  if (installModel) {
973
976
  bizarSettings.model = installModel;
@@ -1177,16 +1180,16 @@ export async function runProvision(opts = {}) {
1177
1180
  await runStep('Installing git hooks', () => installGitHooks({ dryRun }));
1178
1181
  await runStep('Building SDK', () => buildSdk({ dryRun }));
1179
1182
 
1183
+ section('Ensuring global model-router.json');
1184
+ const routerStep = await syncModelRouter({ dryRun, force });
1185
+ if (routerStep.ok) logOk(routerStep.message); else logErr(routerStep.message);
1186
+ stepResults.push({ label: 'model-router', ...routerStep });
1187
+
1180
1188
  section('Writing settings.json');
1181
1189
  const settingsStep = writeClaudeSettings({ dryRun, force });
1182
1190
  if (settingsStep.ok) logOk(settingsStep.message); else logErr(settingsStep.message);
1183
1191
  stepResults.push({ label: 'settings.json', ...settingsStep });
1184
1192
 
1185
- section('Syncing model-router.json');
1186
- const routerStep = await syncModelRouter({ dryRun, force });
1187
- if (routerStep.ok) logOk(routerStep.message); else logErr(routerStep.message);
1188
- stepResults.push({ label: 'model-router', ...routerStep });
1189
-
1190
1193
  section('MCP server (bizar)');
1191
1194
  const mcpStep = setupMcpServer({ dryRun });
1192
1195
  if (mcpStep.ok) logOk(mcpStep.message); else logWarn(mcpStep.message);
@@ -1221,7 +1224,7 @@ export async function runProvision(opts = {}) {
1221
1224
  // back to a clear hint to run `bizar models`.
1222
1225
  let premiumPick = null;
1223
1226
  try {
1224
- const routerPath = join(BIZAR_HOME(), 'config', 'claude', 'model-router.json');
1227
+ const routerPath = resolveGlobalModelRouter();
1225
1228
  const router = JSON.parse(readFileSync(routerPath, 'utf8'));
1226
1229
  const picks = Array.isArray(router?.userSelected?.tierHints?.premium)
1227
1230
  ? router.userSelected.tierHints.premium.filter((id) => typeof id === 'string' && id)
@@ -8,11 +8,12 @@
8
8
  */
9
9
  import { createHash } from 'node:crypto';
10
10
  import { readFileSync } from 'node:fs';
11
- import { resolve } from 'node:path';
12
- import { fileURLToPath } from 'node:url';
11
+ import { resolveGlobalModelRouter } from '../../cli/config-paths.mjs';
13
12
 
14
- const ROOT = resolve(fileURLToPath(new URL('../..', import.meta.url)));
15
- export const DEFAULT_MODEL_ROUTER_PATH = resolve(ROOT, 'config', 'claude', 'model-router.json');
13
+ /** Resolve the single operator-owned router at call time, never from a repo. */
14
+ export function defaultModelRouterPath() {
15
+ return resolveGlobalModelRouter();
16
+ }
16
17
 
17
18
  function fail(code, message) {
18
19
  const error = new Error(message);
@@ -39,9 +40,6 @@ function assertDynamicRegistry(registry) {
39
40
  if (!registry.tiers || typeof registry.tiers !== 'object' || Object.keys(registry.tiers).length === 0) {
40
41
  fail('MODEL_REGISTRY_INVALID', 'The model router must define at least one dynamic tier.');
41
42
  }
42
- for (const [name, tier] of Object.entries(registry.tiers)) {
43
- if (normalizeModelIds(tier?.models).length === 0) fail('MODEL_REGISTRY_INVALID', `Tier ${name} has no candidate models.`);
44
- }
45
43
  const policies = registry.policies || {};
46
44
  if (policies.selectionOwner !== 'orchestrator') fail('MODEL_POLICY_INVALID', 'The orchestrator must own model selection.');
47
45
  if (!['configured-tier-fallback', 'inherit-session'].includes(policies.discoveryFailure)
@@ -54,7 +52,7 @@ function assertDynamicRegistry(registry) {
54
52
  return registry;
55
53
  }
56
54
 
57
- export function loadModelRouter(path = DEFAULT_MODEL_ROUTER_PATH) {
55
+ export function loadModelRouter(path = defaultModelRouterPath()) {
58
56
  try {
59
57
  return assertDynamicRegistry(JSON.parse(readFileSync(path, 'utf8')));
60
58
  } catch (error) {
@@ -73,7 +71,16 @@ export function resolveDispatchModel({
73
71
  const definition = registry.tiers?.[chosenTier];
74
72
  if (!definition) fail('UNKNOWN_TIER', `Unknown model tier ${chosenTier}.`);
75
73
  const disabled = new Set(normalizeModelIds(registry.disabledProviders).map((prefix) => prefix.toLowerCase()));
76
- const candidates = normalizeModelIds(definition.models)
74
+ const selected = normalizeModelIds(registry.userSelected?.models)
75
+ .filter((candidate) => ![...disabled].some((prefix) => candidate.toLowerCase().startsWith(prefix)));
76
+ const hints = registry.userSelected?.tierHints && typeof registry.userSelected.tierHints === 'object'
77
+ ? registry.userSelected.tierHints
78
+ : {};
79
+ // The operator's picker is authoritative. A matching tier hint narrows the
80
+ // pool; otherwise all enabled picks remain eligible rather than silently
81
+ // falling back to repository-supplied candidates.
82
+ const hinted = selected.filter((candidate) => hints[candidate] === chosenTier);
83
+ const candidates = (hinted.length > 0 ? hinted : selected.length > 0 ? selected : normalizeModelIds(definition.models))
77
84
  .filter((candidate) => ![...disabled].some((prefix) => candidate.toLowerCase().startsWith(prefix)));
78
85
  if (candidates.length === 0) fail('NO_ENABLED_TIER_MODEL', `Tier ${chosenTier} has no enabled candidate models.`);
79
86
  const available = availableModelIds == null ? null : new Set(normalizeModelIds(availableModelIds));
@@ -43,7 +43,8 @@ Every plan you produce MUST follow this shape. The user (or Mike, or any downstr
43
43
 
44
44
  ## Subagent Model Selection
45
45
 
46
- When you delegate implementation, you recommend the model tier. Read `.claude/model-router.json` to confirm.
46
+ When you delegate implementation, you recommend the model tier. Read the
47
+ operator-global Bizar router (`$BIZAR_HOME/config/claude/model-router.json`) to confirm.
47
48
 
48
49
  | Task shape | Route to | Tier |
49
50
  |---|---|---|
@@ -44,9 +44,11 @@
44
44
  */
45
45
 
46
46
  import { readFileSync } from 'node:fs';
47
+ import { join } from 'node:path';
47
48
  import { pathToFileURL } from 'node:url';
48
49
 
49
50
  import { loadModelRouter } from '../../../config/agents/model-assignment.mjs';
51
+ import { resolveClaudeConfigDir } from '../../../cli/config-paths.mjs';
50
52
 
51
53
  function deny(reason) {
52
54
  return {
@@ -152,6 +154,17 @@ function readFailoverBlock(toolInput) {
152
154
  };
153
155
  }
154
156
 
157
+ function readConfiguredParentModel(options = {}) {
158
+ if (typeof options.parentModel === 'string') return options.parentModel.trim();
159
+ const settingsPath = options.settingsPath || join(resolveClaudeConfigDir(), 'settings.json');
160
+ try {
161
+ const settings = JSON.parse(readFileSync(settingsPath, 'utf8'));
162
+ return typeof settings?.model === 'string' ? settings.model.trim() : '';
163
+ } catch {
164
+ return '';
165
+ }
166
+ }
167
+
155
168
  export async function guardAgentModel(input, options = {}) {
156
169
  if (!input || typeof input !== 'object') return {};
157
170
  if (input.hook_event_name !== 'PreToolUse' || input.tool_name !== 'Agent') return {};
@@ -160,10 +173,6 @@ export async function guardAgentModel(input, options = {}) {
160
173
  const failoverBlock = readFailoverBlock(toolInput);
161
174
  const hasFailoverContract = Boolean(failoverBlock.routingDecisionId) && Boolean(failoverBlock.fallback);
162
175
 
163
- if (!requested || requested === 'inherit') {
164
- return deny('Bizar Agent dispatch blocked: every Agent call requires an explicit enabled model from `bizar models`; session/provider inheritance is prohibited.');
165
- }
166
-
167
176
  let registry;
168
177
  try {
169
178
  registry = options.registry || loadModelRouter(options.routerPath);
@@ -181,6 +190,20 @@ export async function guardAgentModel(input, options = {}) {
181
190
  const allowed = configuredModels(registry);
182
191
  const userPicks = userSelectedModels(registry);
183
192
 
193
+ // The native Agent tool rejects arbitrary gateway IDs in its `model` enum.
194
+ // Inheritance is safe only when the auditable Bizar selection equals the
195
+ // actual global Claude parent model and is a selected, enabled user pick.
196
+ if (!requested || requested === 'inherit') {
197
+ const configured = typeof toolInput.additionalContext?.bizarConfiguredModel === 'string'
198
+ ? toolInput.additionalContext.bizarConfiguredModel.trim()
199
+ : '';
200
+ const parent = readConfiguredParentModel(options);
201
+ if (!configured || configured !== parent || !userPicks.has(configured)) {
202
+ return deny('Bizar Agent dispatch blocked: native Agent inheritance requires the global Claude parent model to exactly match an enabled Bizar user selection. Run `bizar models` and restart Claude Code.');
203
+ }
204
+ return {};
205
+ }
206
+
184
207
  // F-185 contract: when the orchestrator passes both `routingDecisionId`
185
208
  // and `fallback`, validate the fallback against the userSelected pool
186
209
  // but skip the live-discovery re-probe. The fallback's eligibility was
@@ -61,16 +61,12 @@ function readRouterPath() {
61
61
 
62
62
  /**
63
63
  * 10.22.0 / Phase 4: read the operator's `disabledProviders: string[]`
64
- * list with the same dual-path contract as `cli/commands/models.mjs`.
65
- * The Bizar path (`~/.config/bizar/config/claude/model-router.json`) wins
66
- * when both exist (even with explicit `[]`); the legacy
67
- * `~/.claude/model-router.json` mirror is the fallback. Whitespace +
68
- * lowercase normalization happens here. Returns `[]` on any failure —
64
+ * list from the sole global Bizar router. Whitespace + lowercase
65
+ * normalization happens here. Returns `[]` on any failure —
69
66
  * the hook is advisory and must NEVER block session start.
70
67
  */
71
68
  function readDisabledProviders() {
72
69
  const bizarPath = readRouterPath();
73
- const legacyPath = join(resolveClaudeConfigDir(), 'model-router.json');
74
70
  const extract = (parsed) => {
75
71
  if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) return null;
76
72
  if (!Array.isArray(parsed.disabledProviders)) return null;
@@ -85,18 +81,12 @@ function readDisabledProviders() {
85
81
  };
86
82
  if (existsSync(bizarPath)) {
87
83
  const parsed = readJsonIfObject(bizarPath);
88
- // Bizar path exists its `disabledProviders` is authoritative even
89
- // when explicit `[]`. Missing key still falls back to the legacy.
84
+ // An explicit empty global list is a valid operator policy.
90
85
  if (parsed && Array.isArray(parsed.disabledProviders)) {
91
86
  const extracted = extract(parsed);
92
87
  if (extracted !== null) return extracted;
93
88
  }
94
89
  }
95
- const legacy = readJsonIfObject(legacyPath);
96
- if (legacy && Array.isArray(legacy.disabledProviders)) {
97
- const extracted = extract(legacy);
98
- if (extracted !== null) return extracted;
99
- }
100
90
  return [];
101
91
  }
102
92
 
@@ -193,8 +183,7 @@ function syncOnce() {
193
183
  : {};
194
184
  // 10.22.0 / Phase 4: filter the operator's disabled-provider ids out
195
185
  // of the SessionStart re-apply so Claude Code's `/model` picker never
196
- // surfaces e.g. `anthropic/*` after a session restart. Dual-path read
197
- // matches `cli/commands/models.mjs#readDisabledProviders`.
186
+ // surfaces e.g. `anthropic/*` after a session restart.
198
187
  const disabled = readDisabledProviders();
199
188
  const selectedIds = filterDisabled(
200
189
  models.filter((id) => typeof id === 'string' && id.trim()),
@@ -793,15 +793,17 @@ export function classifyDispatchOutcome(result, error, startMs) {
793
793
  }
794
794
 
795
795
  /**
796
- * Build the augmented agent-call payload. Adds `model`,
797
- * `routingDecisionId`, `tier`, and `selectorReason` so the Agent
798
- * tool can route correctly and the audit trail can prove which
799
- * selector step fired.
796
+ * Build the augmented agent-call payload. The native Agent model field cannot
797
+ * carry arbitrary gateway IDs, so the selected Bizar ID is audit context and
798
+ * the guard permits inheritance only from an identical configured parent.
800
799
  */
801
800
  export function augmentPayload(opts, decision, agentName) {
802
801
  return {
803
802
  ...opts,
804
- model: decision.modelId ?? undefined,
803
+ additionalContext: {
804
+ ...(opts.additionalContext && typeof opts.additionalContext === 'object' ? opts.additionalContext : {}),
805
+ bizarConfiguredModel: decision.modelId ?? null,
806
+ },
805
807
  routingDecisionId: decision.routingDecisionId,
806
808
  tier: decision.tier,
807
809
  selectorReason: decision.reason,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@polderlabs/bizar",
3
- "version": "10.23.9",
3
+ "version": "10.23.11",
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": {
@@ -25,7 +25,6 @@
25
25
  "config/claude/hooks/*.mjs",
26
26
  "config/claude/hooks/*.sh",
27
27
  "config/claude/hooks/README.md",
28
- "config/claude/model-router.json",
29
28
  "config/claude/settings.json",
30
29
  "scripts/git-hooks/commit-msg",
31
30
  "scripts/git-hooks/pre-commit",
@@ -385,7 +385,7 @@ const bizarAuditTool = defineTool("bizar_audit", "Wrapper around `bizar audit --
385
385
  }
386
386
  }, { readOnlyHint: true });
387
387
  // `bizar model list --json` — gateway model inventory.
388
- const bizarModelListTool = defineTool("bizar_model_list", "Wrapper around `bizar models --json`. Returns ONLY the models the user has explicitly enabled via `bizar models` (the `userSelected` block of `config/claude/model-router.json`). Live discovery is filtered out by default to avoid surfacing dozens of unrelated models. Empty list = orchestrator inherits the active session model.", {}, async () => {
388
+ const bizarModelListTool = defineTool("bizar_model_list", "Wrapper around `bizar models --json`. Returns ONLY the models the user has explicitly enabled via `bizar models` in the global Bizar router (`$BIZAR_HOME/config/claude/model-router.json#userSelected`). Live discovery is filtered out by default to avoid surfacing dozens of unrelated models. An empty list means no agent dispatch is permitted until the operator configures models.", {}, async () => {
389
389
  try {
390
390
  // Prefer the new `bizar models` surface; fall back to the legacy
391
391
  // `bizar model list` alias for older installs. The CLI is responsible
@@ -225,8 +225,13 @@ export declare function rankUserSelectedForRole(registry: ModelRegistry, role: s
225
225
  };
226
226
  /**
227
227
  * Pure sorter extracted for testability. Mutates and returns the input
228
- * array. Sort key: `(eligible desc, capabilityScore desc, hasProfile desc,
229
- * originalIndex asc)`.
228
+ * array. Sort key: `(eligible desc, originalIndex asc, capabilityScore desc,
229
+ * hasProfile desc)`. The `originalIndex` primary sort key honours the
230
+ * operator's `userSelected.models` ordering as the authoritative pick
231
+ * sequence — the same contract as the CLI's `resolveDispatchModel`. The
232
+ * capability score is a secondary tiebreaker for entries that share the
233
+ * same `originalIndex` position (i.e., when a profile lookup changes
234
+ * eligibility status without reordering).
230
235
  */
231
236
  export declare function compareRankedEntries(a: RankedUserSelectedEntry, b: RankedUserSelectedEntry): number;
232
237
  /**
@@ -85,8 +85,6 @@ export function loadModelRegistry(src = {}) {
85
85
  if (!isRecord(value))
86
86
  registryError("CONFIG_INVALID", `Tier ${name} is invalid.`);
87
87
  const ids = modelIds(value.models);
88
- if (ids.length === 0)
89
- registryError("CONFIG_INVALID", `Tier ${name} has no candidate models.`);
90
88
  tiers.set(name, { tier: name, modelIds: ids, purpose: String(value.purpose || ""), effort: typeof value.effort === "string" ? value.effort : null });
91
89
  }
92
90
  const roleDefaults = new Map();
@@ -98,6 +96,25 @@ export function loadModelRegistry(src = {}) {
98
96
  const configuredEndpoint = typeof raw.endpoint === "string" ? raw.endpoint : typeof gateway.endpoint === "string" ? gateway.endpoint : null;
99
97
  const endpoint = process.env.BIZAR_MODEL_ROUTER_URL ?? process.env.ANTHROPIC_BASE_URL ?? configuredEndpoint;
100
98
  const userSelected = parseUserSelected(raw.userSelected);
99
+ // Empty tier lists are tolerated when the operator's `userSelected`
100
+ // pool is non-empty: the runtime resolver (resolveTierModel) prefers
101
+ // the operator's authoritative picks and only falls through to a
102
+ // tier's static model list when no user pick matches. The CLI mirror
103
+ // (config/agents/model-assignment.mjs#resolveDispatchModel) applies
104
+ // the same rule; this keeps SDK and CLI symmetric on the canonical
105
+ // template, which ships empty tier lists by design (F-201 follow-up:
106
+ // remove every shipped provider/model default). When `userSelected`
107
+ // is also empty, every tier must still carry at least one candidate
108
+ // so the resolver never dispatches with no model at all.
109
+ if (!(userSelected && userSelected.models.length > 0)) {
110
+ for (const [name, value] of Object.entries(raw.tiers)) {
111
+ if (!isRecord(value))
112
+ registryError("CONFIG_INVALID", `Tier ${name} is invalid.`);
113
+ const ids = modelIds(value.models);
114
+ if (ids.length === 0)
115
+ registryError("CONFIG_INVALID", `Tier ${name} has no candidate models.`);
116
+ }
117
+ }
101
118
  return { version: String(raw.version || "unknown"), endpoint, configuredEndpoint, gateway, policies: raw.policies, tiers, roleDefaults, ...(userSelected ? { userSelected } : {}) };
102
119
  }
103
120
  function parseUserSelected(raw) {
@@ -298,24 +315,35 @@ export function rankUserSelectedForRole(registry, role, requirements = {}) {
298
315
  return entry;
299
316
  });
300
317
  ranked.sort(compareRankedEntries);
301
- const eligible = ranked.filter((entry) => entry.eligible);
302
- // role is consumed only for future per-role filtering / logging hooks.
303
- void role;
318
+ // Tier-aware filtering (parity with the CLI's
319
+ // resolveDispatchModel#hinted): when the caller asks for a specific
320
+ // tier (the common dispatch path passes the agent's role-default
321
+ // tier), narrow `eligible` to entries whose tier hint matches. When
322
+ // no entry matches the requested tier, fall back to the full eligible
323
+ // ranking so a stale tierHints map never strands a dispatch with no
324
+ // candidates at all — same fail-open shape as the CLI.
325
+ const tierMatched = role ? ranked.filter((entry) => entry.eligible && entry.tier === role) : ranked.filter((entry) => entry.eligible);
326
+ const eligible = tierMatched.length > 0 ? tierMatched : ranked.filter((entry) => entry.eligible);
304
327
  return { ranked, eligible };
305
328
  }
306
329
  /**
307
330
  * Pure sorter extracted for testability. Mutates and returns the input
308
- * array. Sort key: `(eligible desc, capabilityScore desc, hasProfile desc,
309
- * originalIndex asc)`.
331
+ * array. Sort key: `(eligible desc, originalIndex asc, capabilityScore desc,
332
+ * hasProfile desc)`. The `originalIndex` primary sort key honours the
333
+ * operator's `userSelected.models` ordering as the authoritative pick
334
+ * sequence — the same contract as the CLI's `resolveDispatchModel`. The
335
+ * capability score is a secondary tiebreaker for entries that share the
336
+ * same `originalIndex` position (i.e., when a profile lookup changes
337
+ * eligibility status without reordering).
310
338
  */
311
339
  export function compareRankedEntries(a, b) {
312
340
  if (a.eligible !== b.eligible)
313
341
  return a.eligible ? -1 : 1;
342
+ if (a.originalIndex !== b.originalIndex)
343
+ return a.originalIndex - b.originalIndex;
314
344
  if (a.capabilityScore !== b.capabilityScore)
315
345
  return b.capabilityScore - a.capabilityScore;
316
- if (a.hasProfile !== b.hasProfile)
317
- return a.hasProfile ? -1 : 1;
318
- return a.originalIndex - b.originalIndex;
346
+ return a.hasProfile !== b.hasProfile ? (a.hasProfile ? -1 : 1) : 0;
319
347
  }
320
348
  /**
321
349
  * Weighted capability score: reasoning=0.3, toolCall=0.25,
@@ -175,13 +175,17 @@ export function rankUserSelectedForRole(registry, role, requirements = {}) {
175
175
  });
176
176
  ranked.sort((a, b) => {
177
177
  if (a.eligible !== b.eligible) return a.eligible ? -1 : 1;
178
+ if (a.originalIndex !== b.originalIndex) return a.originalIndex - b.originalIndex;
178
179
  if (a.capabilityScore !== b.capabilityScore) return b.capabilityScore - a.capabilityScore;
179
- if (a.hasProfile !== b.hasProfile) return a.hasProfile ? -1 : 1;
180
- return a.originalIndex - b.originalIndex;
180
+ return a.hasProfile !== b.hasProfile ? (a.hasProfile ? -1 : 1) : 0;
181
181
  });
182
- const eligible = ranked.filter((entry) => entry.eligible);
183
- // role is consumed only for future per-role filtering / logging hooks.
184
- void role;
182
+ // Tier-aware filtering (parity with the CLI's
183
+ // resolveDispatchModel#hinted): narrow `eligible` to entries whose
184
+ // tier hint matches the requested tier; fall back to the full eligible
185
+ // ranking when no tier-matched candidate exists so a stale tierHints
186
+ // map never strands a dispatch with no candidates at all.
187
+ const tierMatched = role ? ranked.filter((entry) => entry.eligible && entry.tier === role) : ranked.filter((entry) => entry.eligible);
188
+ const eligible = tierMatched.length > 0 ? tierMatched : ranked.filter((entry) => entry.eligible);
185
189
  return { ranked, eligible };
186
190
  }
187
191
 
@@ -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.23.9";
4
+ export declare const SDK_VERSION: "10.23.11";
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.23.9";
4
+ export const SDK_VERSION = "10.23.11";
5
5
  //# sourceMappingURL=version.js.map
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@polderlabs/bizar-sdk",
3
- "version": "10.23.9",
3
+ "version": "10.23.11",
4
4
  "type": "module",
5
5
  "main": "./dist/index.js",
6
6
  "types": "./dist/index.d.ts",
@@ -1,110 +0,0 @@
1
- {
2
- "$schema": "https://bizar.dev/schema/model-router.v3.json",
3
- "version": "13.0.0",
4
- "endpoint": null,
5
- "disabledProviders": ["anthropic"],
6
- "comment": "Mike selects the cheapest sufficient enabled operator-configured model per dispatch. Explicit user-selected models take precedence; empty shipped tiers ensure a fresh install never silently chooses a vendor or model. The orchestrator always supplies an Agent model and fails closed when no enabled configured candidate exists, so Claude Code cannot select an unconfigured provider default. 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 model. `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). Anthropic is opted out by default; operators may remove that entry or opt out of additional providers.",
7
- "userSelected": {
8
- "models": [],
9
- "lastUpdated": null,
10
- "source": "default-empty",
11
- "tierHints": {}
12
- },
13
- "gateway": {
14
- "required": false,
15
- "endpoint": null,
16
- "availabilityProbe": "/models",
17
- "discoveryTimeoutMs": 3000,
18
- "unavailableBehavior": "configured-tier-fallback",
19
- "documentation": "https://code.claude.com/docs/en/llm-gateway"
20
- },
21
- "tiers": {
22
- "premium": {
23
- "models": [],
24
- "purpose": "Architecture, high-risk planning, adversarial verification, and last-resort debugging where mistakes are expensive.",
25
- "effort": "high"
26
- },
27
- "high": {
28
- "models": [],
29
- "purpose": "Complex implementation, security review, and cross-cutting reasoning.",
30
- "effort": "high"
31
- },
32
- "mid-design": {
33
- "models": [],
34
- "purpose": "UI/UX design and product-quality implementation where visual judgment matters.",
35
- "effort": "medium"
36
- },
37
- "default": {
38
- "models": [],
39
- "purpose": "Broad research, git operations, and ordinary engineering work.",
40
- "effort": "medium"
41
- },
42
- "mid": {
43
- "models": [],
44
- "purpose": "Bounded technical analysis, code search, and moderate implementation.",
45
- "effort": "medium"
46
- },
47
- "budget": {
48
- "models": [],
49
- "purpose": "Routine deterministic edits, targeted clarification, and scripted verification.",
50
- "effort": "low"
51
- }
52
- },
53
- "roleDefaults": {
54
- "mike": "premium",
55
- "paul": "premium",
56
- "carl": "premium",
57
- "karen": "high",
58
- "linda": "high",
59
- "ria": "mid-design",
60
- "greg": "default",
61
- "steve": "default",
62
- "oscar": "mid",
63
- "todd": "mid",
64
- "susan": "mid",
65
- "pam": "budget",
66
- "brenda": "budget",
67
- "janet": "budget",
68
- "kevin": "budget",
69
- "brad": "mid-design"
70
- },
71
- "complexityRules": [
72
- {
73
- "when": "irreversible, security-sensitive, architectural, or previously failed twice",
74
- "tier": "premium"
75
- },
76
- {
77
- "when": "cross-cutting implementation or adversarial review",
78
- "tier": "high"
79
- },
80
- {
81
- "when": "UI or product design judgment dominates",
82
- "tier": "mid-design"
83
- },
84
- {
85
- "when": "bounded implementation or codebase research",
86
- "tier": "mid"
87
- },
88
- {
89
- "when": "deterministic, mechanical, or clarification-only",
90
- "tier": "budget"
91
- }
92
- ],
93
- "policies": {
94
- "mainOrchestrator": "mike",
95
- "selectionOwner": "orchestrator",
96
- "dispatchModelOverride": "required-configured-candidate",
97
- "unknownAgent": "configured-tier-fallback",
98
- "discoveryFailure": "configured-tier-fallback",
99
- "unavailableModel": "configured-tier-fallback",
100
- "silentProviderSubstitution": false,
101
- "retryModelAliases": false,
102
- "snapshotDecisions": true,
103
- "maxDispatchModelAttempts": 1
104
- },
105
- "costControls": {
106
- "maxConcurrentAgents": 10,
107
- "maxPremiumAgentsPerWorkflow": 3,
108
- "preferInheritanceWhenDiscoveryUnavailable": false
109
- }
110
- }