@kal-elsam/kairo-runtime 0.15.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 (92) hide show
  1. package/CHANGELOG.md +76 -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 +106 -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 +12 -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/control-plane/attention.js +141 -0
  22. package/src/global/control-plane/build-report.js +146 -0
  23. package/src/global/control-plane/cli.js +36 -0
  24. package/src/global/control-plane/constants.js +38 -0
  25. package/src/global/control-plane/gentle-adapters.js +183 -0
  26. package/src/global/control-plane/provider.js +69 -0
  27. package/src/global/control-plane/review-status.js +115 -0
  28. package/src/global/control-plane/sdd-status.js +49 -0
  29. package/src/global/control-plane/team.js +63 -0
  30. package/src/global/conversation/bootstrap-analyzer-adapters.js +251 -0
  31. package/src/global/conversation/cli.js +53 -0
  32. package/src/global/conversation/codex-sandbox.js +230 -0
  33. package/src/global/conversation/cursor-sandbox.js +215 -0
  34. package/src/global/conversation/project-analysis.js +204 -0
  35. package/src/global/conversation/project-profile.js +178 -0
  36. package/src/global/conversation/project-router.js +149 -0
  37. package/src/global/conversation/project-strategy-store.js +64 -0
  38. package/src/global/conversation/project-strategy.js +514 -0
  39. package/src/global/conversation/sanitized-snapshot.js +169 -0
  40. package/src/global/conversation/secret-scanner.js +71 -0
  41. package/src/global/conversation/service.js +1063 -0
  42. package/src/global/conversation/session-store.js +75 -0
  43. package/src/global/conversation/transcript-store.js +79 -0
  44. package/src/global/conversation/ui.js +195 -0
  45. package/src/global/intelligence/capability-scoring.js +480 -0
  46. package/src/global/intelligence/execution-router.js +444 -0
  47. package/src/global/intelligence/kairo-telemetry-source.js +59 -0
  48. package/src/global/intelligence/kairobench-runner.js +85 -0
  49. package/src/global/intelligence/kairobench-source.js +34 -0
  50. package/src/global/intelligence/kairobench-tasks.js +47 -0
  51. package/src/global/intelligence/model-candidate-catalog.js +456 -0
  52. package/src/global/intelligence/model-capability-registry-sources.js +145 -0
  53. package/src/global/intelligence/model-capability-registry.js +125 -0
  54. package/src/global/intelligence/model-intelligence.js +1646 -0
  55. package/src/global/intelligence/official-benchmark-snapshots.js +162 -0
  56. package/src/global/intelligence/quick-ask.js +149 -0
  57. package/src/global/intelligence/role-profiles.js +251 -0
  58. package/src/global/intelligence/skill-catalog.js +67 -0
  59. package/src/global/intelligence/subscription-pressure-source.js +41 -0
  60. package/src/global/mcp/kairo-mcp.js +51 -18
  61. package/src/global/mcp/work-snapshot-rule.js +4 -2
  62. package/src/global/mcp/workspace-binding.js +88 -0
  63. package/src/global/mcp/workspace-mcp-entry.js +74 -0
  64. package/src/global/mcp-install.js +8 -1
  65. package/src/global/observability/artificial-analysis-models.js +118 -0
  66. package/src/global/observability/claude-models.js +31 -0
  67. package/src/global/observability/claude-usage.js +112 -0
  68. package/src/global/observability/codex-models.js +96 -0
  69. package/src/global/observability/codex-usage.js +160 -0
  70. package/src/global/observability/cursor-auth.js +88 -0
  71. package/src/global/observability/cursor-models.js +101 -0
  72. package/src/global/observability/gentle-probe.js +30 -2
  73. package/src/global/observability/huggingface-leaderboard.js +97 -0
  74. package/src/global/observability/index.js +2 -1
  75. package/src/global/observability/opencode-models.js +101 -0
  76. package/src/global/observability/opencode-usage.js +162 -0
  77. package/src/global/paths.js +49 -2
  78. package/src/global/profile.js +23 -1
  79. package/src/global/runtime/execution-adapters/claude.js +63 -30
  80. package/src/global/runtime/execution-adapters/codex.js +9 -2
  81. package/src/global/runtime/execution-adapters/create-execution-adapter.js +6 -1
  82. package/src/global/runtime/execution-adapters/opencode.js +83 -18
  83. package/src/global/runtime/execution-worktree-manager.js +924 -0
  84. package/src/global/runtime/execution-worktree-orchestrator.js +194 -0
  85. package/src/global/runtime/execution-worktree-store.js +83 -0
  86. package/src/global/runtime/execution-worktree-types.js +45 -0
  87. package/src/global/runtime/run-events.js +38 -0
  88. package/src/global/runtime/run-manager.js +22 -6
  89. package/src/global/runtime/run-supervisor.js +41 -12
  90. package/src/global/runtime/usage-manager.js +96 -0
  91. package/src/global/runtime/usage-store.js +69 -0
  92. package/src/global/runtime/usage-types.js +62 -0
