@kal-elsam/kairo-runtime 0.26.1 → 0.27.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/CHANGELOG.md CHANGED
@@ -5,6 +5,20 @@ Historical entries below may reference the legacy `@kal-elsam/harness` package n
5
5
 
6
6
  ## Unreleased
7
7
 
8
+ ## 0.27.0 — 2026-09-19 (Kairo Runtime)
9
+
10
+ Minor release. Claude model entitlement now gates PROJECT TEAM pools and routes.
11
+
12
+ ### Changed
13
+
14
+ - Denied Claude models (e.g. Fable 5.1 on Pro with `credits_required`) are
15
+ excluded from both the recommendation and automatic execution pools —
16
+ orthogonal to `accessMode`, so a denied model is never mislabeled "manual".
17
+ - Unverified Claude models stay recommendable but are never auto-launchable.
18
+ - Live entitlement also blocks a persisted project-team assignment whose
19
+ account access is denied or unverified; `snapshot()` still only reads the
20
+ disk cache (never probes).
21
+
8
22
  ## 0.26.1 — 2026-09-19 (Kairo Runtime)
9
23
 
10
24
  Patch release. Claude model entitlement probe + cache (no routing change yet).
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@kal-elsam/kairo-runtime",
3
- "version": "0.26.1",
3
+ "version": "0.27.0",
4
4
  "description": "Kairo Runtime — local agent operating system for Codex, Cursor, Claude, Pi, Engram, and Graphify.",
5
5
  "type": "module",
6
6
  "homepage": "https://github.com/Kal-elSam/harness#readme",
@@ -39,6 +39,7 @@ import {
39
39
  } from "./codex-sandbox.js";
40
40
  import { verifyClaudeSubscriptionAuth as defaultVerifyClaudeSubscriptionAuth } from "../runtime/execution-adapters/claude.js";
41
41
  import { readClaudeModels as defaultReadClaudeModels } from "../observability/claude-models.js";
42
+ import { ENTITLEMENT } from "../observability/claude-model-entitlement.js";
42
43
  import { readCursorModels as defaultReadCursorModels } from "../observability/cursor-models.js";
43
44
  import { probeCursorAuth as defaultProbeCursorAuth } from "../observability/cursor-auth.js";
44
45
  import {
@@ -114,6 +115,18 @@ export function createClaudeBootstrapAnalyzerAdapter({ modelId, deps = {} } = {}
114
115
  if (!known) {
115
116
  return { eligible: false, reason: `"${modelId}" is not in Claude's documented model catalog.`, isolation: "unverified", canaryTested: false };
116
117
  }
118
+ // Live per-model entitlement (when the caller supplies it) — denied
119
+ // siblings fail with the real CLI reason, never a silent pass after
120
+ // the documented-catalog check. Missing map/key does not invent denial.
121
+ const entitlement = deps.modelEntitlement?.[modelId];
122
+ if (entitlement?.status === ENTITLEMENT.DENIED) {
123
+ return {
124
+ eligible: false,
125
+ reason: entitlement.reason ?? `"${modelId}" is denied by Claude account entitlement.`,
126
+ isolation: "unverified",
127
+ canaryTested: false
128
+ };
129
+ }
117
130
  }
118
131
  return { eligible: true, isolation: "restricted", canaryTested: true };
119
132
  },
@@ -19,6 +19,8 @@
19
19
  // persisted, not recomputed) — this module only checks whether that
20
20
  // persisted candidate is STILL real-eligible right now.
21
21
 
