@kal-elsam/kairo-runtime 0.26.1 → 0.28.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,33 @@ Historical entries below may reference the legacy `@kal-elsam/harness` package n
5
5
 
6
6
  ## Unreleased
7
7
 
8
+ ## 0.28.0 — 2026-09-19 (Kairo Runtime)
9
+
10
+ Minor release. `/models --verify-access` verifies Claude model entitlement on demand.
11
+
12
+ ### Added
13
+
14
+ - `/models --verify-access [--refresh]` — sequential, human-triggered Claude
15
+ entitlement probes with a real cost statement before any spawn. Without
16
+ `--refresh`, only unverified or TTL-expired models are probed; an all-
17
+ unverified sweep does not persist evidence.
18
+ - One-line Spanish preflight notice when Claude models still have unverified
19
+ access (recommendable, never auto-launched).
20
+
21
+ ## 0.27.0 — 2026-09-19 (Kairo Runtime)
22
+
23
+ Minor release. Claude model entitlement now gates PROJECT TEAM pools and routes.
24
+
25
+ ### Changed
26
+
27
+ - Denied Claude models (e.g. Fable 5.1 on Pro with `credits_required`) are
28
+ excluded from both the recommendation and automatic execution pools —
29
+ orthogonal to `accessMode`, so a denied model is never mislabeled "manual".
30
+ - Unverified Claude models stay recommendable but are never auto-launchable.
31
+ - Live entitlement also blocks a persisted project-team assignment whose
32
+ account access is denied or unverified; `snapshot()` still only reads the
33
+ disk cache (never probes).
34
+
8
35
  ## 0.26.1 — 2026-09-19 (Kairo Runtime)
9
36
 
10
37
  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.28.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",
