@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.
Files changed (81) hide show
  1. package/CHANGELOG.md +79 -0
  2. package/package.json +2 -1
  3. package/scripts/cockpit-smoke.mjs +1 -1
  4. package/scripts/ux-smoke-test.sh +3 -3
  5. package/src/cli.js +96 -11
  6. package/src/global/agent-capabilities/create-capability-adapter.js +2 -2
  7. package/src/global/architect/architect-cli.js +76 -0
  8. package/src/global/architect/architect-codex.js +146 -0
  9. package/src/global/architect/architect-manager.js +125 -0
  10. package/src/global/architect/architect-store.js +377 -0
  11. package/src/global/architect/architect-types.js +47 -0
  12. package/src/global/cli-help.js +10 -1
  13. package/src/global/cockpit/app.js +493 -0
  14. package/src/global/cockpit/card.js +111 -0
  15. package/src/global/cockpit/cli.js +33 -0
  16. package/src/global/cockpit/gauge.js +31 -0
  17. package/src/global/cockpit/project-overlay.js +693 -0
  18. package/src/global/cockpit/rows.js +148 -0
  19. package/src/global/cockpit/theme.js +118 -0
  20. package/src/global/cockpit/view.js +1298 -0
  21. package/src/global/conversation/bootstrap-analyzer-adapters.js +251 -0
  22. package/src/global/conversation/cli.js +53 -0
  23. package/src/global/conversation/codex-sandbox.js +230 -0
  24. package/src/global/conversation/cursor-sandbox.js +215 -0
  25. package/src/global/conversation/project-analysis.js +204 -0
  26. package/src/global/conversation/project-profile.js +178 -0
  27. package/src/global/conversation/project-router.js +149 -0
  28. package/src/global/conversation/project-strategy-store.js +64 -0
  29. package/src/global/conversation/project-strategy.js +514 -0
  30. package/src/global/conversation/sanitized-snapshot.js +169 -0
  31. package/src/global/conversation/secret-scanner.js +71 -0
  32. package/src/global/conversation/service.js +1090 -0
  33. package/src/global/conversation/session-store.js +75 -0
  34. package/src/global/conversation/transcript-store.js +79 -0
  35. package/src/global/conversation/ui.js +195 -0
  36. package/src/global/intelligence/capability-scoring.js +480 -0
  37. package/src/global/intelligence/execution-router.js +466 -0
  38. package/src/global/intelligence/kairo-telemetry-source.js +59 -0
  39. package/src/global/intelligence/kairobench-runner.js +85 -0
  40. package/src/global/intelligence/kairobench-source.js +34 -0
  41. package/src/global/intelligence/kairobench-tasks.js +47 -0
  42. package/src/global/intelligence/model-candidate-catalog.js +456 -0
  43. package/src/global/intelligence/model-capability-registry-sources.js +145 -0
  44. package/src/global/intelligence/model-capability-registry.js +125 -0
  45. package/src/global/intelligence/model-intelligence.js +1646 -0
  46. package/src/global/intelligence/official-benchmark-snapshots.js +162 -0
  47. package/src/global/intelligence/quick-ask.js +149 -0
  48. package/src/global/intelligence/role-profiles.js +251 -0
  49. package/src/global/intelligence/skill-catalog.js +67 -0
  50. package/src/global/intelligence/subscription-pressure-source.js +41 -0
  51. package/src/global/mcp/kairo-mcp.js +51 -18
  52. package/src/global/mcp/work-snapshot-rule.js +4 -2
  53. package/src/global/mcp/workspace-binding.js +88 -0
  54. package/src/global/mcp/workspace-mcp-entry.js +74 -0
  55. package/src/global/mcp-install.js +8 -1
  56. package/src/global/observability/artificial-analysis-models.js +118 -0
  57. package/src/global/observability/claude-models.js +31 -0
  58. package/src/global/observability/claude-usage.js +112 -0
  59. package/src/global/observability/codex-models.js +96 -0
  60. package/src/global/observability/codex-usage.js +160 -0
  61. package/src/global/observability/cursor-auth.js +88 -0
  62. package/src/global/observability/cursor-models.js +101 -0
  63. package/src/global/observability/huggingface-leaderboard.js +97 -0
  64. package/src/global/observability/opencode-models.js +101 -0
  65. package/src/global/observability/opencode-usage.js +162 -0
  66. package/src/global/paths.js +49 -2
  67. package/src/global/profile.js +23 -1
  68. package/src/global/runtime/execution-adapters/claude.js +63 -30
  69. package/src/global/runtime/execution-adapters/codex.js +9 -2
  70. package/src/global/runtime/execution-adapters/create-execution-adapter.js +6 -1
  71. package/src/global/runtime/execution-adapters/opencode.js +83 -18
  72. package/src/global/runtime/execution-worktree-manager.js +924 -0
  73. package/src/global/runtime/execution-worktree-orchestrator.js +194 -0
  74. package/src/global/runtime/execution-worktree-store.js +83 -0
  75. package/src/global/runtime/execution-worktree-types.js +45 -0
  76. package/src/global/runtime/run-events.js +38 -0
  77. package/src/global/runtime/run-manager.js +22 -6
  78. package/src/global/runtime/run-supervisor.js +41 -12
  79. package/src/global/runtime/usage-manager.js +96 -0
  80. package/src/global/runtime/usage-store.js +69 -0
  81. 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
+ }