@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 +30 -0
- package/package.json +1 -1
- package/src/global/cockpit/app.js +36 -3
- package/src/global/cockpit/project-overlay.js +25 -11
- package/src/global/cockpit/view.js +193 -25
- package/src/global/conversation/service.js +125 -2
- package/src/global/observability/codex-models.js +1 -1
- package/src/global/observability/codex-usage.js +1 -1
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.
|
|
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
|
-
|
|
275
|
-
|
|
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 —
|
|
297
|
-
//
|
|
298
|
-
|
|
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)
|
|
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
|
-
|
|
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 (
|
|
918
|
-
*
|
|
919
|
-
*
|
|
920
|
-
*
|
|
921
|
-
*
|
|
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((
|
|
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
|
-
|
|
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();
|
|
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((
|
|
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
|
-
|
|
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.
|
|
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.
|
|
154
|
+
clientInfo: { name: "kairo", title: "Kairo", version: "0.29.0" },
|
|
155
155
|
capabilities: {}
|
|
156
156
|
});
|
|
157
157
|
});
|