@kal-elsam/kairo-runtime 0.27.0 → 0.29.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,36 @@ Historical entries below may reference the legacy `@kal-elsam/harness` package n
5
5
 
6
6
  ## Unreleased
7
7
 
8
+ ## 0.29.0 — 2026-09-19 (Kairo Runtime)
9
+
10
+ Minor release. PROJECT TEAM selections explain themselves.
11
+
12
+ ### Changed
13
+
14
+ - Every PROJECT TEAM row now has a non-empty explanation — ordinary
15
+ leaders no longer render blank (`explainTeamDecision` from real
16
+ `decisionEvidence`, never invented metrics).
17
+ - Compact panel and `/project` overlay show the quality leader and real
18
+ retention% when the operational (efficient) pick differs; evidence
19
+ toggle (`e`) reuses `teamEvidenceLines`.
20
+ - Honest subtitle: operational picks are the efficient model among
21
+ eligible candidates. Orchestrator* footnote: Architect's quality pick
22
+ — Kairo has no separate orchestrator capability profile yet.
23
+ - Availability warnings for denied/unverified Claude entitlement.
24
+
25
+ ## 0.28.0 — 2026-09-19 (Kairo Runtime)
26
+
27
+ Minor release. `/models --verify-access` verifies Claude model entitlement on demand.
28
+
29
+ ### Added
30
+
31
+ - `/models --verify-access [--refresh]` — sequential, human-triggered Claude
32
+ entitlement probes with a real cost statement before any spawn. Without
33
+ `--refresh`, only unverified or TTL-expired models are probed; an all-
34
+ unverified sweep does not persist evidence.
35
+ - One-line Spanish preflight notice when Claude models still have unverified
36
+ access (recommendable, never auto-launched).
37
+
8
38
  ## 0.27.0 — 2026-09-19 (Kairo Runtime)
9
39
 
10
40
  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.29.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;
@@ -1,6 +1,7 @@
1
1
  import { Box, SelectList, Text, Input, Key, matchesKey, fuzzyFilter } from "@earendil-works/pi-tui";
2
2
  import { editorTheme, theme } from "./theme.js";
3
3
  import { CARD_TONE, cardInnerWidth, renderPanel } from "./card.js";
4
+ import { explainTeamDecision } from "./view.js";
4
5
 
5
6
  // /project's interactive overlay — the real preflight -> select analyst ->
6
7
  // confirm -> analyze -> result -> approve loop, reusing the exact same