@@ -258,7 +258,7 @@ export async function runCockpitApp({
258
258
  // blocks with no indication of which command produced which one.
259
259
  pushTranscript("user", task);
260
260
  if (command === "/help") {
261
- pushTranscript("kairo", "Shift+Tab cycles ASK/PLAN/AGENT · /project interactive overlay (or analyze/analyst/approve/refresh/status/cursor exhausted|available subcommands for scripted use) · /plan <task> force a plan · /usage automatic-provider status (Codex/Claude/Go) · /providers all connections incl. Zen/Cursor (manual) · /models CAPABILITY + EFFICIENT picks (--evidence for raw metrics) · /why eligibility detail · /clear · /quit");
261
+ pushTranscript("kairo", "Shift+Tab cycles ASK/PLAN/AGENT · /project interactive overlay (or analyze/analyst/approve/refresh/status/cursor exhausted|available subcommands for scripted use) · /plan <task> force a plan · /usage automatic-provider status (Codex/Claude/Go) · /providers all connections incl. Zen/Cursor (manual) · /models CAPABILITY + EFFICIENT picks (--evidence for raw metrics; --verify-access [--refresh] for Claude entitlement) · /why eligibility detail · /clear · /quit");
262
262
  } else if (command === "/usage") {
263
263
  for (const line of view.usageLines()) pushTranscript("kairo", line);
264
264
  } else if (command === "/providers") {
@@ -271,8 +271,38 @@ export async function runCockpitApp({
271
271
  // the plain-language why (capability, efficient alternative,
272
272
  // fallback), never raw metrics/percentages/ids/sources. Those
273
273
  // stay behind the explicit --evidence flag for technical audit.
274
- const flag = task.slice(command.length).trim();
275
- const explainLines = flag === "--evidence" ? view.aiTeamDetailLines() : view.modelsExplainLines();
274
+ // --verify-access [--refresh] is the only path that spawns Claude
275
+ // entitlement probes (never on snapshot / first poll).
276
+ const flags = new Set(task.slice(command.length).trim().split(/\s+/).filter(Boolean));
277
+ if (flags.has("--verify-access")) {
278
+ const refresh = flags.has("--refresh");
279
+ editor.disableSubmit = true;
280
+ editor.setText("");
281
+ return runAction("Verifying Claude model access", async () => {
282
+ const summary = await service.verifyClaudeEntitlements({
283
+ cwd,
284
+ refresh,
285
+ beforeProbe: ({ costStatement }) => {
286
+ pushTranscript("kairo", costStatement);
287
+ },
288
+ onProgress: ({ modelId, index, total }) => {
289
+ view.beginAction(`Verifying Claude access (${index + 1}/${total}): ${modelId}`);
290
+ }
291
+ });
292
+ const allowed = summary.results.filter((r) => r.status === "allowed").length;
293
+ const denied = summary.results.filter((r) => r.status === "denied").length;
294
+ const unverified = summary.results.filter((r) => r.status === "unverified").length;
295
+ if (summary.probed.length === 0) {
296
+ pushTranscript("kairo", "Claude access already verified for the current catalog (use --refresh to re-probe).");
297
+ } else {
298
+ pushTranscript(
299
+ "kairo",
300
+ `Claude access check: ${summary.probed.length} probed · ${allowed} allowed · ${denied} denied · ${unverified} unverified${summary.persisted ? " · cache updated" : " · cache unchanged"}.`
301
+ );
302
+ }
303
+ }).finally(() => { editor.disableSubmit = false; });
304
+ }
305
+ const explainLines = flags.has("--evidence") ? view.aiTeamDetailLines() : view.modelsExplainLines();
276
306
  for (const line of explainLines) pushTranscript("kairo", line);
277
307
  } else if (command === "/why") {
278
308
  // Drill-down for FIT: which providers were excluded and the exact
@@ -318,6 +348,9 @@ export async function runCockpitApp({
318
348
  return runAction("Analyzing project locally (read-only)", async () => {
319
349
  const preflight = await service.preflightProject({ cwd });
320
350
  view.pendingProjectAnalysis = preflight;
351
+ if (preflight.unverifiedClaudeNotice) {
352
+ pushTranscript("kairo", preflight.unverifiedClaudeNotice);
353
+ }
321
354
  if (!preflight.alternatives.length) {
322
355
  pushTranscript("kairo", "No real Project Analyst candidate is available right now (ASK only supports Codex/Claude today).");
323
356
  return;
@@ -160,6 +160,9 @@ export class ProjectOverlay {
160
160
  this.view.beginAction("Reading project evidence locally");
161
161
  try {
162
162
  this.preflight = await this.service.preflightProject({ cwd: this.cwd });
163
+ if (this.preflight.unverifiedClaudeNotice) {
164
+ this.onNarrate?.(this.preflight.unverifiedClaudeNotice);
165
+ }
163
166
  const models = this.preflight.analystCatalog?.models ?? [];
164
167
  if (!models.length) {
165
168
  this.state = S.NO_ANALYST;
@@ -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,17 @@ 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
+ mergeEntitlementResults,
45
+ readClaudeEntitlementCache,
46
+ resolveClaudeEntitlements,
47
+ writeClaudeEntitlementCache
48
+ } from "../observability/claude-entitlement-store.js";
49
+ import {
50
+ ENTITLEMENT,
51
+ probeClaudeModelEntitlements
52
+ } from "../observability/claude-model-entitlement.js";
42
53
  import {
43
54
  annotateWithRegistryEvidence, bestEfficientModelPerRoleGlobal, bestModelPerRole, bestModelPerRoleGlobal, buildAiTeam,
44
55
  buildEfficientTeam, scoreAvailableModels, summarizeCatalogCoverage
@@ -59,6 +70,35 @@ export const CONVERSATION_SCHEMA = "kairo.conversation/v1";
59
70
  // 30s default.
60
71
  const BOOTSTRAP_ANALYST_TIMEOUT_MS = 180_000;
61
72
 
73
+ /** Max Claude entitlement probes per `/models --verify-access` sweep. */
74
+ const CLAUDE_ENTITLEMENT_MAX_PROBES = 12;
75
+
76
+ /**
77
+ * Cost statement printed before any Claude entitlement spawn. Economy is
78
+ * measured: denied probes cost $0; allowed ones about one cent each.
79
+ * @param {{ pendingCount?: number }} [options]
80
+ */
81
+ export function buildClaudeEntitlementVerifyCostStatement({ pendingCount = 0 } = {}) {
82
+ const n = Math.max(0, Number(pendingCount) || 0);
83
+ if (n <= 0) {
84
+ return "No Claude models need access verification right now (denied probes cost $0; allowed ones about one cent each).";
85
+ }
86
+ return `About to verify ${n} Claude model${n === 1 ? "" : "s"}: models your plan denies cost $0; allowed ones cost about one cent each.`;
87
+ }
88
+
89
+ /**
90
+ * One-line preflight notice when unverified Claude models exist.
91
+ * @param {number} count
92
+ */
93
+ export function buildUnverifiedClaudePreflightNotice(count) {
94
+ const n = Math.max(0, Number(count) || 0);
95
+ return `${n} modelos de Claude tienen acceso sin verificar — se pueden recomendar pero nunca lanzar automáticamente. Corré /models --verify-access para verificar (los que tu plan deniega cuestan $0; los permitidos, alrededor de un centavo cada uno).`;
96
+ }
97
+
98
+ function isPersistableEntitlementStatus(status) {
99
+ return status === ENTITLEMENT.ALLOWED || status === ENTITLEMENT.DENIED;
100
+ }
101
+
62
102
  function publicPlan(record, execution = null) {
63
103
  const status = record.status ?? record;
64
104
  return {
@@ -299,6 +339,11 @@ export function createConversationService(deps = {}) {
299
339
  // disk (no network call) but still shouldn't re-scan on every 2s poll.
300
340
  const huggingFaceLeaderboardTtlMs = deps.huggingFaceLeaderboardTtlMs ?? 6 * 60 * 60_000;
301
341
  const telemetryTtlMs = deps.telemetryTtlMs ?? 30_000;
342
+ // Claude entitlement disk cache is stable for days; subscription auth is
343
+ // a real CLI spawn (~10s) — cache both so snapshot() polls never re-probe
344
+ // per-model entitlement (that lives behind /models --verify-access).
345
+ const claudeEntitlementCacheTtlMs = deps.claudeEntitlementCacheTtlMs ?? 600_000;
346
+ const claudeSubscriptionAuthTtlMs = deps.claudeSubscriptionAuthTtlMs ?? 10_000;
302
347
  const now = deps.now ?? (() => Date.now());
303
348
 
304
349
  // Shared TTL + in-flight-dedupe cache for both provider usage probes:
@@ -341,6 +386,22 @@ export function createConversationService(deps = {}) {
341
386
  const readOpenCodeUsageCached = createCachedProbe(readOpenCodeUsageImpl, opencodeUsageTtlMs);
342
387
  const readOpenCodeGoCached = createCachedProbe(readOpenCodeGoImpl, opencodeGoUsageTtlMs);
343
388
  const readOpenCodeStatsCached = createCachedProbe(readOpenCodeStatsImpl, opencodeUsageTtlMs);
389
+ const readClaudeEntitlementCacheImpl = deps.readClaudeEntitlementCache ?? readClaudeEntitlementCache;
390
+ const writeClaudeEntitlementCacheImpl = deps.writeClaudeEntitlementCache ?? writeClaudeEntitlementCache;
391
+ const mergeEntitlementResultsImpl = deps.mergeEntitlementResults ?? mergeEntitlementResults;
392
+ const probeClaudeModelEntitlementsImpl = deps.probeClaudeModelEntitlements ?? probeClaudeModelEntitlements;
393
+ const readClaudeEntitlementCacheCached = createCachedProbe(
394
+ () => readClaudeEntitlementCacheImpl(homeDir), claudeEntitlementCacheTtlMs
395
+ );
396
+ // Auth spawn is slow; never re-run on every poll. Failures cache as null
397
+ // so resolveClaudeEntitlements treats subscription as mismatched/absent.
398
+ const verifyClaudeSubscriptionAuthCached = createCachedProbe(async () => {
399
+ try {
400
+ return await verifyClaudeSubscriptionAuthImpl({});
401
+ } catch {
402
+ return null;
403
+ }
404
+ }, claudeSubscriptionAuthTtlMs);
344
405
 
345
406
  async function executionFor(projectRoot, taskId) {
346
407
  const link = await readExecution(projectRoot, taskId);
@@ -459,11 +520,13 @@ export function createConversationService(deps = {}) {
459
520
  // explicit /project analyze or /project refresh.
460
521
  result.projectStrategy = await readProjectStrategyImpl(homeDir, projectRoot);
461
522
  if (enableProviderProbes) {
462
- const [codexCatalog, opencodeGoCatalog, cursorCatalog, aa] = await Promise.all([
523
+ const [codexCatalog, opencodeGoCatalog, cursorCatalog, aa, entitlementCache, claudeAuth] = await Promise.all([
463
524
  readCodexModelsCached(projectRoot, { cwd: projectRoot }),
464
525
  readOpenCodeGoModelsCached("global", {}),
465
526
  readCursorModelsCached(projectRoot, { cwd: projectRoot }),
466
- readArtificialAnalysisModelsCached("global", {})
527
+ readArtificialAnalysisModelsCached("global", {}),
528
+ readClaudeEntitlementCacheCached("global", {}),
529
+ verifyClaudeSubscriptionAuthCached("global", {})
467
530
  ]);
468
531
  // Same eligibility policy the execution/ask router uses, with one
469
532
  // deliberate exception: opencode-go is allowed here even without
@@ -489,6 +552,14 @@ export function createConversationService(deps = {}) {
489
552
  if (check.ok) candidates.push(adapterId);
490
553
  }
491
554
  const claudeCatalog = readClaudeModelsImpl();
555
+ // Cache-only resolve — never probeClaudeModelEntitlement* from snapshot().
556
+ const claudeEntitlement = resolveClaudeEntitlements({
557
+ cache: entitlementCache,
558
+ subscriptionType: claudeAuth?.subscriptionType ?? null,
559
+ catalogIds: (claudeCatalog.models ?? []).map((m) => m.id),
560
+ now: now(),
561
+ ttlMs: deps.claudeEntitlementTtlMs ?? DEFAULT_ENTITLEMENT_TTL_MS
562
+ });
492
563
  const catalogsByAdapter = {
493
564
  codex: codexCatalog?.models ?? [],
494
565
  claude: claudeCatalog.models,
@@ -515,7 +586,8 @@ export function createConversationService(deps = {}) {
515
586
  // exact same real provider catalogs scoredAllRaw itself came from.
516
587
  const completeCandidateCatalog = buildCompleteCandidateCatalog(
517
588
  Object.keys(catalogsByAdapter).map((adapterId) => ({ adapterId, models: catalogsByAdapter[adapterId] ?? [] })),
518
- aa.models
589
+ aa.models,
590
+ { modelEntitlement: claudeEntitlement }
519
591
  );
520
592
  // The Recommendation Pool: scoredAllRaw joined with its real
521
593
  // identity, with genuinely superseded generations excluded —
@@ -606,6 +678,8 @@ export function createConversationService(deps = {}) {
606
678
  status: aa.status, source: aa.source, age: aa.age,
607
679
  models: annotateWithRegistryEvidence(scored, registry), roles: bestModelPerRole(scored),
608
680
  eligibility, coverage, unscoredModels,
681
+ // Resolved Claude per-model entitlement (cache-only; never probed here).
682
+ claudeEntitlement,
609
683
  // BEST FIT GLOBAL / EFFICIENT GLOBAL: the honest, uncoordinated
610
684
  // per-role winner — never cedes a role for portfolio diversity,
611
685
  // family concentration, or provider distribution (see
@@ -781,11 +855,96 @@ export function createConversationService(deps = {}) {
781
855
  const projectRoot = await root(cwd);
782
856
  const profile = await computeProjectProfileImpl({ cwd: projectRoot });
783
857
  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 };
858
+ const { scoredAll = [], eligibility = {}, registry = null, providerCapacity = null, unscoredModels = [], claudeEntitlement = {} } = snap.modelIntelligence ?? {};
859
+ const candidates = { scoredAll, eligibility, registry, providerCapacity, claudeEntitlement };
786
860
  const alternatives = computeBootstrapAnalystAlternatives(candidates);
787
861
  const analystCatalog = computeBootstrapAnalystCatalog({ ...candidates, unscoredModels });
788
- return { profile, alternatives, candidates, analystCatalog, projectRoot };
862
+ const unverifiedCount = Object.values(claudeEntitlement).filter(
863
+ (entry) => entry?.status === ENTITLEMENT.UNVERIFIED
864
+ ).length;
865
+ const unverifiedClaudeNotice = unverifiedCount > 0
866
+ ? buildUnverifiedClaudePreflightNotice(unverifiedCount)
867
+ : null;
868
+ return { profile, alternatives, candidates, analystCatalog, projectRoot, unverifiedClaudeNotice };
869
+ },
870
+ /**
871
+ * `/models --verify-access [--refresh]`: the only service path that
872
+ * spawns `probeClaudeModelEntitlements`. Without refresh, only
873
+ * unverified / TTL-expired catalog ids are probed; with refresh, every
874
+ * catalog id is probed (still capped at maxProbes=12). Persist only when
875
+ * at least one allowed/denied result exists — an all-unverified sweep
876
+ * never invents evidence on disk.
877
+ *
878
+ * @param {object} args
879
+ * @param {string} args.cwd
880
+ * @param {boolean} [args.refresh]
881
+ * @param {(info: { pendingCount: number, pendingIds: string[], costStatement: string }) => (void|Promise<void>)} [args.beforeProbe]
882
+ * Called after the probe set is known and BEFORE any spawn — app.js
883
+ * prints the cost statement here.
884
+ * @param {(info: { modelId: string, index: number, total: number, result: object }) => void} [args.onProgress]
885
+ */
886
+ async verifyClaudeEntitlements({ cwd, refresh = false, beforeProbe = null, onProgress = null } = {}) {
887
+ const projectRoot = cwd ? await root(cwd) : null;
888
+ const catalog = readClaudeModelsImpl();
889
+ const catalogIds = (catalog.models ?? []).map((m) => m.id).filter(Boolean);
890
+ let auth = null;
891
+ try {
892
+ auth = await verifyClaudeSubscriptionAuthImpl({});
893
+ } catch {
894
+ auth = null;
895
+ }
896
+ const subscriptionType = auth?.subscriptionType ?? null;
897
+ const cache = await readClaudeEntitlementCacheImpl(homeDir);
898
+ const nowMs = now();
899
+ const ttlMs = deps.claudeEntitlementTtlMs ?? DEFAULT_ENTITLEMENT_TTL_MS;
900
+ const resolved = resolveClaudeEntitlements({
901
+ cache,
902
+ subscriptionType,
903
+ catalogIds,
904
+ now: nowMs,
905
+ ttlMs
906
+ });
907
+ const pendingIds = (refresh
908
+ ? catalogIds
909
+ : catalogIds.filter((id) => resolved[id]?.status === ENTITLEMENT.UNVERIFIED)
910
+ ).slice(0, CLAUDE_ENTITLEMENT_MAX_PROBES);
911
+ const costStatement = buildClaudeEntitlementVerifyCostStatement({ pendingCount: pendingIds.length });
912
+ if (typeof beforeProbe === "function") {
913
+ await beforeProbe({ pendingCount: pendingIds.length, pendingIds, costStatement });
914
+ }
915
+ if (pendingIds.length === 0) {
916
+ return {
917
+ probed: [],
918
+ results: [],
919
+ costStatement,
920
+ persisted: false,
921
+ pendingCount: 0,
922
+ refresh: Boolean(refresh),
923
+ subscriptionType
924
+ };
925
+ }
926
+ const results = await probeClaudeModelEntitlementsImpl({
927
+ modelIds: pendingIds,
928
+ maxProbes: CLAUDE_ENTITLEMENT_MAX_PROBES,
929
+ cwd: projectRoot ?? process.cwd(),
930
+ onProgress
931
+ });
932
+ const hasPersistable = results.some((result) => isPersistableEntitlementStatus(result?.status));
933
+ let persisted = false;
934
+ if (hasPersistable) {
935
+ const merged = mergeEntitlementResultsImpl(cache, { subscriptionType, results });
936
+ await writeClaudeEntitlementCacheImpl(homeDir, merged);
937
+ persisted = true;
938
+ }
939
+ return {
940
+ probed: pendingIds,
941
+ results,
942
+ costStatement,
943
+ persisted,
944
+ pendingCount: pendingIds.length,
945
+ refresh: Boolean(refresh),
946
+ subscriptionType
947
+ };
789
948
  },
790
949
  /**
791
950
  * `/project analyst quality|efficient --confirm` (ANALYZING -> SUGGESTED):
@@ -826,7 +985,8 @@ export function createConversationService(deps = {}) {
826
985
  modelId: analyst.model.modelId,
827
986
  deps: {
828
987
  askProvider: askProviderImpl, runCodexSandboxedBootstrap: runCodexSandboxedBootstrapImpl, isolationDeps: codexIsolationDeps,
829
- verifyClaudeSubscriptionAuth: verifyClaudeSubscriptionAuthImpl, readClaudeModels: readClaudeModelsImpl
988
+ verifyClaudeSubscriptionAuth: verifyClaudeSubscriptionAuthImpl, readClaudeModels: readClaudeModelsImpl,
989
+ modelEntitlement: candidates?.claudeEntitlement ?? {}
830
990
  }
831
991
  });
832
992
  const eligibility = await adapter.checkEligibility();
@@ -1007,7 +1167,8 @@ export function createConversationService(deps = {}) {
1007
1167
  const strategy = await readProjectStrategyImpl(homeDir, projectRoot);
1008
1168
  const snap = await this.snapshot({ cwd: projectRoot });
1009
1169
  const eligibility = snap.modelIntelligence?.eligibility ?? {};
1010
- return resolveProjectRoute({ role, strategy, eligibility });
1170
+ const modelEntitlement = snap.modelIntelligence?.claudeEntitlement ?? {};
1171
+ return resolveProjectRoute({ role, strategy, eligibility, modelEntitlement });
1011
1172
  },
1012
1173
  /**
1013
1174
  * 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.28.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.28.0" },
155
155
  capabilities: {}
156
156
  });
157
157
  });