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