@@ -0,0 +1,683 @@
1
+ import { Box, SelectList, Text, Input, Key, matchesKey, fuzzyFilter } from "@earendil-works/pi-tui";
2
+ import { editorTheme, theme } from "./theme.js";
3
+ import { CARD_TONE, cardInnerWidth, renderPanel } from "./card.js";
4
+
5
+ // /project's interactive overlay — the real preflight -> select analyst ->
6
+ // confirm -> analyze -> result -> approve loop, reusing the exact same
7
+ // service functions the plain-text /project subcommands already use
8
+ // (preflightProject, runBootstrapAnalysis, approveProjectStrategy,
9
+ // refreshProjectStrategy, getProjectTeamEditCatalog, setProjectTeamAssignment).
10
+ // No new persistence, no new ProjectStrategy schema — this is a different
11
+ // way to drive the same real state machine, never a parallel one. The
12
+ // plain-text subcommands (/project status|analyze|analyst|approve|refresh)
13
+ // keep working unchanged; this overlay is what bare `/project` (no
14
+ // subcommand) now opens.
15
+
16
+ export const PROJECT_OVERLAY_STATE = {
17
+ LOADING_PREFLIGHT: "loading-preflight",
18
+ NO_ANALYST: "no-analyst",
19
+ SELECT_ANALYST: "select-analyst",
20
+ CONFIRM_ANALYST: "confirm-analyst",
21
+ ANALYZING: "analyzing",
22
+ RESULT: "result",
23
+ EDIT_LOADING: "edit-loading",
24
+ EDIT_MODEL_SEARCH: "edit-model-search",
25
+ EDIT_CONFIRM: "edit-confirm",
26
+ EDIT_SAVING: "edit-saving",
27
+ APPROVING: "approving",
28
+ REFRESHING: "refreshing",
29
+ ACTIVE: "active",
30
+ STALE: "stale",
31
+ ERROR: "error"
32
+ };
33
+
34
+ const S = PROJECT_OVERLAY_STATE;
35
+
36
+ function teamLines(label, team, aiTeamLabel, tone = "bold") {
37
+ const lines = [tone === "bold" ? theme.bold(label) : theme.fg("muted", label)];
38
+ if (!team?.length) {
39
+ lines.push(theme.fg("muted", " (no active roles)"));
40
+ return lines;
41
+ }
42
+ for (const entry of team) {
43
+ const modelText = entry.model ? aiTeamLabel(entry.model) : theme.fg("warning", "no eligible option");
44
+ lines.push(theme.fg("muted", ` ${entry.role.padEnd(10)} ${modelText}`));
45
+ }
46
+ return lines;
47
+ }
48
+
49
+ /**
50
+ * A pi-tui Component implementing /project's interactive overlay. State is
51
+ * driven entirely by the real, already-existing service calls — this
52
+ * component only sequences them and renders their real results; it never
53
+ * invents team data, approval state, or staleness on its own.
54
+ */
55
+ export class ProjectOverlay {
56
+ /**
57
+ * @param {object} deps
58
+ * @param {object} deps.service - conversation service (preflightProject, runBootstrapAnalysis, approveProjectStrategy, refreshProjectStrategy, getProjectTeamEditCatalog, setProjectTeamAssignment)
59
+ * @param {import("./view.js").CockpitView} deps.view
60
+ * @param {string} deps.cwd
61
+ * @param {() => void} deps.onClose - called when the overlay should close and focus should return to the editor
62
+ * @param {() => void} [deps.requestRender]
63
+ * @param {(text: string) => void} [deps.onNarrate] - mirrors real milestones
64
+ * (analysis started, result ready, approved, refreshed, errors) into the
65
+ * main conversation transcript, the same way the plain-text /project
66
+ * subcommands already narrate their own steps there — the interactive
67
+ * overlay is the rich detail view, but the chat history should still
68
+ * show that an analysis happened and what it decided, matching how a
69
+ * real CLI (Claude, Codex) narrates its own process. Never called for
70
+ * pure navigation (opening a picker, moving a selection) — only for a
71
+ * real state change the overlay's own service calls produced.
72
+ */
73
+ constructor({ service, view, cwd, onClose, requestRender = () => {}, onNarrate = () => {} }) {
74
+ this.service = service;
75
+ this.view = view;
76
+ this.cwd = cwd;
77
+ this.onClose = onClose;
78
+ this.requestRender = requestRender;
79
+ this.onNarrate = onNarrate;
80
+
81
+ this.state = S.LOADING_PREFLIGHT;
82
+ // The real Box render() builds each frame, kept here (not local to
83
+ // render()) so handleMouse can forward a real click into it — pi-tui
84
+ // dispatches mouse events against whatever the component's own last
85
+ // render() actually laid out, never a freshly-built one.
86
+ this.box = null;
87
+ this.lastWidth = 76;
88
+ this.preflight = null;
89
+ this.selectedAnalyst = null;
90
+ this.selectList = null;
91
+ this.suggestedStrategy = null;
92
+ this.activeStrategy = null;
93
+ this.errorMessage = null;
94
+
95
+ // projectTeam editing (section 4).
96
+ this.resultSelectList = null;
97
+ this.editingRole = null;
98
+ this.editCatalog = null;
99
+ this.editModels = [];
100
+ this.editQuery = "";
101
+ this.editInput = null;
102
+ this.editSelectList = null;
103
+ this.pendingEditCandidate = null;
104
+
105
+ const existing = view.snapshot?.projectStrategy ?? null;
106
+ if (existing?.status === "active") {
107
+ this.state = S.ACTIVE;
108
+ this.activeStrategy = existing;
109
+ } else if (existing?.status === "stale") {
110
+ this.state = S.STALE;
111
+ this.activeStrategy = existing;
112
+ } else if (existing?.status === "suggested") {
113
+ this.state = S.RESULT;
114
+ this.suggestedStrategy = existing;
115
+ this.buildResultRoleList();
116
+ } else {
117
+ // LOCAL_PREFLIGHT: real, read-only evidence — no provider call, no
118
+ // quota consumed, nothing persisted yet.
119
+ void this.loadPreflight();
120
+ }
121
+ }
122
+
123
+ invalidate() {
124
+ this.selectList?.invalidate();
125
+ this.resultSelectList?.invalidate();
126
+ this.editSelectList?.invalidate();
127
+ this.editInput?.invalidate();
128
+ this.box?.invalidate();
129
+ }
130
+
131
+ /**
132
+ * Forwards a real mouse event into the real Box render() built — the
133
+ * missing half of the real contract: SelectList/Box already implement
134
+ * handleMouse (a click can select an analyst or a PROJECT TEAM role
135
+ * row), but nothing here ever called it, so those real clicks never
136
+ * reached the list at all. Coordinates are translated from this real
137
+ * bordered panel's own frame (render()'s renderPanel border + padding)
138
+ * back into the Box's own render(cardInnerWidth(width)) coordinate
139
+ * space — the exact inverse of how render() below lays the frame out.
140
+ * @param {import("@earendil-works/pi-tui").TuiMouseEvent} event
141
+ */
142
+ handleMouse(event) {
143
+ if (!this.box) return undefined;
144
+ const innerWidth = cardInnerWidth(this.lastWidth);
145
+ const x = event.x - 2; // real frame's own left border + one padding column (see card.js's cardLine)
146
+ const y = event.y - 1; // real frame's own top border row (see card.js's cardTop)
147
+ if (x < 0 || y < 0 || x >= innerWidth) return undefined;
148
+ return this.box.handleMouse({ ...event, x, y, width: innerWidth });
149
+ }
150
+
151
+ async loadPreflight() {
152
+ this.view.beginAction("Reading project evidence locally");
153
+ try {
154
+ this.preflight = await this.service.preflightProject({ cwd: this.cwd });
155
+ const models = this.preflight.analystCatalog?.models ?? [];
156
+ if (!models.length) {
157
+ this.state = S.NO_ANALYST;
158
+ } else {
159
+ this.buildSelectList();
160
+ this.state = S.SELECT_ANALYST;
161
+ }
162
+ } catch (error) {
163
+ this.errorMessage = error.message ?? String(error);
164
+ this.state = S.ERROR;
165
+ }
166
+ this.view.endAction();
167
+ this.requestRender();
168
+ }
169
+
170
+ /** A short, honest label for a catalog entry's own real recommendationTags/evidenceStatus — never a fabricated "Quality"/"Efficient" claim for a model that doesn't actually carry that tag. */
171
+ static tagLabel(model) {
172
+ if (model.evidenceStatus === "unscored") return "Unscored";
173
+ const tags = [];
174
+ if (model.recommendationTags.includes("quality")) tags.push("Quality fit");
175
+ if (model.recommendationTags.includes("efficient")) tags.push("Efficient fit");
176
+ return tags.join(" · ");
177
+ }
178
+
179
+ /**
180
+ * Builds the real, full analyst catalog picker — every real ask-
181
+ * supported model (scored AND unscored), the real recommended one
182
+ * listed first and pre-selected (index 0), matching every OTHER real
183
+ * candidate's own real tag/evidence state honestly instead of
184
+ * collapsing the catalog back down to just two picks.
185
+ */
186
+ buildSelectList() {
187
+ const catalog = this.preflight.analystCatalog;
188
+ const models = catalog.models ?? [];
189
+ const recommendedKey = catalog.recommendedModel?.candidateKey ?? null;
190
+ const ordered = recommendedKey
191
+ ? [...models.filter((m) => m.candidateKey === recommendedKey), ...models.filter((m) => m.candidateKey !== recommendedKey)]
192
+ : models;
193
+ // Model-first rows: "<display name> <provider> <real tag>" —
194
+ // never provider-first, matching the plan's own mockup. The primary
195
+ // column (model + provider) is explicitly the real, bright `text`
196
+ // color — never left to the terminal's own default or muted, so it
197
+ // stays legible regardless of terminal theme; muted stays reserved
198
+ // for the real secondary tag in `description`.
199
+ const items = ordered.map((model) => ({
200
+ value: model.candidateKey,
201
+ label: theme.fg("text", `${model.displayName} ${model.adapterId}`),
202
+ description: ProjectOverlay.tagLabel(model)
203
+ }));
204
+ this.selectList = new SelectList(items, 8, editorTheme.selectList);
205
+ this.selectList.onSelect = (item) => {
206
+ const picked = models.find((model) => model.candidateKey === item.value);
207
+ if (!picked) return;
208
+ const selectionSource = picked.candidateKey === recommendedKey ? "recommended" : "manual";
209
+ const recommendationTags = picked.recommendationTags ?? [];
210
+ this.selectedAnalyst = {
211
+ // A clean modelRef shape — no UI-only fields leak into what
212
+ // eventually gets persisted verbatim as ProjectStrategy's own
213
+ // bootstrapAnalyst (see buildProjectStrategy). available/
214
+ // evidenceStatus below are UI-only, read by this overlay's own
215
+ // CONFIRM_ANALYST render, never sent to the service or persisted.
216
+ model: { adapterId: picked.adapterId, modelId: picked.modelId, displayName: picked.displayName },
217
+ selectionSource,
218
+ recommendationTags,
219
+ // choice stays ONLY for the legacy plain-text subcommand's own
220
+ // persisted field — never fabricated for a real manual/unscored
221
+ // pick that fits neither bucket.
222
+ choice: recommendationTags.includes("quality") ? "quality" : recommendationTags.includes("efficient") ? "efficient" : null,
223
+ available: picked.available,
224
+ evidenceStatus: picked.evidenceStatus
225
+ };
226
+ this.state = S.CONFIRM_ANALYST;
227
+ this.requestRender();
228
+ };
229
+ this.selectList.onCancel = () => this.close();
230
+ }
231
+
232
+ async confirmAnalyst() {
233
+ this.state = S.ANALYZING;
234
+ this.view.beginAction(`${this.view.aiTeamLabelWithProvider(this.selectedAnalyst.model)} is investigating this project`);
235
+ this.requestRender();
236
+ this.onNarrate(`${this.view.aiTeamLabelWithProvider(this.selectedAnalyst.model)} is investigating this project (read-only)…`);
237
+ try {
238
+ const result = await this.service.runBootstrapAnalysis({
239
+ cwd: this.cwd, profile: this.preflight.profile, candidates: this.preflight.candidates, analyst: this.selectedAnalyst
240
+ });
241
+ this.suggestedStrategy = result;
242
+ this.buildResultRoleList();
243
+ this.state = S.RESULT;
244
+ const roleCount = result.activeRoles?.length ?? 0;
245
+ this.onNarrate(`Suggested project team ready (${roleCount} real role${roleCount === 1 ? "" : "s"}). Open /project to review, edit, or approve it.`);
246
+ } catch (error) {
247
+ this.errorMessage = error.message ?? String(error);
248
+ this.state = S.ERROR;
249
+ this.onNarrate(`Project analysis failed: ${this.errorMessage}`);
250
+ }
251
+ this.view.endAction();
252
+ this.requestRender();
253
+ }
254
+
255
+ /**
256
+ * The RESULT screen's own real, interactive PROJECT TEAM list — one row
257
+ * per real role in strategy.projectTeam, showing its real current
258
+ * model and whether it's a real override. Enter on a row opens that
259
+ * role's real edit picker (see openRolePicker); Esc closes the overlay
260
+ * without approving (unchanged). Approval is a separate, explicit key
261
+ * ("a") — Enter here edits, it never silently approves.
262
+ */
263
+ buildResultRoleList() {
264
+ const team = this.suggestedStrategy?.projectTeam ?? [];
265
+ const items = team.map((entry) => {
266
+ const modelText = entry.model ? this.view.aiTeamLabel(entry.model) : "no eligible option";
267
+ const overrideNote = entry.assignmentSource === "override" ? theme.fg("accent", " (override)") : "";
268
+ // Role + model are the real primary information here — explicit
269
+ // `text` color, never left to default/muted.
270
+ // WHY this model was picked — the real, human-readable evidence
271
+ // buildProjectStrategy already carries (entry.reason, sourced from
272
+ // efficientTeam's own describeEfficiencyDecision), never a
273
+ // fabricated justification. An override has no ranking reason of
274
+ // its own (see applyProjectTeamOverride) — honestly say so instead
275
+ // of silently reusing the old recommendation's reason for a
276
+ // different model.
277
+ const description = entry.assignmentSource === "override"
278
+ ? "Manual override — not the automatic ranking's own pick."
279
+ : (entry.reason ?? "");
280
+ return { value: entry.role, label: theme.fg("text", `${entry.role.padEnd(10)} ${modelText}`) + overrideNote, description };
281
+ });
282
+ this.resultSelectList = new SelectList(items, 6, editorTheme.selectList);
283
+ this.resultSelectList.onSelect = (item) => void this.openRolePicker(item.value);
284
+ this.resultSelectList.onCancel = () => this.close();
285
+ }
286
+
287
+ /**
288
+ * Opens the real edit picker for one projectTeam role — a real, read-
289
+ * only catalog fetch (getProjectTeamEditCatalog), no quota, no write.
290
+ */
291
+ async openRolePicker(role) {
292
+ this.editingRole = role;
293
+ this.editCatalog = null;
294
+ this.state = S.EDIT_LOADING;
295
+ this.view.beginAction(`Reading the real current catalog for ${role}`);
296
+ this.requestRender();
297
+ try {
298
+ this.editCatalog = await this.service.getProjectTeamEditCatalog({ cwd: this.cwd, role });
299
+ this.buildEditPicker();
300
+ this.state = S.EDIT_MODEL_SEARCH;
301
+ } catch (error) {
302
+ this.errorMessage = error.message ?? String(error);
303
+ this.state = S.ERROR;
304
+ }
305
+ this.view.endAction();
306
+ this.requestRender();
307
+ }
308
+
309
+ /**
310
+ * Orders the real edit catalog per the plan's own contract: 1) the
311
+ * role's real current operational model, 2) its real original
312
+ * recommendation (when different from the current), 3) the rest of the
313
+ * real scored candidates, 4) the real unscored ones — never re-ranking
314
+ * anything, just ordering what the catalog already returned.
315
+ */
316
+ orderedEditModels() {
317
+ const entry = this.suggestedStrategy?.projectTeam?.find((e) => e.role === this.editingRole);
318
+ const currentKey = entry?.model?.candidateKey ?? null;
319
+ const recommendedKey = entry?.recommendedAssignment?.model?.candidateKey ?? currentKey;
320
+ const models = this.editCatalog?.models ?? [];
321
+ const byKey = new Map(models.map((m) => [m.candidateKey, m]));
322
+ const seen = new Set();
323
+ const ordered = [];
324
+ const pushIfPresent = (key) => {
325
+ if (key && byKey.has(key) && !seen.has(key)) {
326
+ ordered.push(byKey.get(key));
327
+ seen.add(key);
328
+ }
329
+ };
330
+ pushIfPresent(currentKey);
331
+ pushIfPresent(recommendedKey);
332
+ for (const model of models) {
333
+ if (seen.has(model.candidateKey) || model.evidenceStatus === "unscored") continue;
334
+ ordered.push(model);
335
+ seen.add(model.candidateKey);
336
+ }
337
+ for (const model of models) {
338
+ if (seen.has(model.candidateKey)) continue;
339
+ ordered.push(model);
340
+ seen.add(model.candidateKey);
341
+ }
342
+ return ordered;
343
+ }
344
+
345
+ editItemTag(model, currentKey, recommendedKey) {
346
+ const tags = [];
347
+ if (model.candidateKey === currentKey) tags.push("current");
348
+ if (model.candidateKey === recommendedKey) tags.push("recommended");
349
+ if (model.evidenceStatus === "unscored") tags.push("unscored");
350
+ if (model.accessMode === "manual") tags.push("manual");
351
+ return tags.join(" · ");
352
+ }
353
+
354
+ buildEditPicker() {
355
+ this.editQuery = "";
356
+ this.editInput = new Input({ placeholder: "type to search…" });
357
+ this.editModels = this.orderedEditModels();
358
+ this.rebuildEditSelectList();
359
+ }
360
+
361
+ /** Real fuzzy filtering (pi-tui's own fuzzyFilter) against each real candidate's own display name + provider — never SelectList's built-in setFilter, which only prefix-matches a plain `value` (here, candidateKey), not what a human actually types. */
362
+ rebuildEditSelectList() {
363
+ const entry = this.suggestedStrategy?.projectTeam?.find((e) => e.role === this.editingRole);
364
+ const currentKey = entry?.model?.candidateKey ?? null;
365
+ const recommendedKey = entry?.recommendedAssignment?.model?.candidateKey ?? currentKey;
366
+ const filtered = this.editQuery
367
+ ? fuzzyFilter(this.editModels, this.editQuery, (model) => `${model.displayName} ${model.adapterId}`)
368
+ : this.editModels;
369
+ const items = filtered.map((model) => ({
370
+ value: model.candidateKey,
371
+ label: theme.fg("text", `${model.displayName} ${model.adapterId}`),
372
+ description: this.editItemTag(model, currentKey, recommendedKey)
373
+ }));
374
+ this.editSelectList = new SelectList(items, 8, editorTheme.selectList);
375
+ }
376
+
377
+ beginConfirmEdit(candidateKey) {
378
+ const candidate = this.editCatalog?.models?.find((m) => m.candidateKey === candidateKey);
379
+ if (!candidate) return;
380
+ this.pendingEditCandidate = candidate;
381
+ this.state = S.EDIT_CONFIRM;
382
+ this.requestRender();
383
+ }
384
+
385
+ async commitEdit() {
386
+ this.state = S.EDIT_SAVING;
387
+ this.view.beginAction("Saving the real assignment");
388
+ this.requestRender();
389
+ try {
390
+ this.suggestedStrategy = await this.service.setProjectTeamAssignment({
391
+ cwd: this.cwd, role: this.editingRole, candidateKey: this.pendingEditCandidate.candidateKey
392
+ });
393
+ this.buildResultRoleList();
394
+ this.state = S.RESULT;
395
+ } catch (error) {
396
+ this.errorMessage = error.message ?? String(error);
397
+ this.state = S.ERROR;
398
+ }
399
+ this.view.endAction();
400
+ this.requestRender();
401
+ }
402
+
403
+ async approve() {
404
+ this.state = S.APPROVING;
405
+ this.view.beginAction("Activating the project team");
406
+ this.requestRender();
407
+ try {
408
+ this.activeStrategy = await this.service.approveProjectStrategy({ cwd: this.cwd });
409
+ this.state = S.ACTIVE;
410
+ this.onNarrate("Project team is now ACTIVE.");
411
+ } catch (error) {
412
+ this.errorMessage = error.message ?? String(error);
413
+ this.state = S.ERROR;
414
+ this.onNarrate(`Approval failed: ${this.errorMessage}`);
415
+ }
416
+ this.view.endAction();
417
+ this.requestRender();
418
+ }
419
+
420
+ async refresh() {
421
+ this.state = S.REFRESHING;
422
+ this.view.beginAction("Re-checking the real project evidence");
423
+ this.requestRender();
424
+ try {
425
+ const result = await this.service.refreshProjectStrategy({ cwd: this.cwd });
426
+ this.activeStrategy = result;
427
+ this.state = result?.status === "stale" ? S.STALE : S.ACTIVE;
428
+ this.onNarrate(result ? `Project strategy is now ${result.status.toUpperCase()}.` : "Nothing to refresh yet.");
429
+ } catch (error) {
430
+ this.errorMessage = error.message ?? String(error);
431
+ this.state = S.ERROR;
432
+ this.onNarrate(`Refresh failed: ${this.errorMessage}`);
433
+ }
434
+ this.view.endAction();
435
+ this.requestRender();
436
+ }
437
+
438
+ close() {
439
+ this.onClose?.();
440
+ }
441
+
442
+ handleInput(data) {
443
+ if (this.state === S.SELECT_ANALYST && this.selectList) {
444
+ this.selectList.handleInput(data);
445
+ this.requestRender();
446
+ return;
447
+ }
448
+
449
+ if (this.state === S.RESULT && this.resultSelectList) {
450
+ // "a" approves and activates — a distinct key from Enter, which
451
+ // edits the highlighted role instead. Enter must never silently
452
+ // approve just because a role row happens to be focused.
453
+ if (data === "a" || data === "A") return void this.approve();
454
+ this.resultSelectList.handleInput(data);
455
+ this.requestRender();
456
+ return;
457
+ }
458
+
459
+ if (this.state === S.EDIT_MODEL_SEARCH) {
460
+ if (matchesKey(data, Key.escape) || matchesKey(data, Key.esc)) {
461
+ this.state = S.RESULT;
462
+ this.requestRender();
463
+ return;
464
+ }
465
+ if (matchesKey(data, Key.enter) || matchesKey(data, Key.return)) {
466
+ const item = this.editSelectList?.getSelectedItem?.();
467
+ if (item) this.beginConfirmEdit(item.value);
468
+ return;
469
+ }
470
+ if (matchesKey(data, Key.up) || matchesKey(data, Key.down)) {
471
+ this.editSelectList?.handleInput(data);
472
+ this.requestRender();
473
+ return;
474
+ }
475
+ this.editInput.handleInput(data);
476
+ this.editQuery = this.editInput.getValue();
477
+ this.rebuildEditSelectList();
478
+ this.requestRender();
479
+ return;
480
+ }
481
+
482
+ if (this.state === S.EDIT_CONFIRM) {
483
+ if (matchesKey(data, Key.escape) || matchesKey(data, Key.esc)) {
484
+ this.state = S.EDIT_MODEL_SEARCH;
485
+ this.requestRender();
486
+ return;
487
+ }
488
+ if (matchesKey(data, Key.enter) || matchesKey(data, Key.return)) return void this.commitEdit();
489
+ return;
490
+ }
491
+
492
+ if (matchesKey(data, Key.escape) || matchesKey(data, Key.esc)) {
493
+ if (this.state === S.CONFIRM_ANALYST) {
494
+ this.state = S.SELECT_ANALYST;
495
+ this.requestRender();
496
+ return;
497
+ }
498
+ this.close();
499
+ return;
500
+ }
501
+ if (matchesKey(data, Key.enter) || matchesKey(data, Key.return)) {
502
+ if (this.state === S.CONFIRM_ANALYST) return void this.confirmAnalyst();
503
+ if (this.state === S.STALE) return void this.refresh();
504
+ if (this.state === S.NO_ANALYST || this.state === S.ERROR || this.state === S.ACTIVE) {
505
+ this.close();
506
+ }
507
+ }
508
+ }
509
+
510
+ /** Real border tone per state — never a fixed color, so ERROR/WARNING states read as visually distinct as their own content already claims to be. */
511
+ panelTone() {
512
+ if (this.state === S.ERROR) return CARD_TONE.ERROR;
513
+ if (this.state === S.NO_ANALYST || this.state === S.STALE) return CARD_TONE.WARNING;
514
+ if (this.state === S.RESULT || this.state === S.ACTIVE) return CARD_TONE.SUCCESS;
515
+ return CARD_TONE.INFO;
516
+ }
517
+
518
+ render(width) {
519
+ this.lastWidth = width;
520
+ // No background applied to the whole box — only the SelectList's own
521
+ // active row gets a highlight (editorTheme.selectList's own
522
+ // selectedPrefix/selectedText), so the overlay reads as a real modal
523
+ // over the dashboard, not a solid color block. The real bordered
524
+ // frame below (renderPanel) is what actually keeps this from getting
525
+ // visually lost against the dashboard behind it — a background alone
526
+ // isn't a modal boundary a human eye reliably notices.
527
+ const box = new Box(2, 1);
528
+ this.box = box;
529
+ const aiTeamLabel = (model) => this.view.aiTeamLabel(model);
530
+ const push = (text) => box.addChild(new Text(text));
531
+ // The real, ticking spinner+elapsed-time line every other in-flight
532
+ // action in the cockpit already uses (view.beginAction/tickSpinner/
533
+ // actionStatusLine — app.js's own fast timer keeps it live) — never a
534
+ // static "…" string that just sits there unchanged. Falls back to the
535
+ // static text only if no real action happens to be running yet (the
536
+ // render right before beginAction's own first call).
537
+ const spinnerLine = (fallback) => theme.fg("muted", this.view.actionStatusLine() ?? fallback);
538
+
539
+ switch (this.state) {
540
+ case S.LOADING_PREFLIGHT:
541
+ push(theme.bold("Analyze Project"));
542
+ push(spinnerLine("Reading project evidence locally — no provider call, no quota consumed…"));
543
+ break;
544
+ case S.NO_ANALYST:
545
+ push(theme.bold("Select Project Analyst"));
546
+ push(theme.fg("warning", "No real Project Analyst candidate is available right now (ASK only supports Codex/Claude today)."));
547
+ push(theme.fg("muted", "Esc / Enter to close."));
548
+ break;
549
+ case S.SELECT_ANALYST:
550
+ push(theme.bold("Select Project Analyst"));
551
+ // The Project Analyst investigates and reports on this project's
552
+ // real architecture and risks — it never joins the team it
553
+ // recommends (see BOOTSTRAP_ANALYST_PROFILE), so it's shown
554
+ // distinctly from Architect and every other role, and only ever
555
+ // as one of the two real models Kairo can actually invoke,
556
+ // isolated, read-only (Codex/Claude today).
557
+ push(theme.fg("muted", "Architecture & systems analysis — read-only, no quota consumed until you confirm."));
558
+ box.addChild(this.selectList);
559
+ push(theme.fg("muted", "Enter Select · Esc Cancel"));
560
+ break;
561
+ case S.CONFIRM_ANALYST: {
562
+ const { model, selectionSource, available, evidenceStatus } = this.selectedAnalyst;
563
+ push(theme.bold("Confirm Project Analyst"));
564
+ const sourceNote = selectionSource === "manual" ? theme.fg("muted", " (manual selection)") : "";
565
+ push(` ${this.view.aiTeamLabelWithProvider(model)}${sourceNote}${available === false ? theme.fg("warning", " (not available)") : ""}`);
566
+ if (evidenceStatus === "unscored") {
567
+ push(theme.fg("warning", "This model has no real benchmark evidence — Kairo isn't recommending it, you're choosing it manually."));
568
+ }
569
+ push(theme.fg("warning", "This will run a real, read-only investigation against your project and consume real quota from this provider."));
570
+ push(theme.fg("muted", "Enter confirm and run · Esc back"));
571
+ break;
572
+ }
573
+ case S.ANALYZING:
574
+ push(theme.bold("ANALYZING"));
575
+ push(spinnerLine(`${this.view.aiTeamLabelWithProvider(this.selectedAnalyst.model)} is investigating this project (read-only)…`));
576
+ break;
577
+ case S.RESULT: {
578
+ const strategy = this.suggestedStrategy;
579
+ push(theme.bold("Suggested Project Team"));
580
+ const choiceNote = strategy.bootstrapAnalystChoice ?? (strategy.bootstrapAnalystSelectionSource === "manual" ? "manual pick" : "recommended");
581
+ push(theme.fg("muted", `Project Analyst: ${choiceNote} — ${this.view.aiTeamLabelWithProvider(strategy.bootstrapAnalyst)}`));
582
+ push(theme.bold("PROJECT TEAM"));
583
+ box.addChild(this.resultSelectList);
584
+ // Quality/Efficient stay real, comparative REFERENCE — muted, and
585
+ // rendered strictly below the real operational PROJECT TEAM list
586
+ // above, never replacing it visually.
587
+ for (const line of teamLines("Quality (reference)", strategy.qualityTeam, aiTeamLabel, "muted")) push(line);
588
+ for (const line of teamLines("Efficient (reference)", strategy.efficientTeam, aiTeamLabel, "muted")) push(line);
589
+ push(theme.fg("muted", "Enter edit role · a approve & activate · Esc close without approving"));
590
+ break;
591
+ }
592
+ case S.EDIT_LOADING:
593
+ push(theme.bold(`Edit ${this.editingRole}`));
594
+ push(spinnerLine("Reading the real current catalog for this role — no quota consumed…"));
595
+ break;
596
+ case S.EDIT_MODEL_SEARCH:
597
+ push(theme.bold(`Edit ${this.editingRole}`));
598
+ push(theme.fg("muted", "current · recommended · manual · unscored — real state, never fabricated."));
599
+ box.addChild(this.editInput);
600
+ box.addChild(this.editSelectList);
601
+ push(theme.fg("muted", "Enter select · Esc cancel"));
602
+ break;
603
+ case S.EDIT_CONFIRM: {
604
+ const entry = this.suggestedStrategy.projectTeam.find((e) => e.role === this.editingRole);
605
+ const candidate = this.pendingEditCandidate;
606
+ const oldLabel = entry?.model ? this.view.aiTeamLabelWithProvider(entry.model) : "(none)";
607
+ const newLabel = this.view.aiTeamLabelWithProvider({ adapterId: candidate.adapterId, modelId: candidate.modelId, displayName: candidate.displayName });
608
+ const recommendedKey = entry?.recommendedAssignment?.model?.candidateKey ?? entry?.model?.candidateKey ?? null;
609
+ const isRecommended = candidate.candidateKey === recommendedKey;
610
+ push(theme.bold(`Confirm ${this.editingRole}`));
611
+ push(` ${oldLabel} → ${newLabel}`);
612
+ push(theme.fg("muted", isRecommended ? "restores the real recommendation" : "manual override"));
613
+ if (candidate.evidenceStatus === "unscored") {
614
+ push(theme.fg("warning", "This model has no real benchmark evidence for this role."));
615
+ }
616
+ if (candidate.accessMode === "manual") {
617
+ push(theme.fg("warning", `${candidate.adapterId} isn't executable by Kairo automatically — this role will need a manual handoff.`));
618
+ }
619
+ push(theme.fg("muted", "Enter confirm and save (still suggested, not yet approved) · Esc back"));
620
+ break;
621
+ }
622
+ case S.EDIT_SAVING:
623
+ push(theme.bold(`Edit ${this.editingRole}`));
624
+ push(spinnerLine("Saving the real assignment…"));
625
+ break;
626
+ case S.APPROVING:
627
+ push(theme.bold("Approving"));
628
+ push(spinnerLine("Activating the project team…"));
629
+ break;
630
+ case S.REFRESHING:
631
+ push(theme.bold("Refreshing"));
632
+ push(spinnerLine("Re-checking whether the active project team still matches the real evidence…"));
633
+ break;
634
+ case S.ACTIVE: {
635
+ const strategy = this.activeStrategy;
636
+ push(theme.fg("success", "ACTIVE"));
637
+ push(theme.fg("muted", `Approved ${strategy.approvedAt ?? "?"}`));
638
+ for (const line of teamLines("PROJECT TEAM", strategy.projectTeam ?? strategy.qualityTeam, aiTeamLabel)) push(line);
639
+ push(theme.fg("muted", "Esc close"));
640
+ break;
641
+ }
642
+ case S.STALE: {
643
+ const strategy = this.activeStrategy;
644
+ push(theme.fg("warning", "STALE"));
645
+ push(theme.fg("muted", "The real project evidence has changed since this team was approved — previous assignments are kept until refreshed."));
646
+ for (const line of teamLines("PROJECT TEAM (previous)", strategy.projectTeam ?? strategy.qualityTeam, aiTeamLabel)) push(line);
647
+ push(theme.fg("muted", "Enter refresh · Esc close"));
648
+ break;
649
+ }
650
+ case S.ERROR:
651
+ default:
652
+ push(theme.fg("error", "Error"));
653
+ push(theme.fg("muted", this.errorMessage ?? "Unknown error."));
654
+ push(theme.fg("muted", "Esc / Enter to close."));
655
+ break;
656
+ }
657
+ const innerWidth = cardInnerWidth(width);
658
+ return renderPanel("Project", this.panelTone(), theme, width, box.render(innerWidth));
659
+ }
660
+ }
661
+
662
+ /**
663
+ * Opens the real /project overlay on top of `tui`, focus-managed by pi-tui
664
+ * itself (showOverlay records the currently-focused component — the
665
+ * editor — and restores it automatically when the overlay is hidden, per
666
+ * pi-tui's own OverlayHandle contract).
667
+ * @param {object} args
668
+ * @param {object} args.tui
669
+ * @param {object} args.service
670
+ * @param {import("./view.js").CockpitView} args.view
671
+ * @param {string} args.cwd
672
+ * @param {(text: string) => void} [args.onNarrate] - see ProjectOverlay's own doc
673
+ */
674
+ export function openProjectOverlay({ tui, service, view, cwd, onNarrate }) {
675
+ let handle;
676
+ const overlay = new ProjectOverlay({
677
+ service, view, cwd, onNarrate,
678
+ onClose: () => handle?.hide(),
679
+ requestRender: () => tui.requestRender()
680
+ });
681
+ handle = tui.showOverlay(overlay, { width: 76, maxHeight: "70%", anchor: "center" });
682
+ return handle;
683
+ }