@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,514 @@
1
+ // Combines a real ProjectProfile (project-profile.js), a real, already-
2
+ // validated ProjectAnalysis from the Bootstrap Analyst (project-analysis.js
3
+ // — which the analyst produced by actually reading the project, not a
4
+ // fixed mechanical checklist), and the real candidate pool (scoreAvailableModels
5
+ // output, eligibility, and the Model Intelligence Foundation registry —
6
+ // exactly what buildAiTeam/buildEfficientTeam already need) into a
7
+ // ProjectStrategy: which roles THIS project actually needs, AND which
8
+ // real model wins each one — genuinely re-scored against the project's
9
+ // own real, analyst-derived capabilities per role, never just the generic
10
+ // global team filtered down to a subset of role names.
11
+ //
12
+ // bootstrapAnalyst is NOT decided in this module — by the time
13
+ // buildProjectStrategy runs, the human has already chosen and confirmed a
14
+ // real model (see computeBootstrapAnalystAlternatives + the
15
+ // LOCAL_PREFLIGHT/AWAITING_ANALYST/ANALYZING flow in conversation/service.js),
16
+ // and that model has already produced the real analysis this module
17
+ // consumes. The analyst never picks the team; it only investigates.
18
+
19
+ import { buildAiTeam, buildEfficientTeam, ensureRegistry } from "../intelligence/model-intelligence.js";
20
+ import { ROLE_CAPABILITIES } from "../intelligence/role-profiles.js";
21
+ import { computeRoleEvaluations } from "../intelligence/capability-scoring.js";
22
+
23
+ // The Bootstrap Analyst investigates read-only via askProvider
24
+ // (intelligence/quick-ask.js), which only actually supports these two
25
+ // providers today — offering any other real candidate as an "alternative"
26
+ // here would be a menu item Kairo can't actually run.
27
+ const ASK_SUPPORTED_ADAPTERS = new Set(["codex", "claude"]);
28
+
29
+ /**
30
+ * The Bootstrap Analyst as a temporary, read-only WORKFLOW — deliberately
31
+ * NOT a RoleProfile (see role-profiles.js's ROLE_PROFILES/ROLE_CAPABILITIES,
32
+ * the six real team roles): the analyst never joins the team, never
33
+ * writes anything, and only exists for the duration of one real /project
34
+ * analyze run. Shaped like a RoleProfile (same fields) purely so this
35
+ * codebase's one established "what does doing this job actually mean"
36
+ * shape gets reused instead of inventing a second one — capabilities is
37
+ * the SAME required-reasoning/optional-instructionFollowing baseline the
38
+ * old ANALYST_CAPABILITIES constant hardcoded, now declared once here and
39
+ * consumed everywhere the analyst's own capability floor matters.
40
+ * @type {{role: string, objective: string, responsibility: string, capabilities: {required: string[], optional: string[]}, allowedActions: string[], allowedActionIds: string[], deliverable: string, completionCriteria: string}}
41
+ */
42
+ export const BOOTSTRAP_ANALYST_PROFILE = {
43
+ role: "BootstrapAnalyst",
44
+ objective: "Investigate a real, not-yet-analyzed project read-only and return a structured, evidence-backed ProjectAnalysis Kairo can trust to derive this project's real role requirements from.",
45
+ responsibility: "Read the real project (files, history, workflow docs already collected by project-profile.js, plus anything else it reads on its own) and report real architecture traits, real risks, and which of Kairo's six roles this specific project actually needs — never a boilerplate or generic answer.",
46
+ capabilities: { required: ["reasoning"], optional: ["instructionFollowing"] },
47
+ allowedActions: ["read files", "search/grep the repository", "run read-only inspection commands (e.g. git log, git blame)"],
48
+ allowedActionIds: ["repo.read", "repo.search", "repo.inspect_history"],
49
+ deliverable: "A valid ProjectAnalysis (see project-analysis.js's PROJECT_ANALYSIS_SCHEMA) — every field backed by a real file the analyst actually read, never an invented finding.",
50
+ completionCriteria: "The analysis identifies this project's real architecture, its real risks, and which roles it actually needs, each with real supporting evidence — not just a subset copied from a generic checklist."
51
+ };
52
+
53
+ function modelRef(teamModel) {
54
+ if (!teamModel) return null;
55
+ return { adapterId: teamModel.adapterId, modelId: teamModel.modelId, displayName: teamModel.displayName ?? null };
56
+ }
57
+
58
+ /**
59
+ * The richer model reference `projectTeam` entries carry — adds
60
+ * `candidateKey` (the identity/scoring join key) and `accessMode`
61
+ * (subscription/API/manual — see model-candidate-catalog.js) on top of
62
+ * `modelRef`'s plain adapterId/modelId/displayName, since projectTeam is
63
+ * the OPERATIONAL team a real router resolves against, not just
64
+ * comparative evidence — it needs enough to actually route and launch.
65
+ */
66
+ function projectModelRef(teamModel) {
67
+ if (!teamModel) return null;
68
+ return {
69
+ candidateKey: teamModel.candidateKey ?? null, adapterId: teamModel.adapterId, modelId: teamModel.modelId,
70
+ displayName: teamModel.displayName ?? null, accessMode: teamModel.accessMode ?? null
71
+ };
72
+ }
73
+
74
+ /**
75
+ * The project's own real role->capabilities map, straight from its (by
76
+ * now analyst-derived) roleRequirements — never the generic global
77
+ * table's OWN capability set. But whether a project-derived capability
78
+ * is required or merely optional still comes from the global table's own
79
+ * measured required/optional split (ROLE_CAPABILITIES[role].optional) —
80
+ * `requirement.capabilities` itself is a flat array (project-analysis.js's
81
+ * deriveRoleRequirements never distinguishes required from optional; the
82
+ * Bootstrap Analyst's own schema doesn't ask for that distinction
83
+ * either), and passing a flat array straight through would hit
84
+ * normalizeRoleCapabilities' legacy branch — "every entry required" —
85
+ * silently reintroducing the exact scarce-evidence problem the required/
86
+ * optional split fixed globally (e.g. a project need citing
87
+ * softwareExecution would make it a hard requirement again, per
88
+ * role-profiles.js's own measured ~2-3-candidates-system-wide finding).
89
+ * A capability the project cites that ALSO appears in the role's global
90
+ * optional list stays optional here; everything else (the role's own
91
+ * global-required capabilities, plus anything project-specific the
92
+ * analyst found real evidence for that isn't in the global optional
93
+ * list) stays required — the project's own real evidence is still
94
+ * trusted, just not blindly promoted past what's already known to be
95
+ * thin evidence system-wide.
96
+ */
97
+ function projectRoleCapabilities(profile) {
98
+ return Object.fromEntries(profile.roleRequirements.map((requirement) => {
99
+ const globalOptional = new Set(ROLE_CAPABILITIES[requirement.role]?.optional ?? []);
100
+ const required = requirement.capabilities.filter((capability) => !globalOptional.has(capability));
101
+ const optional = requirement.capabilities.filter((capability) => globalOptional.has(capability));
102
+ return [requirement.role, { required, optional }];
103
+ }));
104
+ }
105
+
106
+ function candidateKeyOf(model) {
107
+ return model.candidateKey ?? `${model.adapterId}::${model.modelId}`;
108
+ }
109
+
110
+ function quotaFor(providerCapacity, adapterId) {
111
+ return providerCapacity?.[adapterId]?.quotaRemainingPercent ?? null;
112
+ }
113
+
114
+ /**
115
+ * The full real Bootstrap Analyst catalog — every real, ask-supported
116
+ * (see ASK_SUPPORTED_ADAPTERS) Codex/Claude candidate askProvider could
117
+ * actually run, scored AND unscored, not just the top Quality/Efficient
118
+ * picks. A real, unscored model (no Artificial Analysis match) is still
119
+ * included — honestly marked `evidenceStatus: "unscored"` — so a human
120
+ * can still pick it manually; Kairo just never recommends one on its own.
121
+ * Ranking itself is the SAME real Pareto/risk-floor machinery every other
122
+ * role uses (BOOTSTRAP_ANALYST_PROFILE.capabilities, under the "Explorer"
123
+ * role bucket — see this module's own history for why that bucket name
124
+ * is reused rather than a new one) — this function never invents a
125
+ * second ranking formula, it only projects the real result into a richer
126
+ * catalog shape and tags each real candidate that happens to be the
127
+ * Quality and/or Efficient winner.
128
+ * @param {object} args - `scoredAll`, `eligibility`, `registry`,
129
+ * `providerCapacity` (as computeBootstrapAnalystAlternatives), plus
130
+ * `unscoredModels` (real catalog models with no AA match — see
131
+ * conversation/service.js's own `unscoredModels`).
132
+ * @returns {{recommendedModel: object|null, models: Array<{candidateKey: string, adapterId: string, modelId: string, displayName: string, evidenceStatus: string, available: boolean, quota: number|null, recommendationTags: string[]}>}}
133
+ */
134
+ export function computeBootstrapAnalystCatalog({ scoredAll, eligibility, registry, providerCapacity = null, unscoredModels = [] }) {
135
+ // Restrict the CANDIDATE POOL itself to ask-supported adapters before
136
+ // ranking — not a post-hoc check on the winner — so the real portfolio
137
+ // logic picks the best real candidate among what Kairo can actually
138
+ // invoke, the same way it would for any other real role. Filtering
139
+ // after the fact would silently lose a genuinely real 2nd/3rd-place
140
+ // candidate whenever the unsupported provider happened to rank #1.
141
+ const askSupportedScored = scoredAll.filter((model) => ASK_SUPPORTED_ADAPTERS.has(model.adapterId));
142
+ // scoredAll (the Recommendation Pool) already excludes superseded
143
+ // candidates (buildRecommendationPool); unscoredModels doesn't go
144
+ // through that pool, so the same real "not superseded" rule is applied
145
+ // here too — defense in depth, never trusting the caller alone to have
146
+ // already filtered a real, proven-stale candidate out.
147
+ const askSupportedUnscored = unscoredModels.filter((model) => ASK_SUPPORTED_ADAPTERS.has(model.adapterId) && model.lifecycle !== "superseded");
148
+
149
+ const roleCapabilities = { Explorer: BOOTSTRAP_ANALYST_PROFILE.capabilities };
150
+ const aiTeam = buildAiTeam(askSupportedScored, eligibility, registry, roleCapabilities);
151
+ const efficientTeam = buildEfficientTeam(askSupportedScored, eligibility, registry, { providerCapacity, roleCapabilities });
152
+ const quality = aiTeam.find((entry) => entry.role === "Explorer")?.primary ?? null;
153
+ const efficient = efficientTeam.find((entry) => entry.role === "Explorer")?.primary ?? null;
154
+ const qualityKey = quality ? candidateKeyOf(quality) : null;
155
+ const efficientKey = efficient ? candidateKeyOf(efficient) : null;
156
+
157
+ const scoredEntries = askSupportedScored.map((model) => {
158
+ const key = candidateKeyOf(model);
159
+ const recommendationTags = [];
160
+ if (key === qualityKey) recommendationTags.push("quality");
161
+ if (key === efficientKey) recommendationTags.push("efficient");
162
+ return {
163
+ candidateKey: key, adapterId: model.adapterId, modelId: model.modelId,
164
+ displayName: model.modelName ?? model.displayName ?? model.modelId,
165
+ evidenceStatus: model.evidenceStatus ?? "scored",
166
+ available: eligibility[model.adapterId]?.ok === true,
167
+ quota: quotaFor(providerCapacity, model.adapterId),
168
+ recommendationTags
169
+ };
170
+ });
171
+ const unscoredEntries = askSupportedUnscored.map((model) => ({
172
+ candidateKey: candidateKeyOf(model), adapterId: model.adapterId, modelId: model.modelId,
173
+ displayName: model.displayName ?? model.modelId,
174
+ evidenceStatus: "unscored",
175
+ available: eligibility[model.adapterId]?.ok === true,
176
+ quota: quotaFor(providerCapacity, model.adapterId),
177
+ recommendationTags: []
178
+ }));
179
+
180
+ const models = [...scoredEntries, ...unscoredEntries];
181
+ const recommendedModel = models.find((model) => model.recommendationTags.includes("quality")) ?? null;
182
+ return { recommendedModel, models };
183
+ }
184
+
185
+ /**
186
+ * Real quality/efficiency Bootstrap Analyst alternatives — computed BEFORE
187
+ * any analysis runs (LOCAL_PREFLIGHT/AWAITING_ANALYST), restricted to
188
+ * providers Kairo can actually invoke read-only (see ASK_SUPPORTED_ADAPTERS).
189
+ * A provider that would otherwise win Explorer but can't actually run ASK
190
+ * (e.g. opencode-go today) is honestly excluded here, never offered as a
191
+ * choice Kairo can't follow through on. A thin projection of
192
+ * computeBootstrapAnalystCatalog's own real ranking — never a second,
193
+ * independent ranking computation.
194
+ *
195
+ * Kept in this plain `{choice, model}` shape ONLY for the existing plain-
196
+ * text `/project analyst quality|efficient --confirm` subcommand, which
197
+ * predates the full catalog and can only ever offer these two picks. Each
198
+ * entry also carries the real `selectionSource`/`recommendationTags` the
199
+ * richer picker (the interactive overlay's analyst catalog step) needs —
200
+ * both a recommended catalog pick, by construction — so both callers can
201
+ * pass the exact same `analyst` shape into runBootstrapAnalysis/
202
+ * buildProjectStrategy.
203
+ * @param {object} candidates - `scoredAll`, `eligibility`, `registry`, `providerCapacity`
204
+ * @returns {Array<{choice: "quality"|"efficient", model: object, selectionSource: "recommended", recommendationTags: string[]}>}
205
+ */
206
+ export function computeBootstrapAnalystAlternatives(candidates) {
207
+ const { models } = computeBootstrapAnalystCatalog(candidates);
208
+ const quality = models.find((model) => model.recommendationTags.includes("quality"));
209
+ const efficient = models.find((model) => model.recommendationTags.includes("efficient"));
210
+ const toAlternativeModel = (model) => (model ? { adapterId: model.adapterId, modelId: model.modelId, displayName: model.displayName } : null);
211
+ return [
212
+ quality ? { choice: "quality", model: toAlternativeModel(quality), selectionSource: "recommended", recommendationTags: quality.recommendationTags } : null,
213
+ efficient ? { choice: "efficient", model: toAlternativeModel(efficient), selectionSource: "recommended", recommendationTags: efficient.recommendationTags } : null
214
+ ].filter(Boolean);
215
+ }
216
+
217
+ /**
218
+ * Builds a "suggested" ProjectStrategy — never "active" (that only happens
219
+ * via explicit human approval, see project-strategy-store.js +
220
+ * conversation/service.js's approveProjectStrategy).
221
+ *
222
+ * orchestrator is the real, project-rescored Architect pick — a decision
223
+ * SEPARATE from bootstrapAnalyst (given in, already confirmed and run by
224
+ * this point), even when they happen to land on the same real model
225
+ * because only one real candidate is accessible right now.
226
+ * @param {object} profile - computeProjectProfile() result, with
227
+ * roleRequirements already replaced by project-analysis.js's
228
+ * deriveRoleRequirements() output (real analyst findings + mechanical floor)
229
+ * @param {object} candidates - the real candidate pool: `scoredAll`
230
+ * (scoreAvailableModels output, every candidate provider), `eligibility`
231
+ * (checkCandidate results per adapterId), `registry` (Model Intelligence
232
+ * Foundation registry), `providerCapacity` (optional, for EFFICIENT's
233
+ * quota tiebreak) — exactly what conversation/service.js's snapshot()
234
+ * already attaches to modelIntelligence for this purpose.
235
+ * @param {{model: object, choice?: "quality"|"efficient"|null, selectionSource?: "recommended"|"manual", recommendationTags?: string[]}} bootstrapAnalyst -
236
+ * the real model the human already chose, confirmed, and ran. `choice`
237
+ * is legacy — only the plain-text `/project analyst quality|efficient`
238
+ * subcommand still relies on it (see computeBootstrapAnalystAlternatives);
239
+ * any real catalog pick (including a manual/unscored one that fits
240
+ * neither bucket) is honestly `choice: null`, never forced into one.
241
+ * `selectionSource`/`recommendationTags` are the real, current contract —
242
+ * defaulted to "recommended"/`[bootstrapAnalyst.choice]` when a caller
243
+ * doesn't supply them, for the legacy path's own backward compatibility.
244
+ * @returns {object} ProjectStrategy (status: "suggested")
245
+ */
246
+ export function buildProjectStrategy(profile, { scoredAll, eligibility, registry, providerCapacity = null }, bootstrapAnalyst) {
247
+ const roleCapabilities = projectRoleCapabilities(profile);
248
+ const aiTeam = buildAiTeam(scoredAll, eligibility, registry, roleCapabilities);
249
+ const efficientTeam = buildEfficientTeam(scoredAll, eligibility, registry, { providerCapacity, roleCapabilities });
250
+
251
+ const byRoleCapability = new Map(aiTeam.map((entry) => [entry.role, entry]));
252
+ const byRoleEfficient = new Map(efficientTeam.map((entry) => [entry.role, entry]));
253
+
254
+ // Only a role the profile's own real evidence asked for (roleRequirements)
255
+ // AND that has a real pick (against THIS project's own capability mix)
256
+ // is "active" — a required role with no real eligible model is honestly
257
+ // dropped, never filled with a guess.
258
+ const activeRoles = profile.roleRequirements
259
+ .map((requirement) => requirement.role)
260
+ .filter((role) => byRoleCapability.has(role));
261
+
262
+ const qualityTeam = activeRoles.map((role) => {
263
+ const entry = byRoleCapability.get(role);
264
+ return { role, model: modelRef(entry.primary), reason: entry.reason ?? null };
265
+ });
266
+ const efficientRoles = activeRoles.map((role) => {
267
+ const entry = byRoleEfficient.get(role);
268
+ return entry ? { role, model: modelRef(entry.primary), reason: entry.reason ?? null } : { role, model: null, reason: null };
269
+ });
270
+
271
+ // The OPERATIONAL team a real router resolves against (see
272
+ // project-router.js) — reuses the exact same real Pareto/risk-floor
273
+ // balance already computed above for efficientTeam (byRoleEfficient),
274
+ // never a third selection formula. qualityTeam/efficientTeam remain
275
+ // comparative evidence only; projectTeam is what Kairo actually
276
+ // delegates to. `assignmentSource` is always "recommended" here — a
277
+ // human override (per-role, never touching this computed ranking) is a
278
+ // separate, later cockpit action that sets it to "override".
279
+ //
280
+ // `fallback` persists the SAME real next-best candidate
281
+ // buildEfficientTeam already computed for this role (its own `fallback`
282
+ // field — the next real eligible candidate under a different adapter,
283
+ // see model-intelligence.js) — never a new alternative-ranking formula.
284
+ // The router uses this, PERSISTED here at analysis time, as its own
285
+ // real `suggestedAlternative` when the primary assignment's eligibility
286
+ // is lost later — alternative selection is domain policy, computed
287
+ // once here, never re-derived independently by the UI/CLI/router (which
288
+ // would risk drifting to different answers for the same real state).
289
+ // recommendedAssignment freezes the real original pick (model, fallback,
290
+ // decisionEvidence) — immutable, never touched by a later override. The
291
+ // top-level model/fallback/decisionEvidence fields are the CURRENT
292
+ // OPERATIONAL assignment (what project-router.js actually resolves
293
+ // against) — identical to recommendedAssignment until a human overrides
294
+ // this role (see applyProjectTeamOverride), at which point they diverge
295
+ // and overrideEvidence records the real access/evidence state behind
296
+ // that specific override, never reusing the original recommendation's
297
+ // own evidence as if it justified a different model.
298
+ const projectTeam = activeRoles.map((role) => {
299
+ const entry = byRoleEfficient.get(role);
300
+ const model = entry ? projectModelRef(entry.primary) : null;
301
+ const fallback = entry?.fallback ? projectModelRef(entry.fallback) : null;
302
+ const decisionEvidence = entry?.decisionEvidence ?? null;
303
+ // The same real, human-readable string efficientTeam's own entries
304
+ // already carry (see buildEfficientTeam/describeEfficiencyDecision) —
305
+ // never a new explanation formula, just surfaced here too so the
306
+ // overlay can show WHY this role got this model, not only which one.
307
+ const reason = entry?.reason ?? null;
308
+ return {
309
+ role, model, fallback, decisionEvidence, reason,
310
+ assignmentSource: "recommended",
311
+ recommendedAssignment: { model, fallback, decisionEvidence, reason },
312
+ overrideEvidence: null
313
+ };
314
+ });
315
+
316
+ return {
317
+ status: "suggested",
318
+ bootstrapAnalyst: bootstrapAnalyst.model,
319
+ bootstrapAnalystChoice: bootstrapAnalyst.choice ?? null,
320
+ bootstrapAnalystSelectionSource: bootstrapAnalyst.selectionSource ?? "recommended",
321
+ bootstrapAnalystRecommendationTags: bootstrapAnalyst.recommendationTags ?? (bootstrapAnalyst.choice ? [bootstrapAnalyst.choice] : []),
322
+ orchestrator: modelRef(byRoleCapability.get("Architect")?.primary),
323
+ activeRoles,
324
+ qualityTeam,
325
+ efficientTeam: efficientRoles,
326
+ projectTeam,
327
+ profileFingerprint: profile.fingerprint,
328
+ approvedAt: null
329
+ };
330
+ }
331
+
332
+ /**
333
+ * Whether a persisted, previously-approved ProjectStrategy is STALE — its
334
+ * real underlying evidence (the ProjectProfile it was built from) has
335
+ * genuinely changed, not just "some time has passed". A suggested (never
336
+ * approved) strategy is never marked stale — it wasn't a commitment yet.
337
+ * @param {object|null} strategy - the persisted ProjectStrategy, or null (NOT_ANALYZED)
338
+ * @param {object} currentProfile - a freshly computed ProjectProfile
339
+ * @returns {boolean}
340
+ */
341
+ export function isStrategyStale(strategy, currentProfile) {
342
+ if (!strategy || strategy.status !== "active") return false;
343
+ return strategy.profileFingerprint !== currentProfile.fingerprint;
344
+ }
345
+
346
+ // projectTeam editing (section 4 — "Edición persistida del PROJECT TEAM").
347
+ // A SUGGESTED strategy only, never active/stale (see applyProjectTeamOverride/
348
+ // resetProjectTeamAssignment's own guards) — the review-before-approval
349
+ // window, not a way to silently mutate an already-running team.
350
+
351
+ // Every real adapter a projectTeam role can be assigned to for EXECUTION,
352
+ // not just the two askProvider-supported ones the Bootstrap Analyst
353
+ // catalog uses (computeBootstrapAnalystCatalog) — Cursor/OpenCode Go are
354
+ // real, selectable manual-handoff options here.
355
+ const TEAM_EDIT_ADAPTERS = new Set(["codex", "claude", "cursor", "opencode-go"]);
356
+
357
+ /**
358
+ * The real edit catalog for one role — every real, non-superseded
359
+ * candidate (scored AND unscored) from all four real adapters, each with
360
+ * its own real accessMode/availability/evidenceStatus and (when the
361
+ * registry has real evidence for it) a real RoleEvaluation for this
362
+ * SPECIFIC role — reused via capability-scoring.js's own
363
+ * computeRoleEvaluations, never a new scoring formula. The role's
364
+ * required capabilities come from the GLOBAL role-profiles.js table
365
+ * (ROLE_CAPABILITIES), not the project's own analyst-derived
366
+ * requirements — the persisted ProjectStrategy doesn't carry the full
367
+ * ProjectProfile forward, only its fingerprint, so the project-specific
368
+ * capability mix isn't available again at edit time; the global table is
369
+ * real, existing data, not an invented substitute.
370
+ * @param {string} role
371
+ * @param {object} candidates - `scoredAll`, `eligibility`, `registry`, `unscoredModels`
372
+ * @returns {{role: string, models: Array<{candidateKey: string, adapterId: string, modelId: string, displayName: string, accessMode: string|null, evidenceStatus: string, available: boolean, roleEvaluation: object|null}>}}
373
+ */
374
+ export function computeProjectTeamEditCatalog(role, { scoredAll = [], eligibility = {}, registry = null, unscoredModels = [] }) {
375
+ const capabilities = ROLE_CAPABILITIES[role];
376
+ if (!capabilities) return { role, models: [] };
377
+
378
+ const teamScored = scoredAll.filter((model) => TEAM_EDIT_ADAPTERS.has(model.adapterId));
379
+ // Same "not superseded" real rule the Recommendation Pool applies to
380
+ // scoredAll — unscoredModels doesn't go through that pool, so it's
381
+ // applied here too, defense in depth, never trusting the caller alone.
382
+ const teamUnscored = unscoredModels.filter((model) => TEAM_EDIT_ADAPTERS.has(model.adapterId) && model.lifecycle !== "superseded");
383
+ // Same real registry-seeding every other role computation relies on
384
+ // (buildAiTeam/buildEfficientTeam's own ensureRegistry) — a caller's
385
+ // real registry might already have richer evidence (Hugging Face,
386
+ // manufacturer snapshots, Kairo's own telemetry); a bare/empty one gets
387
+ // scoreAvailableModels' own AA fields seeded in, never left empty.
388
+ const effectiveRegistry = ensureRegistry(teamScored, registry);
389
+ const evaluations = computeRoleEvaluations(effectiveRegistry, teamScored, role, capabilities.required);
390
+
391
+ const scoredEntries = teamScored.map((model) => {
392
+ const key = candidateKeyOf(model);
393
+ return {
394
+ candidateKey: key, adapterId: model.adapterId, modelId: model.modelId,
395
+ displayName: model.modelName ?? model.displayName ?? model.modelId,
396
+ accessMode: model.accessMode ?? null, evidenceStatus: model.evidenceStatus ?? "scored",
397
+ available: eligibility[model.adapterId]?.ok === true,
398
+ roleEvaluation: evaluations.get(`${model.adapterId}::${model.modelId}`) ?? null
399
+ };
400
+ });
401
+ const unscoredEntries = teamUnscored.map((model) => ({
402
+ candidateKey: candidateKeyOf(model), adapterId: model.adapterId, modelId: model.modelId,
403
+ displayName: model.displayName ?? model.modelId,
404
+ accessMode: model.accessMode ?? null, evidenceStatus: "unscored",
405
+ available: eligibility[model.adapterId]?.ok === true,
406
+ roleEvaluation: null
407
+ }));
408
+
409
+ return { role, models: [...scoredEntries, ...unscoredEntries] };
410
+ }
411
+
412
+ /**
413
+ * Whether two real model references identify the SAME real candidate —
414
+ * prefers candidateKey (the real identity/scoring join key), but falls
415
+ * back to adapterId+modelId when either side lacks one, so "is this the
416
+ * same real model as the recommendation" stays correct even for a real
417
+ * model reference that predates the candidateKey join.
418
+ */
419
+ function sameModel(a, b) {
420
+ if (!a || !b) return false;
421
+ if (a.candidateKey && b.candidateKey) return a.candidateKey === b.candidateKey;
422
+ return a.adapterId === b.adapterId && a.modelId === b.modelId;
423
+ }
424
+
425
+ function findProjectTeamEntry(strategy, role) {
426
+ if (!strategy) throw new Error("No project strategy to edit — run /project analyze first.");
427
+ if (strategy.status !== "suggested") throw new Error(`Cannot edit a ${strategy.status?.toUpperCase() ?? "UNKNOWN"} project strategy — only a SUGGESTED one is editable.`);
428
+ if (!Array.isArray(strategy.projectTeam)) throw new Error("This project strategy was approved before projectTeam existed — re-analyze and approve to enable editing.");
429
+ const index = strategy.projectTeam.findIndex((entry) => entry.role === role);
430
+ if (index === -1) throw new Error(`"${role}" is not part of this project's team.`);
431
+ return index;
432
+ }
433
+
434
+ /**
435
+ * Applies a manual override to one role — the real, edit-catalog-sourced
436
+ * `candidate` becomes the role's real operational model. Picking the same
437
+ * candidate as the role's own real recommendation is treated as a reset
438
+ * (see resetProjectTeamAssignment), not a redundant override — the plan's
439
+ * own "choosing the recommended model again removes the override" rule.
440
+ * recommendedAssignment is NEVER touched; a legacy entry that predates
441
+ * this field (recommendedAssignment undefined) has its own current real
442
+ * model/fallback/decisionEvidence captured as the recommendation here,
443
+ * lazily, since that WAS this project's real original recommendation
444
+ * before any override existed — never lost, never guessed.
445
+ * @param {object} strategy - the persisted SUGGESTED ProjectStrategy
446
+ * @param {string} role
447
+ * @param {{candidateKey: string, adapterId: string, modelId: string, displayName: string, accessMode?: string|null, available?: boolean, evidenceStatus?: string, roleEvaluation?: object|null}} candidate -
448
+ * one real entry from computeProjectTeamEditCatalog's own output.
449
+ * @returns {object} the updated ProjectStrategy (still status: "suggested")
450
+ */
451
+ export function applyProjectTeamOverride(strategy, role, candidate) {
452
+ const index = findProjectTeamEntry(strategy, role);
453
+ const entry = strategy.projectTeam[index];
454
+ const recommendedAssignment = entry.recommendedAssignment
455
+ ?? { model: entry.model, fallback: entry.fallback ?? null, decisionEvidence: entry.decisionEvidence ?? null, reason: entry.reason ?? null };
456
+
457
+ if (sameModel(recommendedAssignment.model, candidate)) {
458
+ return resetProjectTeamAssignment(strategy, role);
459
+ }
460
+
461
+ const updatedEntry = {
462
+ ...entry,
463
+ recommendedAssignment,
464
+ model: {
465
+ candidateKey: candidate.candidateKey ?? null, adapterId: candidate.adapterId, modelId: candidate.modelId,
466
+ displayName: candidate.displayName ?? null, accessMode: candidate.accessMode ?? null
467
+ },
468
+ // The real recommendation's own fallback/decisionEvidence/reason
469
+ // describe THAT candidate's Pareto selection, never a human
470
+ // override's — an override has no real computed fallback, decision
471
+ // receipt, or ranking reason of its own (it wasn't chosen by the
472
+ // ranking at all), so all three are honestly cleared rather than left
473
+ // pointing at evidence for a different model, which would
474
+ // misrepresent it as if it applied here.
475
+ fallback: null,
476
+ decisionEvidence: null,
477
+ reason: null,
478
+ assignmentSource: "override",
479
+ overrideEvidence: {
480
+ accessMode: candidate.accessMode ?? null, available: candidate.available ?? null,
481
+ evidenceStatus: candidate.evidenceStatus ?? null, roleEvaluation: candidate.roleEvaluation ?? null
482
+ }
483
+ };
484
+ const projectTeam = [...strategy.projectTeam];
485
+ projectTeam[index] = updatedEntry;
486
+ return { ...strategy, projectTeam };
487
+ }
488
+
489
+ /**
490
+ * Removes a role's override (if any) and restores its real original
491
+ * recommendation — model, fallback, and decisionEvidence exactly as
492
+ * recommendedAssignment froze them. A no-op-shaped call on a role that
493
+ * was never overridden just re-confirms the same real recommendation.
494
+ * @param {object} strategy - the persisted SUGGESTED ProjectStrategy
495
+ * @param {string} role
496
+ * @returns {object} the updated ProjectStrategy (still status: "suggested")
497
+ */
498
+ export function resetProjectTeamAssignment(strategy, role) {
499
+ const index = findProjectTeamEntry(strategy, role);
500
+ const entry = strategy.projectTeam[index];
501
+ const recommendedAssignment = entry.recommendedAssignment
502
+ ?? { model: entry.model, fallback: entry.fallback ?? null, decisionEvidence: entry.decisionEvidence ?? null, reason: entry.reason ?? null };
503
+ const updatedEntry = {
504
+ ...entry,
505
+ recommendedAssignment,
506
+ model: recommendedAssignment.model, fallback: recommendedAssignment.fallback,
507
+ decisionEvidence: recommendedAssignment.decisionEvidence, reason: recommendedAssignment.reason ?? null,
508
+ assignmentSource: "recommended",
509
+ overrideEvidence: null
510
+ };
511
+ const projectTeam = [...strategy.projectTeam];
512
+ projectTeam[index] = updatedEntry;
513
+ return { ...strategy, projectTeam };
514
+ }