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