22
+ import { ENTITLEMENT } from "../observability/claude-model-entitlement.js";
23
+
22
24
  /**
23
25
  * Whether Kairo can launch an automatic run against this real candidate
24
26
  * right now — reads model-candidate-catalog.js's own `accessMode`
@@ -39,16 +41,46 @@ export const PROJECT_ROUTE_DECISION = {
39
41
  WAIT_FOR_PROJECT_TEAM: "WAIT_FOR_PROJECT_TEAM"
40
42
  };
41
43
 
44
+ const ROUTABLE_ENTITLEMENTS = new Set([ENTITLEMENT.ALLOWED, ENTITLEMENT.NOT_APPLICABLE]);
45
+
42
46
  function assignmentRef(model, assignmentSource) {
43
47
  if (!model) return null;
44
48
  return { provider: model.adapterId, model, assignmentSource };
45
49
  }
46
50
 
47
- /** A real candidate is only ever offered as `suggestedAlternative` when it's currently automatically-executable — the same bar ROUTED itself requires. Never suggests another manual-only or currently-ineligible provider; honestly null instead. */
48
- function routableAlternative(model, eligibility) {
51
+ /**
52
+ * Live entitlement gate: only when `modelEntitlement[modelId]` is PRESENT
53
+ * and status is denied or unverified. Missing key (empty `{}`) keeps
54
+ * existing callers/tests green — no gate.
55
+ * @param {object} model
56
+ * @param {Record<string, {status?: string, reason?: string|null}>} modelEntitlement
57
+ * @returns {{status: string, reason: string|null}|null}
58
+ */
59
+ function blockingEntitlement(model, modelEntitlement) {
60
+ const entry = modelEntitlement?.[model?.modelId];
61
+ if (!entry || typeof entry !== "object") return null;
62
+ if (entry.status === ENTITLEMENT.DENIED || entry.status === ENTITLEMENT.UNVERIFIED) {
63
+ return {
64
+ status: entry.status,
65
+ reason: entry.reason == null ? null : String(entry.reason)
66
+ };
67
+ }
68
+ return null;
69
+ }
70
+
71
+ /**
72
+ * Fallback may only be suggested when it is currently automatically
73
+ * executable AND its entitlement is allowed/not_applicable — or the key
74
+ * is missing (default ok). Denied/unverified fallbacks stay null.
75
+ */
76
+ function routableAlternative(model, eligibility, modelEntitlement = {}) {
49
77
  if (!model) return null;
50
78
  if (!isAutomatic(model)) return null;
51
79
  if (eligibility[model.adapterId]?.ok !== true) return null;
80
+ const entry = modelEntitlement?.[model.modelId];
81
+ if (entry && typeof entry === "object" && !ROUTABLE_ENTITLEMENTS.has(entry.status)) {
82
+ return null;
83
+ }
52
84
  return { provider: model.adapterId, model };
53
85
  }
54
86
 
@@ -80,6 +112,10 @@ function blocked(role, strategyFingerprint, why, { blockedAssignment = null, sug
80
112
  * MANUAL_HANDOFF, still naming the real model/provider so the caller
81
113
  * can show a concrete handoff ("Continue in Cursor with <model>"),
82
114
  * never a bare "not supported".
115
+ * 3.5 Live model entitlement (when `modelEntitlement[modelId]` is present
116
+ * and status is denied or unverified) -> WAIT_FOR_PROJECT_TEAM with
117
+ * blockedAssignment naming the real CLI reason when available.
118
+ * Empty `{}` skips this gate.
83
119
  * 4. The assigned provider isn't currently eligible (quota/availability
84
120
  * changed since the strategy was approved) -> WAIT_FOR_PROJECT_TEAM,
85
121
  * with `blockedAssignment` naming the real unavailable model/reason and
@@ -103,9 +139,12 @@ function blocked(role, strategyFingerprint, why, { blockedAssignment = null, sug
103
139
  * @param {Record<string, {ok: boolean, reason?: string}>} [args.eligibility] -
104
140
  * CURRENT provider eligibility — may have changed since the strategy was
105
141
  * built/approved.
142
+ * @param {Record<string, {status?: string, reason?: string|null}>} [args.modelEntitlement] -
143
+ * live per-model Claude entitlement map (modelId → {status, reason}).
144
+ * Empty `{}` (default) does not gate — keeps pre-entitlement callers green.
106
145
  * @returns {{decision: "ROUTED"|"MANUAL_HANDOFF"|"WAIT_FOR_PROJECT_TEAM", role: string, provider: string|null, model: object|null, assignmentSource: string|null, strategyFingerprint: string|null, blockedAssignment: {provider: string, model: object, assignmentSource: string}|null, suggestedAlternative: {provider: string, model: object}|null, why: string}}
107
146
  */
108
- export function resolveProjectRoute({ role, strategy, eligibility = {} }) {
147
+ export function resolveProjectRoute({ role, strategy, eligibility = {}, modelEntitlement = {} }) {
109
148
  const strategyFingerprint = strategy?.profileFingerprint ?? null;
110
149
 
111
150
  if (!strategy) {
@@ -133,9 +172,25 @@ export function resolveProjectRoute({ role, strategy, eligibility = {} }) {
133
172
  };
134
173
  }
135
174
 
175
+ const entitlementBlock = blockingEntitlement(model, modelEntitlement);
176
+ if (entitlementBlock) {
177
+ const reason = entitlementBlock.reason
178
+ ?? (entitlementBlock.status === ENTITLEMENT.DENIED
179
+ ? "account entitlement denied"
180
+ : "account entitlement unverified");
181
+ const suggestedAlternative = routableAlternative(fallback, eligibility, modelEntitlement);
182
+ const why = suggestedAlternative
183
+ ? `${model.displayName ?? model.modelId} is not currently entitled (${reason}) — no automatic substitution; confirm the suggested alternative for ${role} before proceeding.`
184
+ : `${model.displayName ?? model.modelId} is not currently entitled (${reason}) — no automatic alternative is available for ${role} right now.`;
185
+ return blocked(role, strategyFingerprint, why, {
186
+ blockedAssignment: assignmentRef(model, assignmentSource),
187
+ suggestedAlternative
188
+ });
189
+ }
190
+
136
191
  if (eligibility[model.adapterId]?.ok !== true) {
137
192
  const reason = eligibility[model.adapterId]?.reason ?? "unknown reason";
138
- const suggestedAlternative = routableAlternative(fallback, eligibility);
193
+ const suggestedAlternative = routableAlternative(fallback, eligibility, modelEntitlement);
139
194
  const why = suggestedAlternative
140
195
  ? `${model.adapterId} is not currently eligible (${reason}) — no automatic substitution; confirm the suggested alternative for ${role} before proceeding.`
141
196
  : `${model.adapterId} is not currently eligible (${reason}) — no automatic alternative is available for ${role} right now.`;
@@ -39,6 +39,11 @@ import { readProviderUsage, writeProviderUsage } from "../runtime/usage-store.js
39
39
  import { resolveProjectRoute } from "./project-router.js";
40
40
  import { readArtificialAnalysisModels } from "../observability/artificial-analysis-models.js";
41
41
  import { readHuggingFaceLeaderboard } from "../observability/huggingface-leaderboard.js";
42
+ import {
43
+ DEFAULT_ENTITLEMENT_TTL_MS,
44
+ readClaudeEntitlementCache,
45
+ resolveClaudeEntitlements
46
+ } from "../observability/claude-entitlement-store.js";
42
47
  import {
43
48
  annotateWithRegistryEvidence, bestEfficientModelPerRoleGlobal, bestModelPerRole, bestModelPerRoleGlobal, buildAiTeam,
44
49
  buildEfficientTeam, scoreAvailableModels, summarizeCatalogCoverage
@@ -299,6 +304,11 @@ export function createConversationService(deps = {}) {
299
304
  // disk (no network call) but still shouldn't re-scan on every 2s poll.
300
305
  const huggingFaceLeaderboardTtlMs = deps.huggingFaceLeaderboardTtlMs ?? 6 * 60 * 60_000;
301
306
  const telemetryTtlMs = deps.telemetryTtlMs ?? 30_000;
307
+ // Claude entitlement disk cache is stable for days; subscription auth is
308
+ // a real CLI spawn (~10s) — cache both so snapshot() polls never re-probe
309
+ // per-model entitlement (that lives behind /models --verify-access).
310
+ const claudeEntitlementCacheTtlMs = deps.claudeEntitlementCacheTtlMs ?? 600_000;
311
+ const claudeSubscriptionAuthTtlMs = deps.claudeSubscriptionAuthTtlMs ?? 10_000;
302
312
  const now = deps.now ?? (() => Date.now());
303
313
 
304
314
  // Shared TTL + in-flight-dedupe cache for both provider usage probes:
@@ -341,6 +351,19 @@ export function createConversationService(deps = {}) {
341
351
  const readOpenCodeUsageCached = createCachedProbe(readOpenCodeUsageImpl, opencodeUsageTtlMs);
342
352
  const readOpenCodeGoCached = createCachedProbe(readOpenCodeGoImpl, opencodeGoUsageTtlMs);
343
353
  const readOpenCodeStatsCached = createCachedProbe(readOpenCodeStatsImpl, opencodeUsageTtlMs);
354
+ const readClaudeEntitlementCacheImpl = deps.readClaudeEntitlementCache ?? readClaudeEntitlementCache;
355
+ const readClaudeEntitlementCacheCached = createCachedProbe(
356
+ () => readClaudeEntitlementCacheImpl(homeDir), claudeEntitlementCacheTtlMs
357
+ );
358
+ // Auth spawn is slow; never re-run on every poll. Failures cache as null
359
+ // so resolveClaudeEntitlements treats subscription as mismatched/absent.
360
+ const verifyClaudeSubscriptionAuthCached = createCachedProbe(async () => {
361
+ try {
362
+ return await verifyClaudeSubscriptionAuthImpl({});
363
+ } catch {
364
+ return null;
365
+ }
366
+ }, claudeSubscriptionAuthTtlMs);
344
367
 
345
368
  async function executionFor(projectRoot, taskId) {
346
369
  const link = await readExecution(projectRoot, taskId);
@@ -459,11 +482,13 @@ export function createConversationService(deps = {}) {
459
482
  // explicit /project analyze or /project refresh.
460
483
  result.projectStrategy = await readProjectStrategyImpl(homeDir, projectRoot);
461
484
  if (enableProviderProbes) {
462
- const [codexCatalog, opencodeGoCatalog, cursorCatalog, aa] = await Promise.all([
485
+ const [codexCatalog, opencodeGoCatalog, cursorCatalog, aa, entitlementCache, claudeAuth] = await Promise.all([
463
486
  readCodexModelsCached(projectRoot, { cwd: projectRoot }),
464
487
  readOpenCodeGoModelsCached("global", {}),
465
488
  readCursorModelsCached(projectRoot, { cwd: projectRoot }),
466
- readArtificialAnalysisModelsCached("global", {})
489
+ readArtificialAnalysisModelsCached("global", {}),
490
+ readClaudeEntitlementCacheCached("global", {}),
491
+ verifyClaudeSubscriptionAuthCached("global", {})
467
492
  ]);
468
493
  // Same eligibility policy the execution/ask router uses, with one
469
494
  // deliberate exception: opencode-go is allowed here even without
@@ -489,6 +514,14 @@ export function createConversationService(deps = {}) {
489
514
  if (check.ok) candidates.push(adapterId);
490
515
  }
491
516
  const claudeCatalog = readClaudeModelsImpl();
517
+ // Cache-only resolve — never probeClaudeModelEntitlement* from snapshot().
518
+ const claudeEntitlement = resolveClaudeEntitlements({
519
+ cache: entitlementCache,
520
+ subscriptionType: claudeAuth?.subscriptionType ?? null,
521
+ catalogIds: (claudeCatalog.models ?? []).map((m) => m.id),
522
+ now: now(),
523
+ ttlMs: deps.claudeEntitlementTtlMs ?? DEFAULT_ENTITLEMENT_TTL_MS
524
+ });
492
525
  const catalogsByAdapter = {
493
526
  codex: codexCatalog?.models ?? [],
494
527
  claude: claudeCatalog.models,
@@ -515,7 +548,8 @@ export function createConversationService(deps = {}) {
515
548
  // exact same real provider catalogs scoredAllRaw itself came from.
516
549
  const completeCandidateCatalog = buildCompleteCandidateCatalog(
517
550
  Object.keys(catalogsByAdapter).map((adapterId) => ({ adapterId, models: catalogsByAdapter[adapterId] ?? [] })),
518
- aa.models
551
+ aa.models,
552
+ { modelEntitlement: claudeEntitlement }
519
553
  );
520
554
  // The Recommendation Pool: scoredAllRaw joined with its real
521
555
  // identity, with genuinely superseded generations excluded —
@@ -606,6 +640,8 @@ export function createConversationService(deps = {}) {
606
640
  status: aa.status, source: aa.source, age: aa.age,
607
641
  models: annotateWithRegistryEvidence(scored, registry), roles: bestModelPerRole(scored),
608
642
  eligibility, coverage, unscoredModels,
643
+ // Resolved Claude per-model entitlement (cache-only; never probed here).
644
+ claudeEntitlement,
609
645
  // BEST FIT GLOBAL / EFFICIENT GLOBAL: the honest, uncoordinated
610
646
  // per-role winner — never cedes a role for portfolio diversity,
611
647
  // family concentration, or provider distribution (see
@@ -781,8 +817,8 @@ export function createConversationService(deps = {}) {
781
817
  const projectRoot = await root(cwd);
782
818
  const profile = await computeProjectProfileImpl({ cwd: projectRoot });
783
819
  const snap = await this.snapshot({ cwd: projectRoot });
784
- const { scoredAll = [], eligibility = {}, registry = null, providerCapacity = null, unscoredModels = [] } = snap.modelIntelligence ?? {};
785
- const candidates = { scoredAll, eligibility, registry, providerCapacity };
820
+ const { scoredAll = [], eligibility = {}, registry = null, providerCapacity = null, unscoredModels = [], claudeEntitlement = {} } = snap.modelIntelligence ?? {};
821
+ const candidates = { scoredAll, eligibility, registry, providerCapacity, claudeEntitlement };
786
822
  const alternatives = computeBootstrapAnalystAlternatives(candidates);
787
823
  const analystCatalog = computeBootstrapAnalystCatalog({ ...candidates, unscoredModels });
788
824
  return { profile, alternatives, candidates, analystCatalog, projectRoot };
@@ -826,7 +862,8 @@ export function createConversationService(deps = {}) {
826
862
  modelId: analyst.model.modelId,
827
863
  deps: {
828
864
  askProvider: askProviderImpl, runCodexSandboxedBootstrap: runCodexSandboxedBootstrapImpl, isolationDeps: codexIsolationDeps,
829
- verifyClaudeSubscriptionAuth: verifyClaudeSubscriptionAuthImpl, readClaudeModels: readClaudeModelsImpl
865
+ verifyClaudeSubscriptionAuth: verifyClaudeSubscriptionAuthImpl, readClaudeModels: readClaudeModelsImpl,
866
+ modelEntitlement: candidates?.claudeEntitlement ?? {}
830
867
  }
831
868
  });
832
869
  const eligibility = await adapter.checkEligibility();
@@ -1007,7 +1044,8 @@ export function createConversationService(deps = {}) {
1007
1044
  const strategy = await readProjectStrategyImpl(homeDir, projectRoot);
1008
1045
  const snap = await this.snapshot({ cwd: projectRoot });
1009
1046
  const eligibility = snap.modelIntelligence?.eligibility ?? {};
1010
- return resolveProjectRoute({ role, strategy, eligibility });
1047
+ const modelEntitlement = snap.modelIntelligence?.claudeEntitlement ?? {};
1048
+ return resolveProjectRoute({ role, strategy, eligibility, modelEntitlement });
1011
1049
  },
1012
1050
  /**
1013
1051
  * Read-only preview of what executePlan would do right now.
@@ -31,8 +31,11 @@
31
31
  // anything downstream just because its lineage couldn't be determined.
32
32
 
33
33
  import { matchArtificialAnalysisScore } from "./model-intelligence.js";
34
+ import { ENTITLEMENT } from "../observability/claude-model-entitlement.js";
34
35
 
35
36
  /**
37
+ * @typedef {"allowed"|"denied"|"unverified"|"not_applicable"} ModelEntitlementStatus
38
+ *
36
39
  * @typedef {object} ModelCandidateIdentity
37
40
  * @property {string} candidateKey - `${adapterId}::${modelId}`, this catalog's stable identity key.
38
41
  * @property {string} modelId - the exact provider-reported id — what scoring/execution actually use, never the cleaned name.
@@ -40,6 +43,8 @@ import { matchArtificialAnalysisScore } from "./model-intelligence.js";
40
43
  * @property {string} rawDisplayName - the provider's own displayName, completely unmodified — the real evidence modelName was derived from. Whatever stripDisplayVariant peeled off (effort/context/privacy tokens) to produce modelName is still visible here, never a separate field: /models --evidence's own "technical detail" is just this string.
41
44
  * @property {string} adapterId - "codex" | "claude" | "cursor" | "opencode-go".
42
45
  * @property {"automatic"|"manual"} accessMode - whether Kairo can actually launch this candidate itself right now, or whether it's a real, recommendable option the human runs manually (Cursor's own "auto" router model always; OpenCode Zen always, pending its own Go/Zen billing-attribution proof — see this module's own doc). Named Cursor and OpenCode Go models are automatic.
46
+ * @property {ModelEntitlementStatus} entitlement - orthogonal to accessMode. Codex/Cursor/OpenCode Go/Zen are `not_applicable` (their catalog IS access proof). Claude comes from the live entitlement map, or `unverified` when that map has no entry.
47
+ * @property {string|null} entitlementReason - real CLI/reason string when entitlement is denied/unverified with a message; otherwise null.
43
48
  * @property {"scored"|"partial"|"unscored"} evidenceStatus - "scored": AA matched this exact model AND reports at least one of intelligenceIndex/codingIndex. "partial": AA matched it but both composite indices are null (real match, thin evidence). "unscored": no confident AA match at all. Never role-specific — see this module's own doc for why.
44
49
  * @property {string|null} lineageKey - real, recognized model family/lineage (see LINEAGE_PARSERS) — null when the modelId doesn't match any recognized, conservative pattern. Never guessed.
45
50
  * @property {number|null} generation - a real, comparable version number within that lineage — null whenever lineageKey is null.
@@ -47,6 +52,39 @@ import { matchArtificialAnalysisScore } from "./model-intelligence.js";
47
52
  * @property {{inputPerMTok: number, outputPerMTok: number}|null} resourceCost - real, provider-reported cost, when the provider actually reports one (OpenCode Go today) — never estimated or carried over from a different model.
48
53
  */
49
54
 
55
+ const AUTOMATIC_ENTITLEMENTS = new Set([ENTITLEMENT.ALLOWED, ENTITLEMENT.NOT_APPLICABLE]);
56
+
57
+ /**
58
+ * Resolves per-model entitlement orthogonal to accessMode.
59
+ * Non-Claude adapters: not_applicable (catalog presence is access proof).
60
+ * Claude: from deps.modelEntitlement[modelId], or unverified when missing.
61
+ * @param {string} adapterId
62
+ * @param {string} modelId
63
+ * @param {Record<string, {status?: string, reason?: string|null}>} [modelEntitlement]
64
+ * @returns {{entitlement: ModelEntitlementStatus, entitlementReason: string|null}}
65
+ */
66
+ function resolveEntitlement(adapterId, modelId, modelEntitlement = {}) {
67
+ if (adapterId !== "claude") {
68
+ return { entitlement: ENTITLEMENT.NOT_APPLICABLE, entitlementReason: null };
69
+ }
70
+ const entry = modelEntitlement[modelId];
71
+ if (!entry || typeof entry !== "object") {
72
+ return { entitlement: ENTITLEMENT.UNVERIFIED, entitlementReason: null };
73
+ }
74
+ const status = entry.status;
75
+ if (
76
+ status === ENTITLEMENT.ALLOWED
77
+ || status === ENTITLEMENT.DENIED
78
+ || status === ENTITLEMENT.UNVERIFIED
79
+ ) {
80
+ return {
81
+ entitlement: status,
82
+ entitlementReason: entry.reason == null ? null : String(entry.reason)
83
+ };
84
+ }
85
+ return { entitlement: ENTITLEMENT.UNVERIFIED, entitlementReason: null };
86
+ }
87
+
50
88
  // Real variant tokens observed across the four live provider catalogs
51
89
  // this session audited (Cursor's 223-model catalog especially — the only
52
90
  // source that embeds these directly into displayName text, with no
@@ -332,16 +370,18 @@ function resolveResourceCost(rawModel) {
332
370
  * @param {{id: string, displayName?: string}} rawModel
333
371
  * @param {Array<object>} aaModels
334
372
  * @param {(modelId: string, aaModels: Array<object>) => object|null} matcher
373
+ * @param {Record<string, {status?: string, reason?: string|null}>} [modelEntitlement]
335
374
  * @returns {ModelCandidateIdentity}
336
375
  */
337
- function buildCandidateIdentity(adapterId, rawModel, aaModels, matcher) {
376
+ function buildCandidateIdentity(adapterId, rawModel, aaModels, matcher, modelEntitlement = {}) {
338
377
  const modelId = rawModel.id;
339
378
  const rawDisplayName = rawModel.displayName ?? modelId;
379
+ const { entitlement, entitlementReason } = resolveEntitlement(adapterId, modelId, modelEntitlement);
340
380
 
341
381
  if (adapterId === "cursor" && modelId === "auto") {
342
382
  return {
343
383
  candidateKey: `${adapterId}::${modelId}`, modelId, modelName: "Cursor Auto", rawDisplayName,
344
- adapterId, accessMode: "manual", evidenceStatus: "unscored",
384
+ adapterId, accessMode: "manual", entitlement, entitlementReason, evidenceStatus: "unscored",
345
385
  lineageKey: null, generation: null, lifecycle: "unknown", resourceCost: null
346
386
  };
347
387
  }
@@ -351,7 +391,8 @@ function buildCandidateIdentity(adapterId, rawModel, aaModels, matcher) {
351
391
  const lineage = resolveLineage(modelId);
352
392
  return {
353
393
  candidateKey: `${adapterId}::${modelId}`, modelId, modelName, rawDisplayName,
354
- adapterId, accessMode: resolveAccessMode(adapterId, modelId), evidenceStatus: resolveEvidenceStatus(matched),
394
+ adapterId, accessMode: resolveAccessMode(adapterId, modelId), entitlement, entitlementReason,
395
+ evidenceStatus: resolveEvidenceStatus(matched),
355
396
  // lifecycle is resolved in a second pass (applyLifecycle, called from
356
397
  // buildCompleteCandidateCatalog) once the WHOLE catalog is known —
357
398
  // "superseded" is a statement about this candidate relative to its
@@ -372,16 +413,22 @@ function buildCandidateIdentity(adapterId, rawModel, aaModels, matcher) {
372
413
  * same shape model-intelligence.js's scoreAvailableModels takes, e.g.
373
414
  * `[{ adapterId: "codex", models: readCodexModels().models }, ...]`.
374
415
  * @param {Array<object>} aaModels - readArtificialAnalysisModels().models
375
- * @param {{matchArtificialAnalysisScore?: (modelId: string, aaModels: Array<object>) => object|null}} [deps] -
416
+ * @param {{
417
+ * matchArtificialAnalysisScore?: (modelId: string, aaModels: Array<object>) => object|null,
418
+ * modelEntitlement?: Record<string, {status?: string, reason?: string|null}>
419
+ * }} [deps] -
376
420
  * injectable for tests; defaults to model-intelligence.js's real export.
421
+ * `modelEntitlement` is Claude-only live entitlement (allowed/denied/unverified);
422
+ * missing keys leave Claude as unverified. Non-Claude adapters ignore it.
377
423
  * @returns {Array<ModelCandidateIdentity>}
378
424
  */
379
425
  export function buildCompleteCandidateCatalog(providerCatalogs, aaModels, deps = {}) {
380
426
  const matcher = deps.matchArtificialAnalysisScore ?? matchArtificialAnalysisScore;
427
+ const modelEntitlement = deps.modelEntitlement ?? {};
381
428
  const catalog = [];
382
429
  for (const { adapterId, models } of providerCatalogs) {
383
430
  for (const rawModel of models ?? []) {
384
- catalog.push(buildCandidateIdentity(adapterId, rawModel, aaModels, matcher));
431
+ catalog.push(buildCandidateIdentity(adapterId, rawModel, aaModels, matcher, modelEntitlement));
385
432
  }
386
433
  }
387
434
  return applyLifecycle(catalog);
@@ -435,11 +482,20 @@ export function buildRecommendationPool(scoredAll, completeCatalog) {
435
482
  // never excludes: an un-joined candidate is treated as lineage-
436
483
  // unknown, exactly like any other real unrecognized lineage.
437
484
  if (identity?.lifecycle === "superseded") continue;
485
+ // Denied entitlement is the same class as superseded: never recommend.
486
+ // Unverified Claude stays recommendable (human can still pick it);
487
+ // only automatic launch excludes it (see buildAutomaticExecutionPool).
488
+ if (identity?.entitlement === ENTITLEMENT.DENIED) continue;
489
+ const fallbackEntitlement = scored.adapterId === "claude"
490
+ ? ENTITLEMENT.UNVERIFIED
491
+ : ENTITLEMENT.NOT_APPLICABLE;
438
492
  pool.push({
439
493
  ...scored,
440
494
  candidateKey,
441
495
  modelName: identity?.modelName ?? scored.displayName ?? scored.modelId,
442
496
  accessMode: identity?.accessMode ?? "manual",
497
+ entitlement: identity?.entitlement ?? fallbackEntitlement,
498
+ entitlementReason: identity?.entitlementReason ?? null,
443
499
  evidenceStatus: identity?.evidenceStatus ?? "scored",
444
500
  lineageKey: identity?.lineageKey ?? null,
445
501
  generation: identity?.generation ?? null,
@@ -459,16 +515,22 @@ export function buildRecommendationPool(scoredAll, completeCatalog) {
459
515
  * ModelCandidateIdentity's own doc) AND real,
460
516
  * current eligibility (adapter availability, quota, launchability — the
461
517
  * exact same `eligibility` object checkCandidate/execution-router.js
462
- * already compute, reused here rather than reimplemented). Never
463
- * mutates or filters the Recommendation Pool itself a manual-only real
464
- * recommendation (Cursor, say) stays fully visible there; a caller that
465
- * wants to actually RUN a task must separately produce a real
466
- * "Continue in Cursor"-style handoff for it, never a silent fallback to
467
- * a different, automatically-launchable model the human didn't ask for.
518
+ * already compute, reused here rather than reimplemented) AND entitlement
519
+ * in {allowed, not_applicable} denied and unverified Claude never
520
+ * auto-launch. Never mutates or filters the Recommendation Pool itself
521
+ * a manual-only real recommendation (Cursor, say) stays fully visible
522
+ * there; a caller that wants to actually RUN a task must separately
523
+ * produce a real "Continue in Cursor"-style handoff for it, never a
524
+ * silent fallback to a different, automatically-launchable model the
525
+ * human didn't ask for.
468
526
  * @param {Array<RecommendationPoolCandidate>} recommendationPool
469
527
  * @param {Record<string, {ok: boolean, reason?: string}>} eligibility - checkCandidate() results per adapterId.
470
528
  * @returns {Array<RecommendationPoolCandidate>}
471
529
  */
472
530
  export function buildAutomaticExecutionPool(recommendationPool, eligibility) {
473
- return recommendationPool.filter((candidate) => candidate.accessMode === "automatic" && eligibility[candidate.adapterId]?.ok === true);
531
+ return recommendationPool.filter((candidate) => (
532
+ candidate.accessMode === "automatic"
533
+ && eligibility[candidate.adapterId]?.ok === true
534
+ && AUTOMATIC_ENTITLEMENTS.has(candidate.entitlement)
535
+ ));
474
536
  }
@@ -10,7 +10,9 @@ import { buildClaudeExecutionEnv } from "../runtime/execution-adapters/claude.js
10
10
  export const ENTITLEMENT = Object.freeze({
11
11
  ALLOWED: "allowed",
12
12
  DENIED: "denied",
13
- UNVERIFIED: "unverified"
13
+ UNVERIFIED: "unverified",
14
+ // Non-Claude providers: their live/documented catalog IS access proof.
15
+ NOT_APPLICABLE: "not_applicable"
14
16
  });
15
17
 
16
18
  const DENIED_ERROR_CODES = new Set(["credits_required"]);
@@ -89,7 +89,7 @@ export async function readCodexModels({
89
89
  child.once?.("close", () => { if (!finished) finish(unknown("codex app-server closed before model list")); });
90
90
 
91
91
  writeRequest(child, 1, "initialize", {
92
- clientInfo: { name: "kairo", title: "Kairo", version: "0.26.1" },
92
+ clientInfo: { name: "kairo", title: "Kairo", version: "0.27.0" },
93
93
  capabilities: {}
94
94
  });
95
95
  });
@@ -151,7 +151,7 @@ export async function readCodexUsage({
151
151
  child.once?.("close", () => { if (!finished) finish(unknown("codex app-server closed before rate limits")); });
152
152
 
153
153
  writeRequest(child, 1, "initialize", {
154
- clientInfo: { name: "kairo", title: "Kairo", version: "0.26.1" },
154
+ clientInfo: { name: "kairo", title: "Kairo", version: "0.27.0" },
155
155
  capabilities: {}
156
156
  });
157
157
  });