@kal-elsam/kairo-runtime 0.27.0 → 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,19 @@ 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
+
8
21
  ## 0.27.0 — 2026-09-19 (Kairo Runtime)
9
22
 
10
23
  Minor release. Claude model entitlement now gates PROJECT TEAM pools and routes.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@kal-elsam/kairo-runtime",
3
- "version": "0.27.0",
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;
@@ -41,9 +41,15 @@ import { readArtificialAnalysisModels } from "../observability/artificial-analys
41
41
  import { readHuggingFaceLeaderboard } from "../observability/huggingface-leaderboard.js";
42
42
  import {
43
43
  DEFAULT_ENTITLEMENT_TTL_MS,
44
+ mergeEntitlementResults,
44
45
  readClaudeEntitlementCache,
45
- resolveClaudeEntitlements
46
+ resolveClaudeEntitlements,
47
+ writeClaudeEntitlementCache
46
48
  } from "../observability/claude-entitlement-store.js";
49
+ import {
50
+ ENTITLEMENT,
51
+ probeClaudeModelEntitlements
52
+ } from "../observability/claude-model-entitlement.js";
47
53
  import {
48
54
  annotateWithRegistryEvidence, bestEfficientModelPerRoleGlobal, bestModelPerRole, bestModelPerRoleGlobal, buildAiTeam,
49
55
  buildEfficientTeam, scoreAvailableModels, summarizeCatalogCoverage
@@ -64,6 +70,35 @@ export const CONVERSATION_SCHEMA = "kairo.conversation/v1";
64
70
  // 30s default.
65
71
  const BOOTSTRAP_ANALYST_TIMEOUT_MS = 180_000;
66
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
+
67
102
  function publicPlan(record, execution = null) {
68
103
  const status = record.status ?? record;
69
104
  return {
@@ -352,6 +387,9 @@ export function createConversationService(deps = {}) {
352
387
  const readOpenCodeGoCached = createCachedProbe(readOpenCodeGoImpl, opencodeGoUsageTtlMs);
353
388
  const readOpenCodeStatsCached = createCachedProbe(readOpenCodeStatsImpl, opencodeUsageTtlMs);
354
389
  const readClaudeEntitlementCacheImpl = deps.readClaudeEntitlementCache ?? readClaudeEntitlementCache;
390
+ const writeClaudeEntitlementCacheImpl = deps.writeClaudeEntitlementCache ?? writeClaudeEntitlementCache;
391
+ const mergeEntitlementResultsImpl = deps.mergeEntitlementResults ?? mergeEntitlementResults;
392
+ const probeClaudeModelEntitlementsImpl = deps.probeClaudeModelEntitlements ?? probeClaudeModelEntitlements;
355
393
  const readClaudeEntitlementCacheCached = createCachedProbe(
356
394
  () => readClaudeEntitlementCacheImpl(homeDir), claudeEntitlementCacheTtlMs
357
395
  );
@@ -821,7 +859,92 @@ export function createConversationService(deps = {}) {
821
859
  const candidates = { scoredAll, eligibility, registry, providerCapacity, claudeEntitlement };
822
860
  const alternatives = computeBootstrapAnalystAlternatives(candidates);
823
861
  const analystCatalog = computeBootstrapAnalystCatalog({ ...candidates, unscoredModels });
824
- 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
+ };
825
948
  },
826
949
  /**
827
950
  * `/project analyst quality|efficient --confirm` (ANALYZING -> SUGGESTED):
@@ -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.27.0" },
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.27.0" },
154
+ clientInfo: { name: "kairo", title: "Kairo", version: "0.28.0" },
155
155
  capabilities: {}
156
156
  });
157
157
  });