@@ -109,6 +110,7 @@ export class ProjectOverlay {
109
110
  this.editInput = null;
110
111
  this.editSelectList = null;
111
112
  this.pendingEditCandidate = null;
113
+ this.showTeamEvidence = false;
112
114
 
113
115
  const existing = view.snapshot?.projectStrategy ?? null;
114
116
  if (existing?.status === "active") {
@@ -160,6 +162,9 @@ export class ProjectOverlay {
160
162
  this.view.beginAction("Reading project evidence locally");
161
163
  try {
162
164
  this.preflight = await this.service.preflightProject({ cwd: this.cwd });
165
+ if (this.preflight.unverifiedClaudeNotice) {
166
+ this.onNarrate?.(this.preflight.unverifiedClaudeNotice);
167
+ }
163
168
  const models = this.preflight.analystCatalog?.models ?? [];
164
169
  if (!models.length) {
165
170
  this.state = S.NO_ANALYST;
@@ -293,16 +298,9 @@ export class ProjectOverlay {
293
298
  const overrideNote = entry.assignmentSource === "override" ? theme.fg("accent", " (override)") : "";
294
299
  // Role + model are the real primary information here — explicit
295
300
  // `text` color, never left to default/muted.
296
- // WHY this model was picked — the real, human-readable evidence
297
- // buildProjectStrategy already carries (entry.reason, sourced from
298
- // efficientTeam's own describeEfficiencyDecision), never a
299
- // fabricated justification. An override has no ranking reason of
300
- // its own (see applyProjectTeamOverride) — honestly say so instead
301
- // of silently reusing the old recommendation's reason for a
302
- // different model.
303
- const description = entry.assignmentSource === "override"
304
- ? "Manual override — not the automatic ranking's own pick."
305
- : (entry.reason ?? "");
301
+ // WHY this model was picked — explainTeamDecision (real reason,
302
+ // leader formulation, or override text), never an empty row.
303
+ const description = explainTeamDecision(entry);
306
304
  return { value: entry.role, label: theme.fg("text", `${entry.role.padEnd(10)} ${modelText}`) + overrideNote, description };
307
305
  });
308
306
  this.resultSelectList = new SelectList(items, 6, editorTheme.selectList);
@@ -481,6 +479,13 @@ export class ProjectOverlay {
481
479
  // scratch) — distinct from editing one role's model (Enter) or
482
480
  // approving the current suggestion (a) — see reanalyze()'s own doc.
483
481
  if (data === "r" || data === "R") return void this.reanalyze();
482
+ // "e" toggles the technical evidence section (kept off by default
483
+ // so the modal stays short).
484
+ if (data === "e" || data === "E") {
485
+ this.showTeamEvidence = !this.showTeamEvidence;
486
+ this.requestRender();
487
+ return;
488
+ }
484
489
  this.resultSelectList.handleInput(data);
485
490
  this.requestRender();
486
491
  return;
@@ -613,16 +618,25 @@ export class ProjectOverlay {
613
618
  case S.RESULT: {
614
619
  const strategy = this.suggestedStrategy;
615
620
  push(theme.bold("Suggested Project Team"));
621
+ push(theme.fg("muted", "Operational picks: the efficient model among eligible candidates for each role (quality leader shown when it differs)."));
616
622
  const choiceNote = strategy.bootstrapAnalystChoice ?? (strategy.bootstrapAnalystSelectionSource === "manual" ? "manual pick" : "recommended");
617
623
  push(theme.fg("muted", `Project Analyst: ${choiceNote} — ${this.view.aiTeamLabelWithProvider(strategy.bootstrapAnalyst)}`));
618
624
  push(theme.bold("PROJECT TEAM"));
619
625
  box.addChild(this.resultSelectList);
626
+ if (this.showTeamEvidence && typeof this.view.projectTeamEvidenceLines === "function") {
627
+ const eligibility = this.view.snapshot?.modelIntelligence?.eligibility ?? {};
628
+ const claudeEntitlement = this.view.snapshot?.modelIntelligence?.claudeEntitlement ?? {};
629
+ push(theme.fg("muted", "Evidence"));
630
+ for (const line of this.view.projectTeamEvidenceLines(strategy, { eligibility, claudeEntitlement })) {
631
+ push(line);
632
+ }
633
+ }
620
634
  // Quality/Efficient stay real, comparative REFERENCE — muted, and
621
635
  // rendered strictly below the real operational PROJECT TEAM list
622
636
  // above, never replacing it visually.
623
637
  for (const line of teamLines("Quality (reference)", strategy.qualityTeam, this.view, "muted")) push(line);
624
638
  for (const line of teamLines("Efficient (reference)", strategy.efficientTeam, this.view, "muted")) push(line);
625
- push(theme.fg("muted", "Enter edit role · a approve & activate · r re-analyze from scratch · Esc close without approving"));
639
+ push(theme.fg("muted", "Enter edit role · a approve & activate · e evidence · r re-analyze from scratch · Esc close without approving"));
626
640
  break;
627
641
  }
628
642
  case S.EDIT_LOADING:
@@ -3,6 +3,85 @@ import { buildTaskRows, clampSelection, isActionAvailable } from "./rows.js";
3
3
  import { CARD_TONE, cardBottom, cardInnerWidth, cardLine, cardTop, renderPanel as renderPanelWithTheme } from "./card.js";
4
4
  import { theme } from "./theme.js";
5
5
  import { LOW_QUOTA_WARN_PERCENT } from "../intelligence/execution-router.js";
6
+ import { ENTITLEMENT } from "../observability/claude-model-entitlement.js";
7
+
8
+ /** Plain-language description of what each role optimizes for — mirrors
9
+ * buildAiTeamRoleDefinitions()'s real compute functions in
10
+ * model-intelligence.js, never a per-model claim, so it never needs
11
+ * updating when the underlying models change. */
12
+ export const ROLE_CAPABILITY_BLURB = {
13
+ Explorer: "general reasoning capability",
14
+ Architect: "general reasoning capability",
15
+ Builder: "coding capability",
16
+ Debugger: "reasoning and terminal-debugging capability",
17
+ Tester: "coding and terminal-execution capability",
18
+ Reviewer: "independent reasoning and coding review"
19
+ };
20
+
21
+ const OVERRIDE_DECISION_TEXT = "Manual override — not the automatic ranking's own pick.";
22
+
23
+ /**
24
+ * Pure, human-readable why for one team assignment — from real
25
+ * `reason` / `decisionEvidence` only, never invented metrics.
26
+ * Existing `entry.reason` always wins; overrides use a single formulation.
27
+ * @param {{role?: string, reason?: string|null, assignmentSource?: string|null, decisionEvidence?: object|null}} entry
28
+ * @returns {string}
29
+ */
30
+ export function explainTeamDecision(entry) {
31
+ if (!entry) return `Selected for ${ROLE_CAPABILITY_BLURB.Explorer ?? "this role's capability requirement"}.`;
32
+ if (entry.assignmentSource === "override") return OVERRIDE_DECISION_TEXT;
33
+ if (entry.reason) return entry.reason;
34
+
35
+ const blurb = ROLE_CAPABILITY_BLURB[entry.role] ?? "this role's capability requirement";
36
+ const decisionType = entry.decisionEvidence?.decisionType ?? null;
37
+ if (decisionType === "leader") {
38
+ const floor = entry.decisionEvidence?.requiredFloor;
39
+ const risk = entry.decisionEvidence?.riskLevel;
40
+ if (floor != null && risk != null) {
41
+ const floorPct = Math.round(floor * 100);
42
+ return `Ranked first for ${blurb} among eligible candidates — nothing cheaper or faster displaced it at the ${floorPct}% capability floor (${risk}-risk role).`;
43
+ }
44
+ return `Ranked first for ${blurb} among eligible candidates.`;
45
+ }
46
+ return `Selected for ${blurb}.`;
47
+ }
48
+
49
+ /**
50
+ * Live availability for a projectTeam model ref (which has no
51
+ * `available` flag). Entitlement beats adapter quota/eligibility.
52
+ * @param {object|null|undefined} model
53
+ * @param {{eligibility?: Record<string, {ok: boolean, reason?: string}>, claudeEntitlement?: Record<string, {status: string, reason?: string|null}>}} [opts]
54
+ * @returns {{available: boolean, warning: string|null}}
55
+ */
56
+ export function resolveAssignmentAvailability(model, { eligibility = {}, claudeEntitlement = {} } = {}) {
57
+ if (!model) return { available: false, warning: null };
58
+
59
+ if (model.adapterId === "claude") {
60
+ const entitlement = claudeEntitlement[model.modelId];
61
+ if (entitlement?.status === ENTITLEMENT.DENIED) {
62
+ const reason = entitlement.reason ?? "denied";
63
+ return {
64
+ available: false,
65
+ warning: `Unavailable — your Claude plan denies this model (${reason})`
66
+ };
67
+ }
68
+ if (entitlement?.status === ENTITLEMENT.UNVERIFIED) {
69
+ return {
70
+ available: false,
71
+ warning: "Unavailable — model entitlement not verified (run /models --verify-access)"
72
+ };
73
+ }
74
+ }
75
+
76
+ const check = eligibility[model.adapterId];
77
+ if (check && check.ok === false) {
78
+ return {
79
+ available: false,
80
+ warning: `Unavailable — ${check.reason ?? "not eligible"}`
81
+ };
82
+ }
83
+ return { available: true, warning: null };
84
+ }
6
85
 
7
86
  /**
8
87
  * A real, early heads-up — never fabricated, never re-deriving its own
@@ -580,8 +659,12 @@ export class CockpitView {
580
659
  const modelColumnWidth = this.teamModelColumnWidth([
581
660
  strategy.bootstrapAnalyst, strategy.orchestrator, ...projectTeam.map((entry) => entry.model)
582
661
  ]);
662
+ lines.push(theme.fg("muted", "Operational picks: the efficient model among eligible candidates for each role (quality leader shown when it differs)."));
583
663
  if (strategy.bootstrapAnalyst) lines.push(`${"Project Analyst".padEnd(18)} ${this.teamRoleLabel(strategy.bootstrapAnalyst, modelColumnWidth)}`);
584
- if (strategy.orchestrator) lines.push(`${"Orchestrator".padEnd(18)} ${this.teamRoleLabel(strategy.orchestrator, modelColumnWidth)}`);
664
+ if (strategy.orchestrator) {
665
+ lines.push(`${"Orchestrator*".padEnd(18)} ${this.teamRoleLabel(strategy.orchestrator, modelColumnWidth)}`);
666
+ lines.push(theme.fg("muted", " * Architect's quality pick — Kairo has no separate orchestrator capability profile yet."));
667
+ }
585
668
  // The real OPERATIONAL team (see buildProjectStrategy's own doc) —
586
669
  // never qualityTeam, which is comparative reference only. The overlay
587
670
  // (project-overlay.js) already shows projectTeam under this exact
@@ -589,14 +672,42 @@ export class CockpitView {
589
672
  // same label was the real, reported mismatch. qualityTeam only
590
673
  // remains as a fallback for a strategy persisted before projectTeam
591
674
  // existed (see applyProjectTeamOverride's own legacy-entry comment).
675
+ const eligibility = this.snapshot?.modelIntelligence?.eligibility ?? {};
676
+ const claudeEntitlement = this.snapshot?.modelIntelligence?.claudeEntitlement ?? {};
592
677
  for (const entry of projectTeam) {
593
678
  lines.push(`${entry.role.padEnd(18)} ${entry.model ? this.teamRoleLabel(entry.model, modelColumnWidth) : theme.fg("warning", "no eligible option")}`);
679
+ const extra = this.projectTeamCompactExtraLine(entry, strategy, { eligibility, claudeEntitlement });
680
+ if (extra) lines.push(theme.fg("muted", ` ${extra}`));
594
681
  }
595
682
  if (strategy.status === "suggested") lines.push(theme.fg("muted", "Suggested from real project analysis. Use /project approve to activate."));
596
683
  if (strategy.status === "stale") lines.push(theme.fg("warning", "Real evidence changed since approval — use /project refresh."));
597
684
  return { title: `PROJECT TEAM · ${project} · ${strategy.status.toUpperCase()}`, lines };
598
685
  }
599
686
 
687
+ /**
688
+ * At most one muted extra line for the compact PROJECT TEAM panel —
689
+ * only when it adds something the role row itself doesn't already say
690
+ * (quality leader differs, or a real availability warning). Same pick
691
+ * + available → null.
692
+ */
693
+ projectTeamCompactExtraLine(entry, strategy, { eligibility = {}, claudeEntitlement = {} } = {}) {
694
+ const availability = resolveAssignmentAvailability(entry?.model, { eligibility, claudeEntitlement });
695
+ if (availability.warning) return availability.warning;
696
+ if (!strategy?.qualityTeam || !entry?.model) return null;
697
+ const qualityEntry = strategy.qualityTeam.find((row) => row.role === entry.role);
698
+ if (!qualityEntry?.model) return null;
699
+ const samePick = qualityEntry.model.adapterId === entry.model.adapterId
700
+ && qualityEntry.model.modelId === entry.model.modelId;
701
+ if (samePick) return null;
702
+ const retention = entry.decisionEvidence?.retention;
703
+ const retentionPct = retention != null ? Math.round(retention * 100) : null;
704
+ const leaderLabel = this.aiTeamLabel(qualityEntry.model);
705
+ if (retentionPct != null) {
706
+ return `Quality leader: ${leaderLabel} — operational pick retains ${retentionPct}%`;
707
+ }
708
+ return `Quality leader: ${leaderLabel}`;
709
+ }
710
+
600
711
  /** Contextual key hints — shown in the dashboard's fixed zone, not the scrollable conversation. */
601
712
  renderFooterLines() {
602
713
  const row = this.selectedRow();
@@ -899,27 +1010,15 @@ export class CockpitView {
899
1010
  return lines;
900
1011
  }
901
1012
 
902
- /** Plain-language description of what each role optimizes for — mirrors
903
- * buildAiTeamRoleDefinitions()'s real compute functions in
904
- * model-intelligence.js, never a per-model claim, so it never needs
905
- * updating when the underlying models change. */
906
- static ROLE_CAPABILITY_BLURB = {
907
- Explorer: "general reasoning capability",
908
- Architect: "general reasoning capability",
909
- Builder: "coding capability",
910
- Debugger: "reasoning and terminal-debugging capability",
911
- Tester: "coding and terminal-execution capability",
912
- Reviewer: "independent reasoning and coding review"
913
- };
1013
+ static ROLE_CAPABILITY_BLURB = ROLE_CAPABILITY_BLURB;
914
1014
 
915
1015
  /**
916
1016
  * The default, human-readable `/models` output: per role, the selected
917
- * model, why (the real distribution-policy reason when there is one,
918
- * else the role's plain-language capability requirement), the
919
- * EFFICIENT TEAM alternative when it actually differs, and the real
920
- * fallback used if the selection becomes unavailable. Deliberately no
921
- * raw metrics, percentages, internal ids, or source names — that detail
922
- * moves to /models --evidence (aiTeamDetailLines()) instead.
1017
+ * model, why (via explainTeamDecision — real reason or leader/blurb
1018
+ * formulation), the EFFICIENT TEAM alternative when it actually differs,
1019
+ * and the real fallback used if the selection becomes unavailable.
1020
+ * Deliberately no raw metrics, percentages, internal ids, or source
1021
+ * names that detail moves to /models --evidence (aiTeamDetailLines()).
923
1022
  */
924
1023
  modelsExplainLines() {
925
1024
  const intel = this.snapshot?.modelIntelligence;
@@ -930,7 +1029,8 @@ export class CockpitView {
930
1029
  const leaderByRole = Object.fromEntries((intel.globalGuide?.capability ?? []).map((entry) => [entry.role, entry]));
931
1030
  const freshness = intel.status === "live" ? "live" : `cached ${intel.age ?? "?"}`;
932
1031
  const lines = [theme.fg("muted", `Evidence: ${freshness}`)];
933
- aiTeam.forEach(({ role, primary, fallback, reason }, index) => {
1032
+ aiTeam.forEach((entry, index) => {
1033
+ const { role, primary, fallback } = entry;
934
1034
  // A blank string here would get silently dropped once routed through
935
1035
  // the persisted chat transcript (addTranscript trims and discards
936
1036
  // empty text) — a visible divider is the only separator that
@@ -938,8 +1038,7 @@ export class CockpitView {
938
1038
  if (index > 0) lines.push(theme.fg("muted", "·"));
939
1039
  const availabilityNote = primary.available ? "" : " (currently unavailable)";
940
1040
  lines.push(`${role.padEnd(10)} ${this.aiTeamLabel(primary)}${availabilityNote}`);
941
- const why = reason ?? `Selected for ${CockpitView.ROLE_CAPABILITY_BLURB[role] ?? "this role's capability requirement"}.`;
942
- lines.push(theme.fg("muted", ` ${why}`));
1041
+ lines.push(theme.fg("muted", ` ${explainTeamDecision(entry)}`));
943
1042
 
944
1043
  // The uncoordinated individual leader (globalGuide) — evidence for
945
1044
  // "what's honestly best with nothing else in play?", never the
@@ -984,15 +1083,18 @@ export class CockpitView {
984
1083
  * evidence the Model Intelligence Foundation registry has for that exact
985
1084
  * model. Never recalculates anything — every number here was already
986
1085
  * computed during real selection. Shared by AI TEAM and EFFICIENT TEAM
987
- * inside aiTeamDetailLines(); never called on its own.
1086
+ * inside aiTeamDetailLines(); also reused by projectTeamEvidenceLines
1087
+ * via the optional `extraLinesFor` hook (never forked).
988
1088
  * @param {Array<object>} team
1089
+ * @param {{extraLinesFor?: (entry: object) => string[]|null|undefined}} [options]
989
1090
  */
990
- teamEvidenceLines(team) {
1091
+ teamEvidenceLines(team, { extraLinesFor } = {}) {
991
1092
  const lines = [];
992
1093
  const corroborationLine = (model) => (model.corroboration ?? [])
993
1094
  .map((entry) => `${entry.metric}=${entry.value} (${entry.source})`)
994
1095
  .join(" · ");
995
- team.forEach(({ role, primary, fallback, reason, coverage, confidence, decisionEvidence }, index) => {
1096
+ team.forEach((entry, index) => {
1097
+ const { role, primary, fallback, reason, coverage, confidence, decisionEvidence } = entry;
996
1098
  // A blank string here would get silently dropped once this line is
997
1099
  // routed through the persisted chat transcript (addTranscript trims
998
1100
  // and discards empty text) — a visible divider is the only separator
@@ -1067,10 +1169,76 @@ export class CockpitView {
1067
1169
  if (fallback) lines.push(theme.fg("muted", ` fallback ${this.aiTeamLabelWithProvider(fallback)}`));
1068
1170
  else if (!primary.available) lines.push(theme.fg("warning", " no eligible fallback right now"));
1069
1171
  if (reason) lines.push(theme.fg("muted", ` ${reason}`));
1172
+ if (extraLinesFor) {
1173
+ for (const line of extraLinesFor(entry) ?? []) lines.push(line);
1174
+ }
1070
1175
  });
1071
1176
  return lines;
1072
1177
  }
1073
1178
 
1179
+ /**
1180
+ * Thin adapter: maps strategy.projectTeam entries onto teamEvidenceLines'
1181
+ * primary/fallback shape, resolving availability explicitly (projectModelRef
1182
+ * has no `available`), and injecting quality-leader + entitlement warnings
1183
+ * via extraLinesFor — never a forked evidence renderer.
1184
+ * @param {object} strategy
1185
+ * @param {{eligibility?: object, claudeEntitlement?: object}} [opts]
1186
+ */
1187
+ projectTeamEvidenceLines(strategy, { eligibility = {}, claudeEntitlement = {} } = {}) {
1188
+ const projectTeam = strategy?.projectTeam ?? [];
1189
+ const hasQualityTeam = Array.isArray(strategy?.qualityTeam);
1190
+ const qualityByRole = new Map((strategy?.qualityTeam ?? []).map((row) => [row.role, row]));
1191
+
1192
+ const team = projectTeam.map((entry) => {
1193
+ const primaryAvailability = resolveAssignmentAvailability(entry.model, { eligibility, claudeEntitlement });
1194
+ const fallbackAvailability = entry.fallback
1195
+ ? resolveAssignmentAvailability(entry.fallback, { eligibility, claudeEntitlement })
1196
+ : null;
1197
+ return {
1198
+ role: entry.role,
1199
+ primary: entry.model
1200
+ ? { ...entry.model, available: primaryAvailability.available }
1201
+ : { adapterId: "?", modelId: "?", displayName: "no eligible option", available: false },
1202
+ fallback: entry.fallback
1203
+ ? { ...entry.fallback, available: fallbackAvailability.available }
1204
+ : null,
1205
+ reason: entry.reason ?? null,
1206
+ decisionEvidence: entry.decisionEvidence ?? null,
1207
+ coverage: entry.coverage,
1208
+ confidence: entry.confidence,
1209
+ _availabilityWarning: primaryAvailability.warning,
1210
+ _qualityEntry: qualityByRole.get(entry.role) ?? null
1211
+ };
1212
+ });
1213
+
1214
+ return this.teamEvidenceLines(team, {
1215
+ extraLinesFor: (mapped) => {
1216
+ const extra = [];
1217
+ if (mapped._availabilityWarning) {
1218
+ extra.push(theme.fg("warning", ` ${mapped._availabilityWarning}`));
1219
+ }
1220
+ if (!hasQualityTeam) return extra;
1221
+ const qualityEntry = mapped._qualityEntry;
1222
+ if (!qualityEntry?.model || !mapped.primary?.modelId) return extra;
1223
+ const samePick = qualityEntry.model.adapterId === mapped.primary.adapterId
1224
+ && qualityEntry.model.modelId === mapped.primary.modelId;
1225
+ if (samePick) {
1226
+ extra.push(theme.fg("muted", " Also the quality leader for this role."));
1227
+ return extra;
1228
+ }
1229
+ const retention = mapped.decisionEvidence?.retention;
1230
+ const retentionPct = retention != null ? Math.round(retention * 100) : null;
1231
+ const leaderLabel = this.aiTeamLabel(qualityEntry.model);
1232
+ if (retentionPct != null) {
1233
+ extra.push(theme.fg("muted", ` Quality leader: ${leaderLabel} — operational pick retains ${retentionPct}%`));
1234
+ } else {
1235
+ extra.push(theme.fg("muted", ` Quality leader: ${leaderLabel}`));
1236
+ }
1237
+ return extra;
1238
+ }
1239
+ });
1240
+ }
1241
+
1074
1242
  /**
1075
1243
  * `/models --evidence`: the full breakdown behind both AI TEAM and
1076
1244
  * EFFICIENT TEAM picks — real provider, primary, availability, fallback,
@@ -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.29.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.29.0" },
155
155
  capabilities: {}
156
156
  });
157
157
  });