@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.
- package/CHANGELOG.md +50 -0
- package/package.json +2 -1
- package/scripts/cockpit-smoke.mjs +1 -1
- package/scripts/ux-smoke-test.sh +3 -3
- package/src/cli.js +96 -11
- package/src/global/agent-capabilities/create-capability-adapter.js +2 -2
- package/src/global/architect/architect-cli.js +76 -0
- package/src/global/architect/architect-codex.js +146 -0
- package/src/global/architect/architect-manager.js +125 -0
- package/src/global/architect/architect-store.js +377 -0
- package/src/global/architect/architect-types.js +47 -0
- package/src/global/cli-help.js +10 -1
- package/src/global/cockpit/app.js +475 -0
- package/src/global/cockpit/card.js +111 -0
- package/src/global/cockpit/cli.js +33 -0
- package/src/global/cockpit/gauge.js +31 -0
- package/src/global/cockpit/project-overlay.js +683 -0
- package/src/global/cockpit/rows.js +148 -0
- package/src/global/cockpit/theme.js +118 -0
- package/src/global/cockpit/view.js +1263 -0
- package/src/global/conversation/bootstrap-analyzer-adapters.js +251 -0
- package/src/global/conversation/cli.js +53 -0
- package/src/global/conversation/codex-sandbox.js +230 -0
- package/src/global/conversation/cursor-sandbox.js +215 -0
- package/src/global/conversation/project-analysis.js +204 -0
- package/src/global/conversation/project-profile.js +178 -0
- package/src/global/conversation/project-router.js +149 -0
- package/src/global/conversation/project-strategy-store.js +64 -0
- package/src/global/conversation/project-strategy.js +514 -0
- package/src/global/conversation/sanitized-snapshot.js +169 -0
- package/src/global/conversation/secret-scanner.js +71 -0
- package/src/global/conversation/service.js +1063 -0
- package/src/global/conversation/session-store.js +75 -0
- package/src/global/conversation/transcript-store.js +79 -0
- package/src/global/conversation/ui.js +195 -0
- package/src/global/intelligence/capability-scoring.js +480 -0
- package/src/global/intelligence/execution-router.js +444 -0
- package/src/global/intelligence/kairo-telemetry-source.js +59 -0
- package/src/global/intelligence/kairobench-runner.js +85 -0
- package/src/global/intelligence/kairobench-source.js +34 -0
- package/src/global/intelligence/kairobench-tasks.js +47 -0
- package/src/global/intelligence/model-candidate-catalog.js +456 -0
- package/src/global/intelligence/model-capability-registry-sources.js +145 -0
- package/src/global/intelligence/model-capability-registry.js +125 -0
- package/src/global/intelligence/model-intelligence.js +1646 -0
- package/src/global/intelligence/official-benchmark-snapshots.js +162 -0
- package/src/global/intelligence/quick-ask.js +149 -0
- package/src/global/intelligence/role-profiles.js +251 -0
- package/src/global/intelligence/skill-catalog.js +67 -0
- package/src/global/intelligence/subscription-pressure-source.js +41 -0
- package/src/global/mcp/kairo-mcp.js +51 -18
- package/src/global/mcp/work-snapshot-rule.js +4 -2
- package/src/global/mcp/workspace-binding.js +88 -0
- package/src/global/mcp/workspace-mcp-entry.js +74 -0
- package/src/global/mcp-install.js +8 -1
- package/src/global/observability/artificial-analysis-models.js +118 -0
- package/src/global/observability/claude-models.js +31 -0
- package/src/global/observability/claude-usage.js +112 -0
- package/src/global/observability/codex-models.js +96 -0
- package/src/global/observability/codex-usage.js +160 -0
- package/src/global/observability/cursor-auth.js +88 -0
- package/src/global/observability/cursor-models.js +101 -0
- package/src/global/observability/huggingface-leaderboard.js +97 -0
- package/src/global/observability/opencode-models.js +101 -0
- package/src/global/observability/opencode-usage.js +162 -0
- package/src/global/paths.js +49 -2
- package/src/global/profile.js +23 -1
- package/src/global/runtime/execution-adapters/claude.js +63 -30
- package/src/global/runtime/execution-adapters/codex.js +9 -2
- package/src/global/runtime/execution-adapters/create-execution-adapter.js +6 -1
- package/src/global/runtime/execution-adapters/opencode.js +83 -18
- package/src/global/runtime/execution-worktree-manager.js +924 -0
- package/src/global/runtime/execution-worktree-orchestrator.js +194 -0
- package/src/global/runtime/execution-worktree-store.js +83 -0
- package/src/global/runtime/execution-worktree-types.js +45 -0
- package/src/global/runtime/run-events.js +38 -0
- package/src/global/runtime/run-manager.js +22 -6
- package/src/global/runtime/run-supervisor.js +41 -12
- package/src/global/runtime/usage-manager.js +96 -0
- package/src/global/runtime/usage-store.js +69 -0
- package/src/global/runtime/usage-types.js +62 -0
|
@@ -0,0 +1,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
|
+
}
|