@kal-elsam/kairo-runtime 0.16.0 → 0.18.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 +79 -0
- package/package.json +2 -1
- package/scripts/cockpit-smoke.mjs +1 -1
- package/scripts/ux-smoke-test.sh +3 -3
- package/src/cli.js +96 -11
- package/src/global/agent-capabilities/create-capability-adapter.js +2 -2
- package/src/global/architect/architect-cli.js +76 -0
- package/src/global/architect/architect-codex.js +146 -0
- package/src/global/architect/architect-manager.js +125 -0
- package/src/global/architect/architect-store.js +377 -0
- package/src/global/architect/architect-types.js +47 -0
- package/src/global/cli-help.js +10 -1
- package/src/global/cockpit/app.js +493 -0
- package/src/global/cockpit/card.js +111 -0
- package/src/global/cockpit/cli.js +33 -0
- package/src/global/cockpit/gauge.js +31 -0
- package/src/global/cockpit/project-overlay.js +693 -0
- package/src/global/cockpit/rows.js +148 -0
- package/src/global/cockpit/theme.js +118 -0
- package/src/global/cockpit/view.js +1298 -0
- package/src/global/conversation/bootstrap-analyzer-adapters.js +251 -0
- package/src/global/conversation/cli.js +53 -0
- package/src/global/conversation/codex-sandbox.js +230 -0
- package/src/global/conversation/cursor-sandbox.js +215 -0
- package/src/global/conversation/project-analysis.js +204 -0
- package/src/global/conversation/project-profile.js +178 -0
- package/src/global/conversation/project-router.js +149 -0
- package/src/global/conversation/project-strategy-store.js +64 -0
- package/src/global/conversation/project-strategy.js +514 -0
- package/src/global/conversation/sanitized-snapshot.js +169 -0
- package/src/global/conversation/secret-scanner.js +71 -0
- package/src/global/conversation/service.js +1090 -0
- package/src/global/conversation/session-store.js +75 -0
- package/src/global/conversation/transcript-store.js +79 -0
- package/src/global/conversation/ui.js +195 -0
- package/src/global/intelligence/capability-scoring.js +480 -0
- package/src/global/intelligence/execution-router.js +466 -0
- package/src/global/intelligence/kairo-telemetry-source.js +59 -0
- package/src/global/intelligence/kairobench-runner.js +85 -0
- package/src/global/intelligence/kairobench-source.js +34 -0
- package/src/global/intelligence/kairobench-tasks.js +47 -0
- package/src/global/intelligence/model-candidate-catalog.js +456 -0
- package/src/global/intelligence/model-capability-registry-sources.js +145 -0
- package/src/global/intelligence/model-capability-registry.js +125 -0
- package/src/global/intelligence/model-intelligence.js +1646 -0
- package/src/global/intelligence/official-benchmark-snapshots.js +162 -0
- package/src/global/intelligence/quick-ask.js +149 -0
- package/src/global/intelligence/role-profiles.js +251 -0
- package/src/global/intelligence/skill-catalog.js +67 -0
- package/src/global/intelligence/subscription-pressure-source.js +41 -0
- package/src/global/mcp/kairo-mcp.js +51 -18
- package/src/global/mcp/work-snapshot-rule.js +4 -2
- package/src/global/mcp/workspace-binding.js +88 -0
- package/src/global/mcp/workspace-mcp-entry.js +74 -0
- package/src/global/mcp-install.js +8 -1
- package/src/global/observability/artificial-analysis-models.js +118 -0
- package/src/global/observability/claude-models.js +31 -0
- package/src/global/observability/claude-usage.js +112 -0
- package/src/global/observability/codex-models.js +96 -0
- package/src/global/observability/codex-usage.js +160 -0
- package/src/global/observability/cursor-auth.js +88 -0
- package/src/global/observability/cursor-models.js +101 -0
- package/src/global/observability/huggingface-leaderboard.js +97 -0
- package/src/global/observability/opencode-models.js +101 -0
- package/src/global/observability/opencode-usage.js +162 -0
- package/src/global/paths.js +49 -2
- package/src/global/profile.js +23 -1
- package/src/global/runtime/execution-adapters/claude.js +63 -30
- package/src/global/runtime/execution-adapters/codex.js +9 -2
- package/src/global/runtime/execution-adapters/create-execution-adapter.js +6 -1
- package/src/global/runtime/execution-adapters/opencode.js +83 -18
- package/src/global/runtime/execution-worktree-manager.js +924 -0
- package/src/global/runtime/execution-worktree-orchestrator.js +194 -0
- package/src/global/runtime/execution-worktree-store.js +83 -0
- package/src/global/runtime/execution-worktree-types.js +45 -0
- package/src/global/runtime/run-events.js +38 -0
- package/src/global/runtime/run-manager.js +22 -6
- package/src/global/runtime/run-supervisor.js +41 -12
- package/src/global/runtime/usage-manager.js +96 -0
- package/src/global/runtime/usage-store.js +69 -0
- package/src/global/runtime/usage-types.js +62 -0
|
@@ -0,0 +1,1090 @@
|
|
|
1
|
+
import { createArchitecturePlan } from "../architect/architect-manager.js";
|
|
2
|
+
import {
|
|
3
|
+
listTaskRecords, readExecutionLink, readTaskRecord, resolveProjectRoot, transitionTask,
|
|
4
|
+
updateExecutionLink, verifyPlanForExecution, writeExecutionLink
|
|
5
|
+
} from "../architect/architect-store.js";
|
|
6
|
+
import { PLAN_STATES } from "../architect/architect-types.js";
|
|
7
|
+
import { resolveHomeDir } from "../paths.js";
|
|
8
|
+
import { listRunRecords, readRunEvents, readRunState } from "../runtime/run-store.js";
|
|
9
|
+
import { recoverRuns, startRun, stopRun } from "../runtime/run-manager.js";
|
|
10
|
+
import { createRunId, isActiveRunState } from "../runtime/run-types.js";
|
|
11
|
+
import { formatTranscriptEventText } from "../runtime/run-events.js";
|
|
12
|
+
import { inspectExecutionAdapters } from "../runtime/execution-adapters/index.js";
|
|
13
|
+
import { inspectEngramIntegration } from "../integrations/engram-evidence.js";
|
|
14
|
+
import { hasFiniteUsage } from "../ink/cockpit-usage.js";
|
|
15
|
+
import { readCodexUsage } from "../observability/codex-usage.js";
|
|
16
|
+
import { readClaudeUsage } from "../observability/claude-usage.js";
|
|
17
|
+
import { readOpenCodeUsage, readOpenCodeGoUsage, readOpenCodeStats } from "../observability/opencode-usage.js";
|
|
18
|
+
import { readCodexModels } from "../observability/codex-models.js";
|
|
19
|
+
import { readOpenCodeModels } from "../observability/opencode-models.js";
|
|
20
|
+
import { readClaudeModels } from "../observability/claude-models.js";
|
|
21
|
+
import { readCursorModels } from "../observability/cursor-models.js";
|
|
22
|
+
import { checkCandidate, isLikelyQuestion, selectAskProvider } from "../intelligence/execution-router.js";
|
|
23
|
+
import { readSkillCatalog } from "../intelligence/skill-catalog.js";
|
|
24
|
+
import { askProvider } from "../intelligence/quick-ask.js";
|
|
25
|
+
import { appendTranscriptEntry, clearTranscript, readTranscript } from "./transcript-store.js";
|
|
26
|
+
import { readSession, writeSessionMode } from "./session-store.js";
|
|
27
|
+
import { computeProjectProfile } from "./project-profile.js";
|
|
28
|
+
import {
|
|
29
|
+
buildProjectStrategy, computeBootstrapAnalystAlternatives, computeBootstrapAnalystCatalog, isStrategyStale,
|
|
30
|
+
computeProjectTeamEditCatalog, applyProjectTeamOverride, resetProjectTeamAssignment
|
|
31
|
+
} from "./project-strategy.js";
|
|
32
|
+
import { buildAnalystPrompt, deriveRoleRequirements, parseProjectAnalysis } from "./project-analysis.js";
|
|
33
|
+
import { buildSanitizedSnapshot } from "./sanitized-snapshot.js";
|
|
34
|
+
import { runCodexSandboxedBootstrap } from "./codex-sandbox.js";
|
|
35
|
+
import { createBootstrapAnalyzerAdapter } from "./bootstrap-analyzer-adapters.js";
|
|
36
|
+
import { verifyClaudeSubscriptionAuth } from "../runtime/execution-adapters/claude.js";
|
|
37
|
+
import { readProjectStrategy, writeProjectStrategy } from "./project-strategy-store.js";
|
|
38
|
+
import { readProviderUsage, writeProviderUsage } from "../runtime/usage-store.js";
|
|
39
|
+
import { resolveProjectRoute } from "./project-router.js";
|
|
40
|
+
import { readArtificialAnalysisModels } from "../observability/artificial-analysis-models.js";
|
|
41
|
+
import { readHuggingFaceLeaderboard } from "../observability/huggingface-leaderboard.js";
|
|
42
|
+
import {
|
|
43
|
+
annotateWithRegistryEvidence, bestEfficientModelPerRoleGlobal, bestModelPerRole, bestModelPerRoleGlobal, buildAiTeam,
|
|
44
|
+
buildEfficientTeam, scoreAvailableModels, summarizeCatalogCoverage
|
|
45
|
+
} from "../intelligence/model-intelligence.js";
|
|
46
|
+
import { buildAutomaticExecutionPool, buildCompleteCandidateCatalog, buildRecommendationPool } from "../intelligence/model-candidate-catalog.js";
|
|
47
|
+
import { createCapabilityRegistry } from "../intelligence/model-capability-registry.js";
|
|
48
|
+
import { ingestArtificialAnalysisEvidence, ingestHuggingFaceLeaderboardEvidence } from "../intelligence/model-capability-registry-sources.js";
|
|
49
|
+
import { ingestOfficialSnapshotEvidence } from "../intelligence/official-benchmark-snapshots.js";
|
|
50
|
+
import { ingestKairoTelemetryEvidence } from "../intelligence/kairo-telemetry-source.js";
|
|
51
|
+
import { buildProviderCapacity } from "../intelligence/subscription-pressure-source.js";
|
|
52
|
+
|
|
53
|
+
export const CONVERSATION_SCHEMA = "kairo.conversation/v1";
|
|
54
|
+
|
|
55
|
+
// The Bootstrap Analyst investigates a real project (reads real files,
|
|
56
|
+
// not just answering from the prompt text) — genuinely slower than a
|
|
57
|
+
// quick ASK-mode question, so it gets real room instead of quick-ask's
|
|
58
|
+
// 30s default.
|
|
59
|
+
const BOOTSTRAP_ANALYST_TIMEOUT_MS = 180_000;
|
|
60
|
+
|
|
61
|
+
function publicPlan(record, execution = null) {
|
|
62
|
+
const status = record.status ?? record;
|
|
63
|
+
return {
|
|
64
|
+
taskId: status.taskId,
|
|
65
|
+
taskText: status.taskText ?? null,
|
|
66
|
+
state: status.state,
|
|
67
|
+
provider: status.provider,
|
|
68
|
+
model: status.model ?? null,
|
|
69
|
+
baseHead: status.baseHead,
|
|
70
|
+
createdAt: status.createdAt,
|
|
71
|
+
updatedAt: status.updatedAt,
|
|
72
|
+
error: status.error ?? null,
|
|
73
|
+
artifacts: status.artifacts,
|
|
74
|
+
planReady: ![PLAN_STATES.DRAFT, PLAN_STATES.FAILED].includes(status.state),
|
|
75
|
+
approval: status.state === PLAN_STATES.APPROVED
|
|
76
|
+
? "approved"
|
|
77
|
+
: status.state === PLAN_STATES.REJECTED ? "rejected" : "not_decided",
|
|
78
|
+
execution: execution ?? {
|
|
79
|
+
state: "not_started",
|
|
80
|
+
provider: "claude",
|
|
81
|
+
message: status.state === PLAN_STATES.APPROVED
|
|
82
|
+
? "Approved plan is ready for explicit Claude execution."
|
|
83
|
+
: "Approval is required before execution."
|
|
84
|
+
}
|
|
85
|
+
};
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* Maps execution-adapter availability into the cockpit's `providers` shape,
|
|
90
|
+
* keyed by each adapter's display label (e.g. "Codex", "Claude") so
|
|
91
|
+
* `cockpit/view.js`'s providerLine() lookups resolve to real data instead
|
|
92
|
+
* of its hardcoded fallback text.
|
|
93
|
+
* @param {ReturnType<typeof inspectExecutionAdapters>} adapters
|
|
94
|
+
*/
|
|
95
|
+
// cockpit/view.js's providerLine() looks up "Claude" — the claude adapter's
|
|
96
|
+
// own display label is "Claude Code" (claude.js's `label`), so it needs an
|
|
97
|
+
// explicit override here rather than relying on the label verbatim.
|
|
98
|
+
const PROVIDER_DISPLAY_NAME = { claude: "Claude" };
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* Sums real, auditable token usage (`run.tokenUsage`, emitted by each
|
|
102
|
+
* adapter's own parseEventLine — see run-events.js) per agentId across
|
|
103
|
+
* recent Kairo-launched runs. This is measured consumption from runs Kairo
|
|
104
|
+
* itself started — separate from provider account-level subscription quota.
|
|
105
|
+
* @param {Array<object>} runs - listRunRecords() results
|
|
106
|
+
* @returns {Record<string, {total: number, runCount: number}>}
|
|
107
|
+
*/
|
|
108
|
+
function aggregateRunUsageByAgent(runs) {
|
|
109
|
+
const byAgent = {};
|
|
110
|
+
for (const run of runs) {
|
|
111
|
+
if (!hasFiniteUsage(run?.tokenUsage)) continue;
|
|
112
|
+
const agentId = run.agentId ?? "unknown";
|
|
113
|
+
const bucket = byAgent[agentId] ?? { total: 0, runCount: 0 };
|
|
114
|
+
bucket.total += Number.isFinite(run.tokenUsage.total) ? run.tokenUsage.total : 0;
|
|
115
|
+
bucket.runCount += 1;
|
|
116
|
+
byAgent[agentId] = bucket;
|
|
117
|
+
}
|
|
118
|
+
return byAgent;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
function formatCodexUsage(usage) {
|
|
122
|
+
if (!usage || usage.status === "unknown") return "ENABLED · usage unknown";
|
|
123
|
+
const primary = usage.primary ? `5h ${usage.primary.remainingPercent}% left` : null;
|
|
124
|
+
const secondary = usage.secondary ? `weekly ${usage.secondary.remainingPercent}% left` : null;
|
|
125
|
+
const parts = [primary, secondary].filter(Boolean);
|
|
126
|
+
return `${parts.join(" · ") || "usage unavailable"} · ${usage.status}`;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
// Claude Code's own `/usage` is a local_command (intercepted client-side,
|
|
130
|
+
// zero cost — see observability/claude-usage.js), so this mirrors
|
|
131
|
+
// formatCodexUsage's shape: real session/weekly percentages, never invented.
|
|
132
|
+
function formatClaudeUsage(usage) {
|
|
133
|
+
if (!usage || usage.status === "unknown") return "ENABLED · usage unknown";
|
|
134
|
+
const primary = usage.primary ? `${usage.primary.label} ${usage.primary.remainingPercent}% left` : null;
|
|
135
|
+
const secondary = usage.secondary ? `${usage.secondary.label} ${usage.secondary.remainingPercent}% left` : null;
|
|
136
|
+
const parts = [primary, secondary].filter(Boolean);
|
|
137
|
+
return `${parts.join(" · ") || "usage unavailable"} · ${usage.status}`;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
// Real subscription headroom per adapter, for EFFICIENT TEAM's quota-pressure
|
|
141
|
+
// dimension (subscription-pressure-source.js). Quota is account-wide, so this
|
|
142
|
+
// picks the worst-case real window — a provider isn't "healthy" just because
|
|
143
|
+
// its 5h window has room if its weekly window (or, for Go, any one of its
|
|
144
|
+
// windows) is nearly exhausted.
|
|
145
|
+
function remainingPercentByAdapter(codexUsage, claudeUsage, opencodeUsage) {
|
|
146
|
+
const result = {};
|
|
147
|
+
const worstOf = (usage) => {
|
|
148
|
+
const values = [usage?.primary?.remainingPercent, usage?.secondary?.remainingPercent]
|
|
149
|
+
.filter((v) => typeof v === "number");
|
|
150
|
+
return values.length ? Math.min(...values) : null;
|
|
151
|
+
};
|
|
152
|
+
const codexRemaining = worstOf(codexUsage);
|
|
153
|
+
if (codexRemaining != null) result.codex = codexRemaining;
|
|
154
|
+
const claudeRemaining = worstOf(claudeUsage);
|
|
155
|
+
if (claudeRemaining != null) result.claude = claudeRemaining;
|
|
156
|
+
const goWindows = opencodeUsage?.go?.windows ?? [];
|
|
157
|
+
const goValues = goWindows.map((w) => w.remainingPercent).filter((v) => typeof v === "number");
|
|
158
|
+
if (goValues.length) result["opencode-go"] = Math.min(...goValues);
|
|
159
|
+
return result;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
function providersFromAdapters(adapters, usageByAgent = {}, codexUsage = null, claudeUsage = null, opencodeUsage = null) {
|
|
163
|
+
const providers = {};
|
|
164
|
+
for (const adapter of adapters) {
|
|
165
|
+
const state = adapter.available
|
|
166
|
+
? (adapter.launchable ? "ENABLED" : "LIMITED")
|
|
167
|
+
: "MISSING";
|
|
168
|
+
const name = PROVIDER_DISPLAY_NAME[adapter.id] ?? adapter.label;
|
|
169
|
+
const usage = usageByAgent[adapter.id];
|
|
170
|
+
const usageSuffix = usage
|
|
171
|
+
? ` · ${usage.total} tokens (${usage.runCount} run${usage.runCount === 1 ? "" : "s"} via Kairo)`
|
|
172
|
+
: "";
|
|
173
|
+
providers[name] = {
|
|
174
|
+
status: (adapter.reason ? `${state} · ${adapter.reason}` : state) + usageSuffix
|
|
175
|
+
};
|
|
176
|
+
}
|
|
177
|
+
if (codexUsage) providers.Codex = { status: formatCodexUsage(codexUsage), usage: codexUsage };
|
|
178
|
+
if (claudeUsage) providers.Claude = { status: formatClaudeUsage(claudeUsage), usage: claudeUsage };
|
|
179
|
+
if (opencodeUsage && (opencodeUsage.go || opencodeUsage.zen)) {
|
|
180
|
+
const go = opencodeUsage.go;
|
|
181
|
+
const zen = opencodeUsage.zen;
|
|
182
|
+
const goLabel = go?.status === "measured" || go?.status === "rate-limited"
|
|
183
|
+
? (go.windows ?? []).map((w) => `${w.name} ${w.remainingPercent}% left`).join(" · ") : "Go usage unknown";
|
|
184
|
+
const zenLabel = zen?.status === "local_recorded" ? `Zen 7d $${zen.totalCost.toFixed(2)} local` : "Zen 7d local unknown";
|
|
185
|
+
providers.OpenCode = { status: `${goLabel} · ${zenLabel}`, usage: opencodeUsage };
|
|
186
|
+
}
|
|
187
|
+
return providers;
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
/**
|
|
191
|
+
* Maps the Engram integration inspection into the cockpit's `integrations`
|
|
192
|
+
* shape. Other integration lines (MCP/Skills/CodeGraph/Graphify/Gentle)
|
|
193
|
+
* still use view.js's fallback text until a similarly cheap, per-poll-safe
|
|
194
|
+
* inspector exists for each.
|
|
195
|
+
* @param {ReturnType<typeof inspectEngramIntegration>} engram
|
|
196
|
+
*/
|
|
197
|
+
function integrationsFromInspections(engram) {
|
|
198
|
+
return { engram: { status: engram.status } };
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
function snapshot(projectRoot, plans, providers = {}, integrations = {}) {
|
|
202
|
+
return {
|
|
203
|
+
schema: CONVERSATION_SCHEMA,
|
|
204
|
+
projectRoot,
|
|
205
|
+
providers,
|
|
206
|
+
integrations,
|
|
207
|
+
usage: { codex: null, claude: null, opencode: null },
|
|
208
|
+
modelIntelligence: {
|
|
209
|
+
status: "unknown", source: null, age: null, models: [], roles: [], eligibility: {}, coverage: [],
|
|
210
|
+
globalGuide: { capability: [], efficient: [] }, aiTeam: [], efficientTeam: []
|
|
211
|
+
},
|
|
212
|
+
governance: {
|
|
213
|
+
methodologyOwner: "gentle-ai",
|
|
214
|
+
orchestratorOwner: "kairo",
|
|
215
|
+
approvalIsExecutionConsent: false
|
|
216
|
+
},
|
|
217
|
+
capabilities: {
|
|
218
|
+
architecture: true,
|
|
219
|
+
planDecision: true,
|
|
220
|
+
automatedImplementation: true,
|
|
221
|
+
claudeExecution: "subscription_only",
|
|
222
|
+
openCodeExecution: {
|
|
223
|
+
available: false,
|
|
224
|
+
reason: "Unavailable until opencode-go/* is verified and server-side Use balance is disabled."
|
|
225
|
+
}
|
|
226
|
+
},
|
|
227
|
+
timeline: plans
|
|
228
|
+
};
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
export function createConversationService(deps = {}) {
|
|
232
|
+
const resolveRoot = deps.resolveRoot ?? resolveProjectRoot;
|
|
233
|
+
const createPlan = deps.createPlan ?? createArchitecturePlan;
|
|
234
|
+
const listPlans = deps.listPlans ?? listTaskRecords;
|
|
235
|
+
const readPlan = deps.readPlan ?? readTaskRecord;
|
|
236
|
+
const transition = deps.transition ?? transitionTask;
|
|
237
|
+
const homeDir = deps.homeDir ?? resolveHomeDir();
|
|
238
|
+
const readExecution = deps.readExecution ?? readExecutionLink;
|
|
239
|
+
const readRun = deps.readRun ?? readRunState;
|
|
240
|
+
const recover = deps.recoverRuns ?? recoverRuns;
|
|
241
|
+
const verifyExecution = deps.verifyExecution ?? verifyPlanForExecution;
|
|
242
|
+
const launchRun = deps.startRun ?? startRun;
|
|
243
|
+
const cancelRun = deps.stopRun ?? stopRun;
|
|
244
|
+
const reserveExecution = deps.writeExecution ?? writeExecutionLink;
|
|
245
|
+
const updateExecution = deps.updateExecution ?? updateExecutionLink;
|
|
246
|
+
const newRunId = deps.createRunId ?? createRunId;
|
|
247
|
+
const inspectAdapters = deps.inspectExecutionAdapters ?? inspectExecutionAdapters;
|
|
248
|
+
const inspectEngram = deps.inspectEngramIntegration ?? inspectEngramIntegration;
|
|
249
|
+
const listRuns = deps.listRunRecords ?? listRunRecords;
|
|
250
|
+
const readRunEventsImpl = deps.readRunEvents ?? readRunEvents;
|
|
251
|
+
const readCodexUsageImpl = deps.readCodexUsage ?? readCodexUsage;
|
|
252
|
+
const readClaudeUsageImpl = deps.readClaudeUsage ?? readClaudeUsage;
|
|
253
|
+
const readOpenCodeUsageImpl = deps.readOpenCodeUsage
|
|
254
|
+
?? (deps.resolveRoot ? async () => null : readOpenCodeUsage);
|
|
255
|
+
const readOpenCodeGoImpl = deps.readOpenCodeGoUsage
|
|
256
|
+
?? (deps.resolveRoot ? async () => null : readOpenCodeGoUsage);
|
|
257
|
+
const readOpenCodeStatsImpl = deps.readOpenCodeStats
|
|
258
|
+
?? (deps.resolveRoot ? async () => null : readOpenCodeStats);
|
|
259
|
+
const readCodexModelsImpl = deps.readCodexModels ?? readCodexModels;
|
|
260
|
+
const readOpenCodeModelsImpl = deps.readOpenCodeModels ?? readOpenCodeModels;
|
|
261
|
+
const readClaudeModelsImpl = deps.readClaudeModels ?? readClaudeModels;
|
|
262
|
+
const readSkillCatalogImpl = deps.readSkillCatalog ?? readSkillCatalog;
|
|
263
|
+
const routeAsk = deps.selectAskProvider ?? selectAskProvider;
|
|
264
|
+
const askProviderImpl = deps.askProvider ?? askProvider;
|
|
265
|
+
const appendTranscriptImpl = deps.appendTranscriptEntry ?? appendTranscriptEntry;
|
|
266
|
+
const readTranscriptImpl = deps.readTranscript ?? readTranscript;
|
|
267
|
+
const clearTranscriptImpl = deps.clearTranscript ?? clearTranscript;
|
|
268
|
+
const readSessionImpl = deps.readSession ?? readSession;
|
|
269
|
+
const writeSessionModeImpl = deps.writeSessionMode ?? writeSessionMode;
|
|
270
|
+
const computeProjectProfileImpl = deps.computeProjectProfile ?? computeProjectProfile;
|
|
271
|
+
const readProjectStrategyImpl = deps.readProjectStrategy ?? readProjectStrategy;
|
|
272
|
+
const writeProjectStrategyImpl = deps.writeProjectStrategy ?? writeProjectStrategy;
|
|
273
|
+
const readProviderUsageImpl = deps.readProviderUsage ?? readProviderUsage;
|
|
274
|
+
const writeProviderUsageImpl = deps.writeProviderUsage ?? writeProviderUsage;
|
|
275
|
+
const parseProjectAnalysisImpl = deps.parseProjectAnalysis ?? parseProjectAnalysis;
|
|
276
|
+
const deriveRoleRequirementsImpl = deps.deriveRoleRequirements ?? deriveRoleRequirements;
|
|
277
|
+
const buildSanitizedSnapshotImpl = deps.buildSanitizedSnapshot ?? buildSanitizedSnapshot;
|
|
278
|
+
const runCodexSandboxedBootstrapImpl = deps.runCodexSandboxedBootstrap ?? runCodexSandboxedBootstrap;
|
|
279
|
+
const createBootstrapAnalyzerAdapterImpl = deps.createBootstrapAnalyzerAdapter ?? createBootstrapAnalyzerAdapter;
|
|
280
|
+
const codexIsolationDeps = deps.codexIsolationDeps ?? {};
|
|
281
|
+
const verifyClaudeSubscriptionAuthImpl = deps.verifyClaudeSubscriptionAuth ?? verifyClaudeSubscriptionAuth;
|
|
282
|
+
const readArtificialAnalysisModelsImpl = deps.readArtificialAnalysisModels ?? readArtificialAnalysisModels;
|
|
283
|
+
// Unit tests inject resolveRoot and must remain provider-call free. The real
|
|
284
|
+
// cockpit opts in explicitly so a refresh performs one bounded read-only probe.
|
|
285
|
+
const enableProviderProbes = deps.enableProviderProbes ?? !deps.resolveRoot;
|
|
286
|
+
const codexUsageTtlMs = deps.codexUsageTtlMs ?? 60_000;
|
|
287
|
+
const claudeUsageTtlMs = deps.claudeUsageTtlMs ?? 60_000;
|
|
288
|
+
const opencodeUsageTtlMs = deps.opencodeUsageTtlMs ?? 300_000;
|
|
289
|
+
const opencodeGoUsageTtlMs = deps.opencodeGoUsageTtlMs ?? 60_000;
|
|
290
|
+
// Model catalogs and Artificial Analysis's real benchmark scores both
|
|
291
|
+
// change slowly and cost real subprocess spawns / a real network call —
|
|
292
|
+
// snapshot() polls every couple seconds, so these need a much longer TTL
|
|
293
|
+
// than usage, not a fresh read on every poll.
|
|
294
|
+
const modelCatalogTtlMs = deps.modelCatalogTtlMs ?? 600_000;
|
|
295
|
+
const artificialAnalysisTtlMs = deps.artificialAnalysisTtlMs ?? 6 * 60 * 60_000;
|
|
296
|
+
// Same scale as Artificial Analysis: real benchmark leaderboards don't
|
|
297
|
+
// change minute to minute. Telemetry reads local run records already on
|
|
298
|
+
// disk (no network call) but still shouldn't re-scan on every 2s poll.
|
|
299
|
+
const huggingFaceLeaderboardTtlMs = deps.huggingFaceLeaderboardTtlMs ?? 6 * 60 * 60_000;
|
|
300
|
+
const telemetryTtlMs = deps.telemetryTtlMs ?? 30_000;
|
|
301
|
+
const now = deps.now ?? (() => Date.now());
|
|
302
|
+
|
|
303
|
+
// Shared TTL + in-flight-dedupe cache for both provider usage probes:
|
|
304
|
+
// concurrent snapshot() calls collapse into one underlying read, and reads
|
|
305
|
+
// are skipped entirely while a fresh-enough cached value exists.
|
|
306
|
+
function createCachedProbe(readFn, ttlMs) {
|
|
307
|
+
let cache = null;
|
|
308
|
+
let inFlight = null;
|
|
309
|
+
return async function readCached(key, args) {
|
|
310
|
+
if (!enableProviderProbes) return null;
|
|
311
|
+
const currentTime = now();
|
|
312
|
+
if (cache && cache.key === key && currentTime - cache.readAt < ttlMs) return cache.value;
|
|
313
|
+
if (inFlight) return inFlight;
|
|
314
|
+
inFlight = Promise.resolve(readFn(args))
|
|
315
|
+
.then((value) => {
|
|
316
|
+
cache = { key, readAt: now(), value };
|
|
317
|
+
return value;
|
|
318
|
+
})
|
|
319
|
+
.finally(() => { inFlight = null; });
|
|
320
|
+
return inFlight;
|
|
321
|
+
};
|
|
322
|
+
}
|
|
323
|
+
const readCodexUsageCached = createCachedProbe(readCodexUsageImpl, codexUsageTtlMs);
|
|
324
|
+
const readClaudeUsageCached = createCachedProbe(readClaudeUsageImpl, claudeUsageTtlMs);
|
|
325
|
+
const readCursorModelsImpl = deps.readCursorModels ?? readCursorModels;
|
|
326
|
+
const readCodexModelsCached = createCachedProbe(readCodexModelsImpl, modelCatalogTtlMs);
|
|
327
|
+
const readOpenCodeGoModelsCached = createCachedProbe(
|
|
328
|
+
() => readOpenCodeModelsImpl({ provider: "opencode-go" }), modelCatalogTtlMs
|
|
329
|
+
);
|
|
330
|
+
const readCursorModelsCached = createCachedProbe(readCursorModelsImpl, modelCatalogTtlMs);
|
|
331
|
+
const readArtificialAnalysisModelsCached = createCachedProbe(
|
|
332
|
+
(args) => readArtificialAnalysisModelsImpl({ ...args, homeDir }), artificialAnalysisTtlMs
|
|
333
|
+
);
|
|
334
|
+
const readHuggingFaceLeaderboardImpl = deps.readHuggingFaceLeaderboard ?? readHuggingFaceLeaderboard;
|
|
335
|
+
const readHuggingFaceLeaderboardCached = createCachedProbe(
|
|
336
|
+
(datasetId) => readHuggingFaceLeaderboardImpl({ datasetId, homeDir }), huggingFaceLeaderboardTtlMs
|
|
337
|
+
);
|
|
338
|
+
const listRunRecordsImpl = deps.listRunRecords ?? listRunRecords;
|
|
339
|
+
const listRunRecordsCached = createCachedProbe(() => listRunRecordsImpl(homeDir), telemetryTtlMs);
|
|
340
|
+
const readOpenCodeUsageCached = createCachedProbe(readOpenCodeUsageImpl, opencodeUsageTtlMs);
|
|
341
|
+
const readOpenCodeGoCached = createCachedProbe(readOpenCodeGoImpl, opencodeGoUsageTtlMs);
|
|
342
|
+
const readOpenCodeStatsCached = createCachedProbe(readOpenCodeStatsImpl, opencodeUsageTtlMs);
|
|
343
|
+
|
|
344
|
+
async function executionFor(projectRoot, taskId) {
|
|
345
|
+
const link = await readExecution(projectRoot, taskId);
|
|
346
|
+
if (!link) return null;
|
|
347
|
+
const run = await readRun(homeDir, link.runId);
|
|
348
|
+
return {
|
|
349
|
+
runId: link.runId,
|
|
350
|
+
provider: run?.agentId ?? link.agentId ?? "claude",
|
|
351
|
+
state: run?.state ?? link.state ?? "failed",
|
|
352
|
+
active: run ? isActiveRunState(run.state) : false,
|
|
353
|
+
error: run?.error ?? link.error ?? null,
|
|
354
|
+
startedAt: run?.startedAt ?? link.createdAt ?? null,
|
|
355
|
+
updatedAt: run?.updatedAt ?? link.updatedAt ?? null,
|
|
356
|
+
message: run ? `Claude run is ${run.state}.` : (link.error ?? "Claude run record is unavailable.")
|
|
357
|
+
};
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
// The legacy keyword-classification routeExecution() helper that used to
|
|
361
|
+
// back planExecution/executePlan's no-role fallback path was removed
|
|
362
|
+
// here — PROJECT TEAM (resolveProjectRoute, via routeProjectExecution
|
|
363
|
+
// below) is now the sole authority for execution routing (see
|
|
364
|
+
// planExecution's own doc). The underlying selectExecutionProvider
|
|
365
|
+
// classifier itself still exists in execution-router.js but is no
|
|
366
|
+
// longer imported or called anywhere in this file.
|
|
367
|
+
|
|
368
|
+
async function root(cwd) { return resolveRoot(cwd); }
|
|
369
|
+
|
|
370
|
+
/**
|
|
371
|
+
* Projects a real project-router decision into the public
|
|
372
|
+
* ProjectExecutionPreview shape — never recalculates anything the
|
|
373
|
+
* router already decided, only reshapes it (plain `model` id string for
|
|
374
|
+
* launchRun's own contract, alongside the full `modelRef`) and derives
|
|
375
|
+
* `confirmationTarget` — the one real thing left to explicitly confirm,
|
|
376
|
+
* present ONLY when there's something genuinely confirmable:
|
|
377
|
+
* - ROUTED: the real assigned candidate.
|
|
378
|
+
* - WAIT_FOR_PROJECT_TEAM with a real suggestedAlternative: that
|
|
379
|
+
* alternative, never the blocked assignment itself.
|
|
380
|
+
* - Everything else (MANUAL_HANDOFF, or WAIT_FOR_PROJECT_TEAM with no
|
|
381
|
+
* real alternative): null — nothing to confirm into an automatic run.
|
|
382
|
+
* @param {ReturnType<typeof resolveProjectRoute>} route
|
|
383
|
+
*/
|
|
384
|
+
function toExecutionPreview(route) {
|
|
385
|
+
let confirmationTarget = null;
|
|
386
|
+
if (route.decision === "ROUTED" && route.model) {
|
|
387
|
+
confirmationTarget = { role: route.role, selection: "assigned", strategyFingerprint: route.strategyFingerprint, candidateKey: route.model.candidateKey ?? null };
|
|
388
|
+
} else if (route.decision === "WAIT_FOR_PROJECT_TEAM" && route.suggestedAlternative?.model) {
|
|
389
|
+
confirmationTarget = { role: route.role, selection: "suggested-alternative", strategyFingerprint: route.strategyFingerprint, candidateKey: route.suggestedAlternative.model.candidateKey ?? null };
|
|
390
|
+
}
|
|
391
|
+
return {
|
|
392
|
+
decision: route.decision, role: route.role,
|
|
393
|
+
provider: route.provider, model: route.model?.modelId ?? null, modelRef: route.model,
|
|
394
|
+
assignmentSource: route.assignmentSource, strategyFingerprint: route.strategyFingerprint, why: route.why,
|
|
395
|
+
blockedAssignment: route.blockedAssignment, suggestedAlternative: route.suggestedAlternative,
|
|
396
|
+
confirmationTarget
|
|
397
|
+
};
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
return {
|
|
401
|
+
async snapshot({ cwd }) {
|
|
402
|
+
const projectRoot = await root(cwd);
|
|
403
|
+
await recover(homeDir);
|
|
404
|
+
const plans = await listPlans(projectRoot);
|
|
405
|
+
const projected = await Promise.all(plans.map(async (plan) => publicPlan(
|
|
406
|
+
plan, await executionFor(projectRoot, plan.taskId)
|
|
407
|
+
)));
|
|
408
|
+
const adapters = inspectAdapters({ cwd: projectRoot });
|
|
409
|
+
const engram = inspectEngram();
|
|
410
|
+
const recentRuns = await listRuns(homeDir, { limit: 50 });
|
|
411
|
+
const usageByAgent = aggregateRunUsageByAgent(recentRuns);
|
|
412
|
+
const [codexUsage, claudeUsage, opencodeUsage] = await Promise.all([
|
|
413
|
+
readCodexUsageCached(projectRoot, { cwd: projectRoot }),
|
|
414
|
+
readClaudeUsageCached("global", {}),
|
|
415
|
+
deps.readOpenCodeUsage
|
|
416
|
+
? readOpenCodeUsageCached("global", {})
|
|
417
|
+
: Promise.all([
|
|
418
|
+
readOpenCodeGoCached("global", {}),
|
|
419
|
+
readOpenCodeStatsCached("global", {})
|
|
420
|
+
]).then(([go, zen]) => ({ go, zen }))
|
|
421
|
+
]);
|
|
422
|
+
const result = snapshot(
|
|
423
|
+
projectRoot, projected,
|
|
424
|
+
providersFromAdapters(adapters, usageByAgent, codexUsage, claudeUsage, opencodeUsage), integrationsFromInspections(engram)
|
|
425
|
+
);
|
|
426
|
+
result.usage.codex = codexUsage;
|
|
427
|
+
result.usage.claude = claudeUsage;
|
|
428
|
+
result.usage.opencode = opencodeUsage;
|
|
429
|
+
// Cheap local read only — never recomputed here. Recomputing a real
|
|
430
|
+
// ProjectProfile (git log, Graphify probe) on every poll tick would
|
|
431
|
+
// make the dashboard itself expensive; that only happens on an
|
|
432
|
+
// explicit /project analyze or /project refresh.
|
|
433
|
+
result.projectStrategy = await readProjectStrategyImpl(homeDir, projectRoot);
|
|
434
|
+
if (enableProviderProbes) {
|
|
435
|
+
const [codexCatalog, opencodeGoCatalog, cursorCatalog, aa] = await Promise.all([
|
|
436
|
+
readCodexModelsCached(projectRoot, { cwd: projectRoot }),
|
|
437
|
+
readOpenCodeGoModelsCached("global", {}),
|
|
438
|
+
readCursorModelsCached(projectRoot, { cwd: projectRoot }),
|
|
439
|
+
readArtificialAnalysisModelsCached("global", {})
|
|
440
|
+
]);
|
|
441
|
+
// Same eligibility policy the execution/ask router uses, with one
|
|
442
|
+
// deliberate exception: opencode-go is allowed here even without
|
|
443
|
+
// `launchable` (requireLaunchable: false) — you do have real
|
|
444
|
+
// access to its models via the Go subscription, so AI TEAM can
|
|
445
|
+
// name them as real options, even though Kairo can't yet safely
|
|
446
|
+
// auto-execute through the shared opencode adapter (see
|
|
447
|
+
// execution-adapters/opencode.js). Real task routing never gets
|
|
448
|
+
// this exception (selectExecutionProvider always requires
|
|
449
|
+
// launchable), so Kairo never actually picks Go for a real run
|
|
450
|
+
// it's guaranteed to reject at launch. Zen and Cursor stay
|
|
451
|
+
// excluded here regardless (PAYG risk / manual-only).
|
|
452
|
+
// Cursor's own real usage/billing data is never auto-detected (see
|
|
453
|
+
// execution-router.js's checkCandidate doc) — this is only ever
|
|
454
|
+
// the human's last word via /project cursor exhausted|available,
|
|
455
|
+
// persisted as an ordinary provider usage record.
|
|
456
|
+
const cursorManualQuota = await readProviderUsageImpl(homeDir, "cursor");
|
|
457
|
+
const eligibility = {};
|
|
458
|
+
const candidates = [];
|
|
459
|
+
for (const adapterId of ["codex", "claude", "opencode-go", "opencode-zen", "cursor"]) {
|
|
460
|
+
const check = checkCandidate(adapterId, { adapters, codexUsage, claudeUsage, opencodeGoUsage: opencodeUsage?.go, cursorManualQuota }, { requireLaunchable: false });
|
|
461
|
+
eligibility[adapterId] = check;
|
|
462
|
+
if (check.ok) candidates.push(adapterId);
|
|
463
|
+
}
|
|
464
|
+
const claudeCatalog = readClaudeModelsImpl();
|
|
465
|
+
const catalogsByAdapter = {
|
|
466
|
+
codex: codexCatalog?.models ?? [],
|
|
467
|
+
claude: claudeCatalog.models,
|
|
468
|
+
"opencode-go": opencodeGoCatalog?.models ?? [],
|
|
469
|
+
cursor: cursorCatalog?.models ?? []
|
|
470
|
+
};
|
|
471
|
+
const scored = scoreAvailableModels(
|
|
472
|
+
candidates.map((adapterId) => ({ adapterId, models: catalogsByAdapter[adapterId] ?? [] })),
|
|
473
|
+
aa.models
|
|
474
|
+
);
|
|
475
|
+
// AI TEAM needs the full real capability picture — including
|
|
476
|
+
// providers that are temporarily ineligible (quota exhausted, rate
|
|
477
|
+
// limited) — so a preferred model never just vanishes; it's shown
|
|
478
|
+
// unavailable with a real eligible fallback instead. `scored`
|
|
479
|
+
// above stays eligibility-filtered for existing consumers.
|
|
480
|
+
const scoredAllRaw = scoreAvailableModels(
|
|
481
|
+
Object.keys(catalogsByAdapter).map((adapterId) => ({ adapterId, models: catalogsByAdapter[adapterId] ?? [] })),
|
|
482
|
+
aa.models
|
|
483
|
+
);
|
|
484
|
+
// The Complete Candidate Catalog — every real model across every
|
|
485
|
+
// real provider catalog, mapped to ModelCandidateIdentity (clean
|
|
486
|
+
// modelName, accessMode, evidenceStatus, real lineage/lifecycle —
|
|
487
|
+
// see model-candidate-catalog.js's own doc). Built once, from the
|
|
488
|
+
// exact same real provider catalogs scoredAllRaw itself came from.
|
|
489
|
+
const completeCandidateCatalog = buildCompleteCandidateCatalog(
|
|
490
|
+
Object.keys(catalogsByAdapter).map((adapterId) => ({ adapterId, models: catalogsByAdapter[adapterId] ?? [] })),
|
|
491
|
+
aa.models
|
|
492
|
+
);
|
|
493
|
+
// The Recommendation Pool: scoredAllRaw joined with its real
|
|
494
|
+
// identity, with genuinely superseded generations excluded —
|
|
495
|
+
// QUALITY/EFFICIENT TEAM and ProjectStrategy consume THIS, never
|
|
496
|
+
// scoredAllRaw directly, so an old generation Cursor still
|
|
497
|
+
// re-exposes (e.g. Claude Sonnet 4) naturally stops competing
|
|
498
|
+
// without buildAiTeam/buildEfficientTeam's own ranking logic
|
|
499
|
+
// needing to know why. Manual-only real candidates (Cursor,
|
|
500
|
+
// OpenCode Go) stay in it — this is "what Kairo can honestly
|
|
501
|
+
// recommend", not "what Kairo can launch by itself".
|
|
502
|
+
const scoredAll = buildRecommendationPool(scoredAllRaw, completeCandidateCatalog);
|
|
503
|
+
// The Automatic Execution Pool: the real subset of scoredAll
|
|
504
|
+
// Kairo could actually launch itself right now (accessMode
|
|
505
|
+
// "automatic" AND real, current eligibility) — exposed for the
|
|
506
|
+
// real task router (not yet built) to consume; QUALITY/EFFICIENT
|
|
507
|
+
// TEAM never filter by this, only by capability/portfolio.
|
|
508
|
+
const automaticExecutionPool = buildAutomaticExecutionPool(scoredAll, eligibility);
|
|
509
|
+
// How much of each real catalog could even be matched to AA data —
|
|
510
|
+
// independent of runtime eligibility above. A provider can be fully
|
|
511
|
+
// eligible right now and still have unmatched models simply because
|
|
512
|
+
// AA doesn't track them, or (Claude, today) Kairo only has a
|
|
513
|
+
// documented catalog rather than a live per-account discovery.
|
|
514
|
+
const coverage = summarizeCatalogCoverage([
|
|
515
|
+
{ adapterId: "codex", catalogStatus: codexCatalog?.status ?? "unknown", models: codexCatalog?.models ?? [] },
|
|
516
|
+
{ adapterId: "claude", catalogStatus: claudeCatalog.status, models: claudeCatalog.models },
|
|
517
|
+
{ adapterId: "opencode-go", catalogStatus: opencodeGoCatalog?.status ?? "unknown", models: opencodeGoCatalog?.models ?? [] },
|
|
518
|
+
{ adapterId: "cursor", catalogStatus: cursorCatalog?.status ?? "unknown", models: cursorCatalog?.models ?? [] }
|
|
519
|
+
], aa.models);
|
|
520
|
+
// Real catalog models that exist but couldn't be matched to any
|
|
521
|
+
// real Artificial Analysis data — shown honestly as UNSCORED in
|
|
522
|
+
// /models --evidence instead of just vanishing with no trace.
|
|
523
|
+
// Derived directly from the Complete Candidate Catalog's own real
|
|
524
|
+
// evidenceStatus — model-intelligence.js's old listUnscoredModels
|
|
525
|
+
// duplicated this exact same real AA-match check separately; this
|
|
526
|
+
// catalog replaces it as the one real source of truth.
|
|
527
|
+
const unscoredModels = completeCandidateCatalog
|
|
528
|
+
// Same "not superseded" real rule the Recommendation Pool
|
|
529
|
+
// applies to SCORED candidates (buildRecommendationPool) — an
|
|
530
|
+
// unscored candidate with a real, proven newer same-lineage
|
|
531
|
+
// successor is exactly as stale as a scored one would be, and
|
|
532
|
+
// must never surface in /models --evidence's UNSCORED list or
|
|
533
|
+
// the projectTeam edit catalog as if it were still current.
|
|
534
|
+
.filter((candidate) => candidate.evidenceStatus === "unscored" && candidate.lifecycle !== "superseded")
|
|
535
|
+
.map((candidate) => ({
|
|
536
|
+
adapterId: candidate.adapterId, modelId: candidate.modelId, displayName: candidate.rawDisplayName,
|
|
537
|
+
candidateKey: candidate.candidateKey, accessMode: candidate.accessMode, lifecycle: candidate.lifecycle
|
|
538
|
+
}));
|
|
539
|
+
|
|
540
|
+
// The Model Intelligence Foundation registry: every source Kairo
|
|
541
|
+
// has (AA, Hugging Face scoped to Go, manufacturer snapshots,
|
|
542
|
+
// Kairo's own real run telemetry) collected with provenance, not
|
|
543
|
+
// blended into AI TEAM's ranking math — Terminal-Bench and AA's
|
|
544
|
+
// codingIndex aren't the same measurement, and averaging them would
|
|
545
|
+
// violate the registry's own no-blending contract. Instead it's
|
|
546
|
+
// surfaced as corroborating evidence alongside each pick (see
|
|
547
|
+
// annotateWithRegistryEvidence), so AI TEAM's actual decision stays
|
|
548
|
+
// exactly the real-metric ranking it already was, while /models can
|
|
549
|
+
// now also show what else is known about the chosen model.
|
|
550
|
+
const registry = createCapabilityRegistry();
|
|
551
|
+
ingestArtificialAnalysisEvidence(
|
|
552
|
+
registry, Object.keys(catalogsByAdapter).map((adapterId) => ({ adapterId, models: catalogsByAdapter[adapterId] ?? [] })),
|
|
553
|
+
aa.models, { fetchedAt: aa.fetchedAt ?? new Date().toISOString() }
|
|
554
|
+
);
|
|
555
|
+
const hle = await readHuggingFaceLeaderboardCached("cais/hle", "cais/hle").catch(() => null);
|
|
556
|
+
if (hle?.entries?.length) {
|
|
557
|
+
ingestHuggingFaceLeaderboardEvidence(
|
|
558
|
+
registry, [{ adapterId: "opencode-go", models: catalogsByAdapter["opencode-go"] ?? [] }],
|
|
559
|
+
// scale: "hundred" — verified live against the real HF cache
|
|
560
|
+
// (cais/hle): DeepSeek-V4.1-Flash's real reported value is
|
|
561
|
+
// 63.9, not a 0-1 fraction. AA's own "hle" field IS a real 0-1
|
|
562
|
+
// fraction, so the two sources genuinely disagree on scale
|
|
563
|
+
// despite sharing the metric name "hle" — see
|
|
564
|
+
// model-capability-registry-sources.js's own doc for the bug
|
|
565
|
+
// this fixes.
|
|
566
|
+
hle.entries, { metric: "hle", scale: "hundred", fetchedAt: hle.fetchedAt }
|
|
567
|
+
);
|
|
568
|
+
}
|
|
569
|
+
ingestOfficialSnapshotEvidence(registry);
|
|
570
|
+
const runRecords = await listRunRecordsCached("runs", null).catch(() => []);
|
|
571
|
+
ingestKairoTelemetryEvidence(registry, runRecords);
|
|
572
|
+
// Provider quota is a PROVIDER-level fact, never copied into the
|
|
573
|
+
// per-model capability registry as if it were model evidence — see
|
|
574
|
+
// subscription-pressure-source.js. buildEfficientTeam resolves it
|
|
575
|
+
// separately, by adapterId, only as its very last tiebreak.
|
|
576
|
+
const providerCapacity = buildProviderCapacity(remainingPercentByAdapter(codexUsage, claudeUsage, opencodeUsage));
|
|
577
|
+
|
|
578
|
+
result.modelIntelligence = {
|
|
579
|
+
status: aa.status, source: aa.source, age: aa.age,
|
|
580
|
+
models: annotateWithRegistryEvidence(scored, registry), roles: bestModelPerRole(scored),
|
|
581
|
+
eligibility, coverage, unscoredModels,
|
|
582
|
+
// BEST FIT GLOBAL / EFFICIENT GLOBAL: the honest, uncoordinated
|
|
583
|
+
// per-role winner — never cedes a role for portfolio diversity,
|
|
584
|
+
// family concentration, or provider distribution (see
|
|
585
|
+
// bestModelPerRoleGlobal's own header for why that coordination
|
|
586
|
+
// belongs to PROJECT TEAM alone, not a "what's genuinely best"
|
|
587
|
+
// question). This is the real fix for the reported bug: the
|
|
588
|
+
// dashboard widget was showing aiTeam/efficientTeam (coordinated,
|
|
589
|
+
// portfolio-aware) as if it were this uncoordinated global view,
|
|
590
|
+
// which could silently show a real diversity pick (e.g. Muse
|
|
591
|
+
// Spark) as "the best Architect" when it only won because Astra
|
|
592
|
+
// had already been used elsewhere.
|
|
593
|
+
globalGuide: {
|
|
594
|
+
capability: bestModelPerRoleGlobal(scoredAll, eligibility, registry),
|
|
595
|
+
efficient: bestEfficientModelPerRoleGlobal(scoredAll, eligibility, registry, { providerCapacity })
|
|
596
|
+
},
|
|
597
|
+
// Kept temporarily as compatibility aliases for existing callers
|
|
598
|
+
// — PROJECT TEAM (the real, coordinated-portfolio result) once a
|
|
599
|
+
// project has actually been analyzed; today's callers still read
|
|
600
|
+
// these as the global view until the cockpit widget migrates.
|
|
601
|
+
aiTeam: buildAiTeam(scoredAll, eligibility, registry),
|
|
602
|
+
efficientTeam: buildEfficientTeam(scoredAll, eligibility, registry, { providerCapacity }),
|
|
603
|
+
// Raw ingredients (never rendered directly) so a caller that
|
|
604
|
+
// needs a PROJECT-specific re-scoring (see analyzeProject below)
|
|
605
|
+
// can call buildAiTeam/buildEfficientTeam again with the
|
|
606
|
+
// project's own real roleCapabilities, instead of only ever
|
|
607
|
+
// filtering the generic global team by role name. `scoredAll`
|
|
608
|
+
// here is the Recommendation Pool (superseded generations
|
|
609
|
+
// already excluded), not the raw scoreAvailableModels() output
|
|
610
|
+
// — see scoredAllRaw/completeCandidateCatalog above.
|
|
611
|
+
scoredAll, registry, providerCapacity,
|
|
612
|
+
// The real subset Kairo can actually launch itself — exposed
|
|
613
|
+
// for the real task router (not yet built) to consume; never
|
|
614
|
+
// used by QUALITY/EFFICIENT TEAM or ProjectStrategy, which only
|
|
615
|
+
// ever filter by capability/portfolio, never by accessMode.
|
|
616
|
+
automaticExecutionPool
|
|
617
|
+
};
|
|
618
|
+
}
|
|
619
|
+
return result;
|
|
620
|
+
},
|
|
621
|
+
async submitArchitecture({ cwd, task, model = null }) {
|
|
622
|
+
const projectRoot = await root(cwd);
|
|
623
|
+
const result = await createPlan({ cwd: projectRoot, task, model });
|
|
624
|
+
return { ...publicPlan(result.status), reused: result.reused === true, projectRoot };
|
|
625
|
+
},
|
|
626
|
+
/**
|
|
627
|
+
* Real read-only question -> real answer, via whichever provider is
|
|
628
|
+
* actually available/quota-healthy — no task, no plan, no approval
|
|
629
|
+
* gate. Throws (never returns a fabricated answer) if no provider can
|
|
630
|
+
* answer or the call itself fails.
|
|
631
|
+
*/
|
|
632
|
+
async askQuestion({ cwd, task }) {
|
|
633
|
+
const projectRoot = await root(cwd);
|
|
634
|
+
const adapters = inspectAdapters({ cwd: projectRoot });
|
|
635
|
+
let codexUsage = null;
|
|
636
|
+
let claudeUsage = null;
|
|
637
|
+
let codexCatalog = {};
|
|
638
|
+
let claudeCatalog = {};
|
|
639
|
+
if (enableProviderProbes) {
|
|
640
|
+
[codexUsage, claudeUsage, codexCatalog] = await Promise.all([
|
|
641
|
+
readCodexUsageCached(projectRoot, { cwd: projectRoot }),
|
|
642
|
+
readClaudeUsageCached("global", {}),
|
|
643
|
+
readCodexModelsImpl()
|
|
644
|
+
]);
|
|
645
|
+
claudeCatalog = readClaudeModelsImpl();
|
|
646
|
+
}
|
|
647
|
+
const decision = routeAsk({ adapters, codexUsage, claudeUsage, catalogs: { codex: codexCatalog, claude: claudeCatalog }, taskText: task });
|
|
648
|
+
if (decision.decision !== "ROUTED") throw new Error(`Cannot answer: ${decision.why}`);
|
|
649
|
+
const result = await askProviderImpl({ provider: decision.provider, question: task, model: decision.model, cwd: projectRoot });
|
|
650
|
+
if (result.status !== "answered") throw new Error(result.error ?? `${decision.provider} gave no answer.`);
|
|
651
|
+
return { provider: decision.provider, model: decision.model, answer: result.answer, projectRoot };
|
|
652
|
+
},
|
|
653
|
+
/**
|
|
654
|
+
* The composer's real entry point. When the cockpit's explicit WorkMode
|
|
655
|
+
* is given, it decides outright — ASK always answers read-only, never
|
|
656
|
+
* creating a plan; PLAN/AGENT always create a plan (submitArchitecture),
|
|
657
|
+
* never answering directly — replacing the old isLikelyQuestion guess
|
|
658
|
+
* with what the user actually told Kairo they're doing. `mode` is
|
|
659
|
+
* optional only for backward compatibility with any caller that
|
|
660
|
+
* predates WorkMode; the real cockpit always passes it.
|
|
661
|
+
* @param {object} args
|
|
662
|
+
* @param {string} args.cwd
|
|
663
|
+
* @param {string} args.task
|
|
664
|
+
* @param {"ask"|"plan"|"agent"|null} [args.mode]
|
|
665
|
+
*/
|
|
666
|
+
async submitTask({ cwd, task, mode = null }) {
|
|
667
|
+
const isQuestion = mode ? mode === "ask" : isLikelyQuestion(task);
|
|
668
|
+
if (isQuestion) {
|
|
669
|
+
const answer = await this.askQuestion({ cwd, task });
|
|
670
|
+
return { kind: "answer", ...answer };
|
|
671
|
+
}
|
|
672
|
+
const plan = await this.submitArchitecture({ cwd, task });
|
|
673
|
+
return { kind: "plan", ...plan };
|
|
674
|
+
},
|
|
675
|
+
/**
|
|
676
|
+
* Real, persisted KairoSession for this project — right now just the
|
|
677
|
+
* current WorkMode ("ask" | "plan" | "agent"). A session that predates
|
|
678
|
+
* WorkMode (or has no file yet) reads as "ask", the strictly read-only
|
|
679
|
+
* default — see session-store.js's readSession.
|
|
680
|
+
*/
|
|
681
|
+
async getSession({ cwd }) {
|
|
682
|
+
const projectRoot = await root(cwd);
|
|
683
|
+
return readSessionImpl(homeDir, projectRoot);
|
|
684
|
+
},
|
|
685
|
+
/**
|
|
686
|
+
* Persists a new WorkMode for this project — pure local state, no
|
|
687
|
+
* provider I/O, so Shift+Tab/`/plan` stay instant.
|
|
688
|
+
* @param {{cwd: string, mode: "ask"|"plan"|"agent"}} args
|
|
689
|
+
*/
|
|
690
|
+
async setMode({ cwd, mode }) {
|
|
691
|
+
const projectRoot = await root(cwd);
|
|
692
|
+
return writeSessionModeImpl(homeDir, projectRoot, mode);
|
|
693
|
+
},
|
|
694
|
+
/**
|
|
695
|
+
* `/project analyze` (LOCAL_PREFLIGHT -> AWAITING_ANALYST): computes a
|
|
696
|
+
* real, read-only ProjectProfile and real Bootstrap Analyst
|
|
697
|
+
* alternatives (quality/efficient, restricted to providers Kairo can
|
|
698
|
+
* actually run read-only — see project-strategy.js's
|
|
699
|
+
* ASK_SUPPORTED_ADAPTERS) — no provider call yet, no ProjectStrategy
|
|
700
|
+
* created yet. The human picks and confirms one of these via
|
|
701
|
+
* runBootstrapAnalysis below; nothing is persisted until that real
|
|
702
|
+
* analysis actually runs and validates.
|
|
703
|
+
*
|
|
704
|
+
* `analystCatalog` is the full real analyst catalog (every real
|
|
705
|
+
* scored AND unscored ask-supported candidate, see
|
|
706
|
+
* project-strategy.js's computeBootstrapAnalystCatalog) — additive,
|
|
707
|
+
* for a future richer analyst picker; `alternatives` (the existing
|
|
708
|
+
* plain quality/efficient pair) stays unchanged for today's overlay
|
|
709
|
+
* and analyst-run callers, which don't consume the fuller catalog yet.
|
|
710
|
+
*/
|
|
711
|
+
async preflightProject({ cwd }) {
|
|
712
|
+
const projectRoot = await root(cwd);
|
|
713
|
+
const profile = await computeProjectProfileImpl({ cwd: projectRoot });
|
|
714
|
+
const snap = await this.snapshot({ cwd: projectRoot });
|
|
715
|
+
const { scoredAll = [], eligibility = {}, registry = null, providerCapacity = null, unscoredModels = [] } = snap.modelIntelligence ?? {};
|
|
716
|
+
const candidates = { scoredAll, eligibility, registry, providerCapacity };
|
|
717
|
+
const alternatives = computeBootstrapAnalystAlternatives(candidates);
|
|
718
|
+
const analystCatalog = computeBootstrapAnalystCatalog({ ...candidates, unscoredModels });
|
|
719
|
+
return { profile, alternatives, candidates, analystCatalog, projectRoot };
|
|
720
|
+
},
|
|
721
|
+
/**
|
|
722
|
+
* `/project analyst quality|efficient --confirm` (ANALYZING -> SUGGESTED):
|
|
723
|
+
* runs the human's already-confirmed real model read-only (askProvider
|
|
724
|
+
* — the same real, no-file-write path ASK mode uses; never a new
|
|
725
|
+
* execution surface) against the real, limited context package
|
|
726
|
+
* (project-analysis.js's buildAnalystPrompt), validates its structured
|
|
727
|
+
* response, and ONLY on a valid response deterministically derives
|
|
728
|
+
* real role requirements and builds + persists a SUGGESTED
|
|
729
|
+
* ProjectStrategy. An invalid/unparseable analyst response throws —
|
|
730
|
+
* no ProjectStrategy is ever created from it.
|
|
731
|
+
* @param {object} args
|
|
732
|
+
* @param {string} args.cwd
|
|
733
|
+
* @param {object} args.profile - from preflightProject
|
|
734
|
+
* @param {object} args.candidates - from preflightProject
|
|
735
|
+
* @param {{choice: "quality"|"efficient", model: object}} args.analyst
|
|
736
|
+
*/
|
|
737
|
+
async runBootstrapAnalysis({ cwd, profile, candidates, analyst }) {
|
|
738
|
+
const projectRoot = await root(cwd);
|
|
739
|
+
// SECRET-SAFE (a real, meaningful reduction — not by itself a
|
|
740
|
+
// filesystem sandbox guarantee, see sanitized-snapshot.js's own
|
|
741
|
+
// header for why): the analyst's `cwd` points at a bounded,
|
|
742
|
+
// secret-redacted temporary copy, never the real project directory.
|
|
743
|
+
// A normal investigation never sees an unredacted secret.
|
|
744
|
+
//
|
|
745
|
+
// The actual provider call is routed through a neutral
|
|
746
|
+
// BootstrapAnalyzerAdapter (bootstrap-analyzer-adapters.js) rather
|
|
747
|
+
// than branched inline here — each adapter reports its own real
|
|
748
|
+
// isolation level ("verified" | "restricted" | "unverified") and is
|
|
749
|
+
// checked BEFORE any provider call is attempted, so an ineligible
|
|
750
|
+
// adapter (e.g. Codex without a verified OS-level boundary on this
|
|
751
|
+
// platform) fails closed with a concrete reason instead of silently
|
|
752
|
+
// running with weaker protection than the caller expects.
|
|
753
|
+
const snapshot = await buildSanitizedSnapshotImpl(projectRoot);
|
|
754
|
+
try {
|
|
755
|
+
const prompt = buildAnalystPrompt(profile);
|
|
756
|
+
const adapter = createBootstrapAnalyzerAdapterImpl(analyst.model.adapterId, {
|
|
757
|
+
modelId: analyst.model.modelId,
|
|
758
|
+
deps: {
|
|
759
|
+
askProvider: askProviderImpl, runCodexSandboxedBootstrap: runCodexSandboxedBootstrapImpl, isolationDeps: codexIsolationDeps,
|
|
760
|
+
verifyClaudeSubscriptionAuth: verifyClaudeSubscriptionAuthImpl, readClaudeModels: readClaudeModelsImpl
|
|
761
|
+
}
|
|
762
|
+
});
|
|
763
|
+
const eligibility = await adapter.checkEligibility();
|
|
764
|
+
if (!eligibility.eligible) {
|
|
765
|
+
throw new Error(`Bootstrap Analyst is not eligible to run: ${eligibility.reason ?? "unknown reason"}`);
|
|
766
|
+
}
|
|
767
|
+
// A real project investigation (the model reads real files, not
|
|
768
|
+
// just answering from the prompt text) genuinely takes longer
|
|
769
|
+
// than ASK mode's quick-question default — give it real room
|
|
770
|
+
// instead of timing out mid-investigation.
|
|
771
|
+
const response = await adapter.analyze({
|
|
772
|
+
question: prompt, snapshotRoot: snapshot.snapshotRoot, timeoutMs: BOOTSTRAP_ANALYST_TIMEOUT_MS
|
|
773
|
+
});
|
|
774
|
+
if (response.status !== "answered") {
|
|
775
|
+
throw new Error(`Bootstrap Analyst did not answer: ${response.error ?? response.status}`);
|
|
776
|
+
}
|
|
777
|
+
const parsed = parseProjectAnalysisImpl(response.answer);
|
|
778
|
+
if (!parsed.valid) throw new Error(`Bootstrap Analyst response failed validation: ${parsed.error}`);
|
|
779
|
+
// Real-evidence gate, checked PER recommendedRoleNeeds entry (see
|
|
780
|
+
// deriveRoleRequirements): a role need is only trusted when its
|
|
781
|
+
// OWN evidence cites at least one real file the analyst actually
|
|
782
|
+
// had access to — one well-evidenced role need can no longer
|
|
783
|
+
// vouch for every other role need in the same response.
|
|
784
|
+
const roleRequirements = deriveRoleRequirementsImpl(parsed.analysis, profile.roleRequirements, snapshot.copiedFiles);
|
|
785
|
+
const strategy = buildProjectStrategy({ ...profile, roleRequirements }, candidates, analyst);
|
|
786
|
+
await writeProjectStrategyImpl(homeDir, projectRoot, strategy);
|
|
787
|
+
return {
|
|
788
|
+
...strategy, projectRoot, analysis: parsed.analysis,
|
|
789
|
+
sanitization: { filesCopied: snapshot.filesCopied, secretsRedacted: snapshot.secretsRedacted, excludedPrivatePaths: snapshot.excludedPrivatePaths.length }
|
|
790
|
+
};
|
|
791
|
+
} finally {
|
|
792
|
+
await snapshot.cleanup();
|
|
793
|
+
}
|
|
794
|
+
},
|
|
795
|
+
/** `/project approve`: SUGGESTED -> ACTIVE. Requires a real suggested strategy to already exist — the Bootstrap Analyst choice is already locked in by the time a strategy exists at all (see runBootstrapAnalysis), so there's nothing left to confirm here. */
|
|
796
|
+
async approveProjectStrategy({ cwd }) {
|
|
797
|
+
const projectRoot = await root(cwd);
|
|
798
|
+
const existing = await readProjectStrategyImpl(homeDir, projectRoot);
|
|
799
|
+
if (!existing) throw new Error("No suggested project strategy yet — run /project analyze first.");
|
|
800
|
+
const approved = { ...existing, status: "active", approvedAt: new Date().toISOString() };
|
|
801
|
+
await writeProjectStrategyImpl(homeDir, projectRoot, approved);
|
|
802
|
+
return { ...approved, projectRoot };
|
|
803
|
+
},
|
|
804
|
+
/**
|
|
805
|
+
* `/project refresh`: a strategy that was never approved (no strategy
|
|
806
|
+
* yet, or still just "suggested") is left as-is — the interactive
|
|
807
|
+
* AWAITING_ANALYST/ANALYZING flow (preflightProject + a fresh
|
|
808
|
+
* /project analyze) is the only way to get a new suggestion; refresh
|
|
809
|
+
* never silently re-runs a real provider call. An ACTIVE strategy
|
|
810
|
+
* whose real fingerprint no longer matches the current evidence is
|
|
811
|
+
* marked STALE (persisted) but keeps its previous team assignments/
|
|
812
|
+
* approval intact — a stale flag, not a silent, unapproved swap.
|
|
813
|
+
*/
|
|
814
|
+
async refreshProjectStrategy({ cwd }) {
|
|
815
|
+
const projectRoot = await root(cwd);
|
|
816
|
+
const existing = await readProjectStrategyImpl(homeDir, projectRoot);
|
|
817
|
+
if (!existing || existing.status !== "active") return existing;
|
|
818
|
+
const profile = await computeProjectProfileImpl({ cwd: projectRoot });
|
|
819
|
+
if (isStrategyStale(existing, profile)) {
|
|
820
|
+
const stale = { ...existing, status: "stale" };
|
|
821
|
+
await writeProjectStrategyImpl(homeDir, projectRoot, stale);
|
|
822
|
+
return { ...stale, projectRoot, profile };
|
|
823
|
+
}
|
|
824
|
+
return { ...existing, projectRoot, profile };
|
|
825
|
+
},
|
|
826
|
+
/**
|
|
827
|
+
* `/project cursor exhausted|available`: the ONLY way Cursor's quota
|
|
828
|
+
* state ever changes (see execution-router.js's checkCandidate doc —
|
|
829
|
+
* Cursor exposes no real, zero-cost local usage read, so this is never
|
|
830
|
+
* auto-detected). Persisted as an ordinary provider usage record
|
|
831
|
+
* (runtime/usage-store.js), read back by snapshot()'s own eligibility
|
|
832
|
+
* computation on every poll — never held only in memory, so it
|
|
833
|
+
* survives a restart the same way a real detected quota state would.
|
|
834
|
+
* @param {{exhausted: boolean, reason?: string|null}} args
|
|
835
|
+
*/
|
|
836
|
+
async setCursorManualQuota({ exhausted, reason = null }) {
|
|
837
|
+
const record = {
|
|
838
|
+
provider: "cursor", manualExhausted: !!exhausted,
|
|
839
|
+
reason: exhausted ? (reason ?? "Cursor marked out of credits (manual, via /project cursor exhausted)") : null,
|
|
840
|
+
setAt: new Date().toISOString()
|
|
841
|
+
};
|
|
842
|
+
await writeProviderUsageImpl(homeDir, "cursor", record);
|
|
843
|
+
return record;
|
|
844
|
+
},
|
|
845
|
+
/**
|
|
846
|
+
* The real projectTeam edit catalog for one role (section 4 —
|
|
847
|
+
* "Edición persistida del PROJECT TEAM") — every real, non-superseded
|
|
848
|
+
* candidate from all four real adapters, with its own real
|
|
849
|
+
* availability/evidenceStatus/role evaluation. Read-only; never
|
|
850
|
+
* writes anything, never consumes quota.
|
|
851
|
+
*/
|
|
852
|
+
async getProjectTeamEditCatalog({ cwd, role }) {
|
|
853
|
+
const projectRoot = await root(cwd);
|
|
854
|
+
const snap = await this.snapshot({ cwd: projectRoot });
|
|
855
|
+
const { scoredAll = [], eligibility = {}, registry = null, unscoredModels = [] } = snap.modelIntelligence ?? {};
|
|
856
|
+
return computeProjectTeamEditCatalog(role, { scoredAll, eligibility, registry, unscoredModels });
|
|
857
|
+
},
|
|
858
|
+
/**
|
|
859
|
+
* Persists a real, human-confirmed assignment for one role of a
|
|
860
|
+
* SUGGESTED project strategy — either a manual override (a real,
|
|
861
|
+
* currently-listed edit-catalog candidate) or an implicit reset (the
|
|
862
|
+
* role's own real recommended candidate chosen again). Rejects a role
|
|
863
|
+
* outside this project's team, a candidate no longer in the real
|
|
864
|
+
* current catalog (superseded/disappeared), or any strategy that
|
|
865
|
+
* isn't SUGGESTED (active/stale stay read-only in this increment).
|
|
866
|
+
* Never runs anything, never consumes quota — this only ever changes
|
|
867
|
+
* what a LATER approved run would delegate to.
|
|
868
|
+
* @param {object} args
|
|
869
|
+
* @param {string} args.cwd
|
|
870
|
+
* @param {string} args.role
|
|
871
|
+
* @param {string} args.candidateKey - one real candidateKey from
|
|
872
|
+
* getProjectTeamEditCatalog's own output for this exact role.
|
|
873
|
+
*/
|
|
874
|
+
async setProjectTeamAssignment({ cwd, role, candidateKey }) {
|
|
875
|
+
const projectRoot = await root(cwd);
|
|
876
|
+
const existing = await readProjectStrategyImpl(homeDir, projectRoot);
|
|
877
|
+
if (!existing) throw new Error("No project strategy to edit — run /project analyze first.");
|
|
878
|
+
if (existing.status !== "suggested") {
|
|
879
|
+
throw new Error(`Cannot edit a ${existing.status.toUpperCase()} project strategy — only a SUGGESTED one is editable.`);
|
|
880
|
+
}
|
|
881
|
+
const catalog = await this.getProjectTeamEditCatalog({ cwd, role });
|
|
882
|
+
const candidate = catalog.models.find((model) => model.candidateKey === candidateKey);
|
|
883
|
+
if (!candidate) throw new Error(`"${candidateKey}" is not a real, current candidate for ${role} — it may be superseded or no longer available.`);
|
|
884
|
+
const updated = applyProjectTeamOverride(existing, role, candidate);
|
|
885
|
+
await writeProjectStrategyImpl(homeDir, projectRoot, updated);
|
|
886
|
+
return updated;
|
|
887
|
+
},
|
|
888
|
+
/**
|
|
889
|
+
* Real persisted chat history for this project, kept globally under
|
|
890
|
+
* `~/.harness/sessions/<projectKey>/transcript.json` (not inside the
|
|
891
|
+
* repo — matches Claude Code/Codex/OpenCode's own convention) — loaded
|
|
892
|
+
* once at cockpit startup so a restart never silently drops the
|
|
893
|
+
* conversation, the way plan/task state already survives restarts.
|
|
894
|
+
*/
|
|
895
|
+
async loadTranscript({ cwd }) {
|
|
896
|
+
const projectRoot = await root(cwd);
|
|
897
|
+
return readTranscriptImpl(homeDir, projectRoot);
|
|
898
|
+
},
|
|
899
|
+
/** Persists one chat entry; a write failure throws so the caller can surface it. */
|
|
900
|
+
async appendTranscript({ cwd, role, text }) {
|
|
901
|
+
const projectRoot = await root(cwd);
|
|
902
|
+
await appendTranscriptImpl(homeDir, projectRoot, { role, text });
|
|
903
|
+
},
|
|
904
|
+
/** Persists an empty transcript so `/clear` stays cleared across a restart. */
|
|
905
|
+
async clearTranscript({ cwd }) {
|
|
906
|
+
const projectRoot = await root(cwd);
|
|
907
|
+
await clearTranscriptImpl(homeDir, projectRoot);
|
|
908
|
+
},
|
|
909
|
+
async showPlan({ cwd, taskId }) {
|
|
910
|
+
const projectRoot = await root(cwd);
|
|
911
|
+
const record = await readPlan(projectRoot, taskId);
|
|
912
|
+
if (!record) throw new Error(`Plan "${taskId}" not found.`);
|
|
913
|
+
return {
|
|
914
|
+
...publicPlan(record, await executionFor(projectRoot, taskId)),
|
|
915
|
+
projectRoot,
|
|
916
|
+
taskMarkdown: record.taskMarkdown,
|
|
917
|
+
planMarkdown: record.planMarkdown
|
|
918
|
+
};
|
|
919
|
+
},
|
|
920
|
+
async decidePlan({ cwd, taskId, decision }) {
|
|
921
|
+
if (![PLAN_STATES.APPROVED, PLAN_STATES.REJECTED].includes(decision)) {
|
|
922
|
+
throw new Error("Decision must be approved or rejected.");
|
|
923
|
+
}
|
|
924
|
+
const projectRoot = await root(cwd);
|
|
925
|
+
const record = await transition(projectRoot, taskId, decision);
|
|
926
|
+
return { ...publicPlan(record, await executionFor(projectRoot, taskId)), projectRoot };
|
|
927
|
+
},
|
|
928
|
+
/**
|
|
929
|
+
* The real PROJECT TEAM route for one role, right now — reloads the
|
|
930
|
+
* persisted ProjectStrategy and CURRENT eligibility fresh on every
|
|
931
|
+
* call (never cached/reused across preview and confirm; executePlan's
|
|
932
|
+
* own revalidation calls this exact same method again before ever
|
|
933
|
+
* reserving quota — see its own doc).
|
|
934
|
+
* @param {string} role
|
|
935
|
+
* @param {string} projectRoot
|
|
936
|
+
*/
|
|
937
|
+
async routeProjectExecution(role, projectRoot) {
|
|
938
|
+
const strategy = await readProjectStrategyImpl(homeDir, projectRoot);
|
|
939
|
+
const snap = await this.snapshot({ cwd: projectRoot });
|
|
940
|
+
const eligibility = snap.modelIntelligence?.eligibility ?? {};
|
|
941
|
+
return resolveProjectRoute({ role, strategy, eligibility });
|
|
942
|
+
},
|
|
943
|
+
/**
|
|
944
|
+
* Read-only preview of what executePlan would do right now.
|
|
945
|
+
*
|
|
946
|
+
* PROJECT TEAM is the sole authority for execution routing — `role`
|
|
947
|
+
* is required and always comes from the caller's own explicit choice,
|
|
948
|
+
* NEVER inferred from the plan's task text. Resolves the role against
|
|
949
|
+
* the approved ProjectStrategy via resolveProjectRoute and returns a
|
|
950
|
+
* ProjectExecutionPreview (decision/provider/model/modelRef/
|
|
951
|
+
* assignmentSource/strategyFingerprint/why/blockedAssignment/
|
|
952
|
+
* suggestedAlternative/confirmationTarget — see toExecutionPreview's
|
|
953
|
+
* own doc). The legacy keyword-classification router
|
|
954
|
+
* (selectExecutionProvider) is never consulted here — a project with
|
|
955
|
+
* no active team simply returns WAIT_FOR_PROJECT_TEAM, the router's
|
|
956
|
+
* own honest answer, never a silent fallback to guessing from text.
|
|
957
|
+
*/
|
|
958
|
+
async planExecution({ cwd, taskId, role }) {
|
|
959
|
+
if (!role) throw new Error("planExecution requires an explicit role — it is never inferred from the task's text.");
|
|
960
|
+
const projectRoot = await root(cwd);
|
|
961
|
+
const record = await readPlan(projectRoot, taskId);
|
|
962
|
+
if (!record) throw new Error(`Plan "${taskId}" not found.`);
|
|
963
|
+
const route = await this.routeProjectExecution(role, projectRoot);
|
|
964
|
+
return { ...toExecutionPreview(route), projectRoot, taskId };
|
|
965
|
+
},
|
|
966
|
+
/**
|
|
967
|
+
* @param {object} args
|
|
968
|
+
* @param {string} args.cwd
|
|
969
|
+
* @param {string} args.taskId
|
|
970
|
+
* @param {{role: string, selection: "assigned"|"suggested-alternative", strategyFingerprint: string|null, candidateKey: string|null}} args.confirmationTarget -
|
|
971
|
+
* the EXACT confirmationTarget a prior planExecution({role}) preview
|
|
972
|
+
* returned — required; PROJECT TEAM is the sole authority for what
|
|
973
|
+
* executes, so there is no free-form agentId/model override. Before
|
|
974
|
+
* reserving any real quota or launching anything, the real project
|
|
975
|
+
* route is recomputed from scratch (fresh strategy + fresh
|
|
976
|
+
* eligibility) and compared field-for-field against this —
|
|
977
|
+
* strategyFingerprint, role, and the resolved candidateKey must all
|
|
978
|
+
* still match exactly. Any drift (strategy re-approved, quota lost,
|
|
979
|
+
* override changed) rejects outright and asks for a new preview;
|
|
980
|
+
* never silently re-routes to something else. Never accepts a
|
|
981
|
+
* MANUAL_HANDOFF candidate — that's never something Kairo launches.
|
|
982
|
+
*/
|
|
983
|
+
async executePlan({ cwd, taskId, confirmationTarget }) {
|
|
984
|
+
if (!confirmationTarget) throw new Error(`Cannot execute "${taskId}": a confirmationTarget from a fresh planExecution({role}) preview is required — PROJECT TEAM is the sole authority for execution.`);
|
|
985
|
+
const projectRoot = await root(cwd);
|
|
986
|
+
const existing = await executionFor(projectRoot, taskId);
|
|
987
|
+
const record = await verifyExecution(projectRoot, taskId, { checkWorkingTree: !existing });
|
|
988
|
+
if (existing) return { ...publicPlan(record, existing), projectRoot, reused: true };
|
|
989
|
+
|
|
990
|
+
const route = await this.routeProjectExecution(confirmationTarget.role, projectRoot);
|
|
991
|
+
const resolvedCandidate = confirmationTarget.selection === "assigned"
|
|
992
|
+
? (route.decision === "ROUTED" ? route.model : null)
|
|
993
|
+
: (route.decision === "WAIT_FOR_PROJECT_TEAM" ? route.suggestedAlternative?.model ?? null : null);
|
|
994
|
+
const candidateStillMatches = resolvedCandidate
|
|
995
|
+
&& route.strategyFingerprint === confirmationTarget.strategyFingerprint
|
|
996
|
+
&& resolvedCandidate.candidateKey === confirmationTarget.candidateKey;
|
|
997
|
+
if (!candidateStillMatches) {
|
|
998
|
+
throw new Error(`Cannot execute "${taskId}": the real project team state changed since this was confirmed (strategy, eligibility, or override) — request a new preview and confirm again.`);
|
|
999
|
+
}
|
|
1000
|
+
const resolvedAgentId = resolvedCandidate.adapterId;
|
|
1001
|
+
const resolvedModel = resolvedCandidate.modelId;
|
|
1002
|
+
|
|
1003
|
+
const runId = newRunId();
|
|
1004
|
+
const createdAt = new Date().toISOString();
|
|
1005
|
+
try {
|
|
1006
|
+
await reserveExecution(projectRoot, taskId, { runId, agentId: resolvedAgentId, state: "reserved", createdAt, updatedAt: createdAt });
|
|
1007
|
+
} catch (error) {
|
|
1008
|
+
if (error?.code !== "EEXIST") throw error;
|
|
1009
|
+
const raced = await executionFor(projectRoot, taskId);
|
|
1010
|
+
if (!raced) throw error;
|
|
1011
|
+
return { ...publicPlan(record, raced), projectRoot, reused: true };
|
|
1012
|
+
}
|
|
1013
|
+
const task = [
|
|
1014
|
+
"Implement the explicitly approved architecture plan below.",
|
|
1015
|
+
"Follow repository AGENTS.md and Gentle governance. Do not treat plan approval as any additional governance receipt.",
|
|
1016
|
+
"Use safe, non-bypassed permissions for this session.",
|
|
1017
|
+
"",
|
|
1018
|
+
record.planMarkdown
|
|
1019
|
+
].join("\n");
|
|
1020
|
+
try {
|
|
1021
|
+
const started = await launchRun({
|
|
1022
|
+
homeDir, runId, agentId: resolvedAgentId, task, cwd: projectRoot, model: resolvedModel,
|
|
1023
|
+
permissions: [], allowUnsafePermissions: false, permissionSource: "cockpit",
|
|
1024
|
+
// Real, un-redacted assistant/result content flows into this
|
|
1025
|
+
// real run's own event log only when this is true (see
|
|
1026
|
+
// run-redact.js's own allowTranscript gate) — the cockpit is a
|
|
1027
|
+
// local, interactive session where the human launching the run
|
|
1028
|
+
// is the one reading it back live (readRunTranscript below),
|
|
1029
|
+
// never a background/unattended context, so showing the run's
|
|
1030
|
+
// own real output where it's already displayed makes sense.
|
|
1031
|
+
captureTranscript: true, strategy: "direct", wait: false
|
|
1032
|
+
});
|
|
1033
|
+
await updateExecution(projectRoot, taskId, {
|
|
1034
|
+
runId, agentId: resolvedAgentId, state: started.metadata.state, createdAt, updatedAt: new Date().toISOString()
|
|
1035
|
+
});
|
|
1036
|
+
return {
|
|
1037
|
+
...publicPlan(record, {
|
|
1038
|
+
runId, provider: resolvedAgentId, state: started.metadata.state, active: true,
|
|
1039
|
+
error: null, startedAt: started.metadata.startedAt, updatedAt: started.metadata.updatedAt,
|
|
1040
|
+
message: `${resolvedAgentId} run is ${started.metadata.state}.`
|
|
1041
|
+
}),
|
|
1042
|
+
projectRoot,
|
|
1043
|
+
reused: false
|
|
1044
|
+
};
|
|
1045
|
+
} catch (error) {
|
|
1046
|
+
await updateExecution(projectRoot, taskId, {
|
|
1047
|
+
runId, agentId: resolvedAgentId, state: "failed", error: error.message ?? String(error), createdAt,
|
|
1048
|
+
updatedAt: new Date().toISOString()
|
|
1049
|
+
});
|
|
1050
|
+
throw error;
|
|
1051
|
+
}
|
|
1052
|
+
},
|
|
1053
|
+
async cancelExecution({ cwd, taskId }) {
|
|
1054
|
+
const projectRoot = await root(cwd);
|
|
1055
|
+
const link = await readExecution(projectRoot, taskId);
|
|
1056
|
+
if (!link) throw new Error(`Plan "${taskId}" has no Claude execution.`);
|
|
1057
|
+
await cancelRun(homeDir, link.runId);
|
|
1058
|
+
const record = await readPlan(projectRoot, taskId);
|
|
1059
|
+
return { ...publicPlan(record, await executionFor(projectRoot, taskId)), projectRoot };
|
|
1060
|
+
},
|
|
1061
|
+
/**
|
|
1062
|
+
* Tails a real run's own event log for new "run.transcript" entries —
|
|
1063
|
+
* the real, un-redacted assistant/result content a run emits when it
|
|
1064
|
+
* was launched with captureTranscript:true (see executePlan's own
|
|
1065
|
+
* comment). `sinceIndex` is the transcript-relative index (not the
|
|
1066
|
+
* event log's own) the caller has already shown — the cockpit polls
|
|
1067
|
+
* this repeatedly while a run stays active, passing back the real
|
|
1068
|
+
* `nextIndex` each time so it never re-shows a line twice or misses
|
|
1069
|
+
* one. `entries[].provider` is the real adapter id that produced it
|
|
1070
|
+
* (`event.source`), read straight off the real event — never guessed
|
|
1071
|
+
* or defaulted to a single provider, since multiple runs across
|
|
1072
|
+
* different real providers (Codex/Claude/OpenCode) can be active at
|
|
1073
|
+
* once.
|
|
1074
|
+
* @param {object} args
|
|
1075
|
+
* @param {string} args.runId
|
|
1076
|
+
* @param {number} [args.sinceIndex]
|
|
1077
|
+
* @returns {Promise<{runId: string, nextIndex: number, entries: Array<{provider: string|null, timestamp: string|null, text: string}>}>}
|
|
1078
|
+
*/
|
|
1079
|
+
async readRunTranscript({ runId, sinceIndex = 0 }) {
|
|
1080
|
+
const events = await readRunEventsImpl(homeDir, runId);
|
|
1081
|
+
const transcriptEvents = events.filter((event) => event?.type === "run.transcript");
|
|
1082
|
+
const entries = transcriptEvents.slice(sinceIndex).map((event) => ({
|
|
1083
|
+
provider: event.source ?? null,
|
|
1084
|
+
timestamp: event.timestamp ?? null,
|
|
1085
|
+
text: formatTranscriptEventText(event.data)
|
|
1086
|
+
}));
|
|
1087
|
+
return { runId, nextIndex: transcriptEvents.length, entries };
|
|
1088
|
+
}
|
|
1089
|
+
};
|
|
1090
|
+
}
|