@kal-elsam/kairo-runtime 0.13.1 → 0.15.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 (53) hide show
  1. package/CHANGELOG.md +107 -0
  2. package/README.md +12 -10
  3. package/bin/kairo-runtime.js +0 -0
  4. package/bin/kairo.js +0 -0
  5. package/package.json +5 -1
  6. package/scripts/cockpit-smoke.mjs +14 -10
  7. package/src/cli.js +182 -11
  8. package/src/global/check-resolutions.js +31 -0
  9. package/src/global/cli-help.js +23 -4
  10. package/src/global/component-ecosystem-checks.js +2 -0
  11. package/src/global/component-integration-cli.js +29 -10
  12. package/src/global/components-resolve-cli.js +246 -0
  13. package/src/global/connection-actions.js +147 -0
  14. package/src/global/connections.js +269 -0
  15. package/src/global/fleet-configure-plan.js +123 -0
  16. package/src/global/fleet-configure.js +303 -0
  17. package/src/global/fleet-models.js +188 -0
  18. package/src/global/fleet-set.js +219 -0
  19. package/src/global/fleet-shared.js +38 -0
  20. package/src/global/ink/cockpit-controller.js +2 -4
  21. package/src/global/ink/cockpit-enter.js +3 -1
  22. package/src/global/ink/cockpit-focus.js +7 -1
  23. package/src/global/ink/cockpit-models.js +33 -21
  24. package/src/global/ink/cockpit-palette.js +8 -3
  25. package/src/global/ink/cockpit-views.js +9 -2
  26. package/src/global/ink/orchestrator-app.js +42 -4
  27. package/src/global/ink/ux/live-overview.js +53 -26
  28. package/src/global/ink/ux/overview-actions.js +80 -0
  29. package/src/global/ink/ux/overview-needs.js +1 -1
  30. package/src/global/integrations/engram-evidence.js +7 -2
  31. package/src/global/integrations/sdd-apply.js +17 -7
  32. package/src/global/integrations/sdd-evidence.js +22 -3
  33. package/src/global/integrations/sdd-plan.js +21 -3
  34. package/src/global/integrations/sdd-resolutions.js +73 -0
  35. package/src/global/integrations/sdd-state.js +69 -0
  36. package/src/global/integrations/sdd-verify.js +9 -4
  37. package/src/global/mcp/kairo-mcp.js +56 -5
  38. package/src/global/mcp/resolve-mcp-workspace.js +51 -0
  39. package/src/global/mcp/work-snapshot-rule.js +89 -0
  40. package/src/global/mcp/work-snapshot-tool.js +49 -0
  41. package/src/global/mcp-install.js +239 -0
  42. package/src/global/next/next-cli.js +35 -0
  43. package/src/global/next/next-report.js +145 -0
  44. package/src/global/next/project-key.js +36 -0
  45. package/src/global/next/publish-work-snapshot.js +116 -0
  46. package/src/global/next/work-enroll.js +91 -0
  47. package/src/global/next/work-snapshot.js +216 -0
  48. package/src/global/observability/fleet-activity.js +197 -0
  49. package/src/global/observability/fleet-models-catalog.js +137 -0
  50. package/src/global/observability/fleet-platforms.js +166 -0
  51. package/src/global/observability/fleet-probe.js +229 -0
  52. package/src/global/paths.js +2 -1
  53. package/src/global/self-update.js +216 -0
@@ -0,0 +1,269 @@
1
+ /**
2
+ * Connection chips for IDE panel / CLI — companion probes + agent MCP registration.
3
+ * Read-only; never mutates configs.
4
+ */
5
+ import { join } from "node:path";
6
+ import { readFile } from "node:fs/promises";
7
+ import { resolveHomeDir } from "./paths.js";
8
+ import { buildCompanionSnapshot } from "./observability/build-companion-snapshot.js";
9
+ import { runPassiveObservabilitySnapshot } from "./observability/passive-snapshot-flight.js";
10
+ import { inspectEngramIntegration } from "./integrations/engram-evidence.js";
11
+ import { resolveGitHeadSha } from "./observability/graphify-probe.js";
12
+ import { enrichConnection } from "./connection-actions.js";
13
+ import { buildFleetReport } from "./observability/fleet-probe.js";
14
+
15
+ export const CONNECTION_ACCESS = Object.freeze({
16
+ gentle: "Probe contract; export/import review bundles (import needs consent).",
17
+ hermes: "Read-only: sessions via loopback API. Never runs hermes doctor/status.",
18
+ engram: "Disk evidence + engram version; setup needs consent.",
19
+ graphify: "Read-only: query, path, explain on workspace graph.",
20
+ agent: "MCP tools for Cursor agents (status, runs, alerts, graph)."
21
+ });
22
+
23
+ export const MCP_CLIENTS = Object.freeze({
24
+ cursor: {
25
+ id: "cursor",
26
+ label: "Cursor",
27
+ configRelativePath: join(".cursor", "mcp.json")
28
+ }
29
+ });
30
+
31
+ function chip(id, label, state, access, detail) {
32
+ return enrichConnection({
33
+ id,
34
+ label,
35
+ state: typeof state === "string" && state ? state : "unknown",
36
+ access,
37
+ detail: typeof detail === "string" && detail ? detail : ""
38
+ });
39
+ }
40
+
41
+ function gentleDetail(state) {
42
+ switch (state) {
43
+ case "available":
44
+ return "Gentle AI is available for review-bundle export/import.";
45
+ case "missing":
46
+ return "Install gentle-ai separately, then Refresh.";
47
+ case "incompatible":
48
+ return "Gentle is present but the review contract is incompatible.";
49
+ case "error":
50
+ return "Gentle probe failed. Check PATH and try Doctor.";
51
+ default:
52
+ return `Gentle state: ${state}.`;
53
+ }
54
+ }
55
+
56
+ function hermesDetail(state) {
57
+ switch (state) {
58
+ case "available":
59
+ return "Hermes loopback API is reachable; sessions are read-only.";
60
+ case "missing":
61
+ return "Install Hermes Agent separately, then Refresh.";
62
+ case "auth_required":
63
+ return "Hermes API requires auth (KAIRO_HERMES_API_KEY).";
64
+ case "incompatible":
65
+ return "Hermes is present but the local API contract is incompatible.";
66
+ case "unavailable":
67
+ case "error":
68
+ return "Hermes binary found, but loopback API (http://127.0.0.1:8642) is down. Everyday chat is `hermes` (see https://hermes-ai.net/es/docs/quickstart/). For the Kairo chip: set API_SERVER_ENABLED=true in ~/.hermes/.env, run hermes gateway run, then Refresh. Optional.";
69
+ default:
70
+ return `Hermes state: ${state}.`;
71
+ }
72
+ }
73
+
74
+ function engramDetail(status) {
75
+ switch (status) {
76
+ case "configured":
77
+ return "Engram integration evidence looks configured on disk.";
78
+ case "available":
79
+ return "Engram binary found; some agents still need configure.";
80
+ case "unconfigured":
81
+ return "Engram binary found; configure via Settings or components configure.";
82
+ case "missing":
83
+ return "Engram not detected. Optional memory — governance still works.";
84
+ case "conflict":
85
+ return "Engram config conflict. Open Settings → Engram.";
86
+ case "restart_required":
87
+ return "Engram config written; restart the agent to activate MCP tools.";
88
+ case "unsupported":
89
+ return "Engram version unsupported by this Kairo contract.";
90
+ case "error":
91
+ return "Engram inspection failed.";
92
+ default:
93
+ return `Engram status: ${status}.`;
94
+ }
95
+ }
96
+
97
+ function graphifyDetail(state, graphStatus) {
98
+ if (graphStatus === "stale") {
99
+ return "Graph exists but may be stale vs git HEAD. Run graphify update .";
100
+ }
101
+ if (graphStatus === "missing") {
102
+ return "No graphify-out/graph.json. Run graphify update . in the workspace.";
103
+ }
104
+ if (state === "available" && (graphStatus === "ok" || graphStatus == null)) {
105
+ return "Graphify CLI available; graph ready for query/path/explain.";
106
+ }
107
+ if (state === "missing") {
108
+ return "Install graphify separately, then Refresh.";
109
+ }
110
+ if (state === "error" || graphStatus === "error" || graphStatus === "malformed") {
111
+ return "Graphify probe failed or graph is malformed.";
112
+ }
113
+ return `Graphify state: ${state}${graphStatus ? ` / ${graphStatus}` : ""}.`;
114
+ }
115
+
116
+ export function resolveMcpConfigPath(client = "cursor", { homeDir = resolveHomeDir() } = {}) {
117
+ const entry = MCP_CLIENTS[client] ?? MCP_CLIENTS.cursor;
118
+ return join(homeDir, entry.configRelativePath);
119
+ }
120
+
121
+ /** Healthy Cursor entry must include cwd: "." (legacy without cwd → Repair).
122
+ * Runtime workspace identity still prefers VSCODE_CWD when Cursor spawns under $HOME.
123
+ */
124
+ export function isHealthyKairoMcpEntry(entry) {
125
+ if (!entry || typeof entry !== "object" || Array.isArray(entry)) return false;
126
+ if (entry.command !== "kairo") return false;
127
+ if (!Array.isArray(entry.args) || entry.args.length !== 1 || entry.args[0] !== "mcp") {
128
+ return false;
129
+ }
130
+ return entry.cwd === ".";
131
+ }
132
+
133
+ /**
134
+ * Read-only: is Kairo registered under mcpServers.kairo for the client?
135
+ * Entries without cwd: "." are broken (Repair), not connected.
136
+ */
137
+ export async function detectAgentMcpRegistration({
138
+ client = "cursor",
139
+ homeDir = resolveHomeDir(),
140
+ readFileFn = readFile
141
+ } = {}) {
142
+ const path = resolveMcpConfigPath(client, { homeDir });
143
+ try {
144
+ const raw = await readFileFn(path, "utf8");
145
+ const parsed = JSON.parse(raw);
146
+ const entry = parsed?.mcpServers?.kairo;
147
+ if (entry && typeof entry === "object") {
148
+ if (isHealthyKairoMcpEntry(entry)) {
149
+ return {
150
+ connected: true,
151
+ state: "connected",
152
+ path,
153
+ detail: `Kairo MCP registered in ${path}. Reload Cursor MCP if tools are missing.`
154
+ };
155
+ }
156
+ return {
157
+ connected: false,
158
+ state: "error",
159
+ path,
160
+ detail: "Kairo MCP entry is unhealthy (require command kairo, args [mcp], cwd \".\"). Re-run kairo mcp install --yes."
161
+ };
162
+ }
163
+ return {
164
+ connected: false,
165
+ state: "not_connected",
166
+ path,
167
+ detail: "Kairo MCP is not registered. Click Connect Agent (opens kairo mcp install)."
168
+ };
169
+ } catch (error) {
170
+ if (error?.code === "ENOENT") {
171
+ return {
172
+ connected: false,
173
+ state: "not_connected",
174
+ path,
175
+ detail: "No Cursor MCP config yet. Click Connect Agent to create one."
176
+ };
177
+ }
178
+ return {
179
+ connected: false,
180
+ state: "error",
181
+ path,
182
+ detail: `Could not read ${path}: ${error?.message ?? error}`
183
+ };
184
+ }
185
+ }
186
+
187
+ /**
188
+ * Pure adapter: companion snapshot (+ optional agent registration) → connection chips.
189
+ */
190
+ export function mapCompanionToConnections(companion = {}, agent = null) {
191
+ const gentleState = companion?.signals?.gentle?.state ?? "missing";
192
+ const hermesState = companion?.signals?.hermes?.activity?.state
193
+ ?? companion?.signals?.hermes?.state
194
+ ?? "missing";
195
+ const engramStatus = companion?.engram?.status ?? "missing";
196
+ const graphifyState = companion?.signals?.graphify?.state ?? "missing";
197
+ const graphStatus = companion?.signals?.graphify?.graphStatus ?? null;
198
+
199
+ const connections = [
200
+ chip("gentle", "Gentle", gentleState, CONNECTION_ACCESS.gentle, gentleDetail(gentleState)),
201
+ chip("hermes", "Hermes", hermesState, CONNECTION_ACCESS.hermes, hermesDetail(hermesState)),
202
+ chip("engram", "Engram", engramStatus, CONNECTION_ACCESS.engram, engramDetail(engramStatus)),
203
+ chip(
204
+ "graphify",
205
+ "Graphify",
206
+ graphStatus === "stale" ? "stale" : graphifyState,
207
+ CONNECTION_ACCESS.graphify,
208
+ graphifyDetail(graphifyState, graphStatus)
209
+ )
210
+ ];
211
+
212
+ if (agent) {
213
+ connections.push(chip(
214
+ "agent",
215
+ "Agent",
216
+ agent.state ?? (agent.connected ? "connected" : "not_connected"),
217
+ CONNECTION_ACCESS.agent,
218
+ agent.detail ?? ""
219
+ ));
220
+ }
221
+
222
+ return connections;
223
+ }
224
+
225
+ export async function buildConnectionsReport({
226
+ homeDir = resolveHomeDir(),
227
+ workspaceRoot = process.cwd(),
228
+ client = "cursor",
229
+ packageRoot = null,
230
+ packageName = null,
231
+ cliVersion = null,
232
+ buildCompanion = buildCompanionSnapshot,
233
+ buildObservability = (ctx) => runPassiveObservabilitySnapshot(ctx),
234
+ inspectEngram = inspectEngramIntegration,
235
+ detectAgent = detectAgentMcpRegistration,
236
+ resolveHead = resolveGitHeadSha,
237
+ buildFleet = buildFleetReport
238
+ } = {}) {
239
+ const cwd = workspaceRoot ?? process.cwd();
240
+ const headSha = typeof resolveHead === "function" ? resolveHead(cwd) : null;
241
+ const companion = await buildCompanion({
242
+ inspectEngram: (ctx) => inspectEngram({ homeDir, ...(ctx ?? {}) }),
243
+ buildObservability,
244
+ observabilityContext: {
245
+ cwd,
246
+ homeDir,
247
+ workspaceRoot: cwd,
248
+ headSha,
249
+ packageRoot,
250
+ packageName,
251
+ cliVersion
252
+ }
253
+ });
254
+ const agent = await detectAgent({ client, homeDir });
255
+ const connections = mapCompanionToConnections(companion, agent);
256
+ const fleet = typeof buildFleet === "function"
257
+ ? await buildFleet({ homeDir })
258
+ : { ok: true, fleets: [] };
259
+ return {
260
+ ok: companion?.ok !== false,
261
+ generatedAt: companion?.generatedAt ?? new Date().toISOString(),
262
+ client,
263
+ connections,
264
+ fleets: fleet?.fleets ?? [],
265
+ activity: fleet?.activity ?? null,
266
+ fleetNote: fleet?.note ?? "Declared config topology — not live token usage.",
267
+ orchestratorAuthority: fleet?.orchestratorAuthority ?? null
268
+ };
269
+ }
@@ -0,0 +1,123 @@
1
+ /**
2
+ * Plan builders for fleet configure (kept separate to stay under file size budget).
3
+ */
4
+ import { join } from "node:path";
5
+ import {
6
+ parseFrontmatterModel,
7
+ replaceFrontmatterModel,
8
+ replaceCodexDefaultModel,
9
+ parseCodexDefaultModel
10
+ } from "./observability/fleet-platforms.js";
11
+ import { buildOpenCodeFleetSetPlan } from "./fleet-set.js";
12
+ import { SDD_PHASES } from "./fleet-shared.js";
13
+
14
+ export async function planClaudeChanges(source, homeDir, pathExists, read) {
15
+ const changes = [];
16
+ const settingsPath = join(homeDir, ".claude", "settings.json");
17
+ if (await pathExists(settingsPath)) {
18
+ const settings = JSON.parse(await read(settingsPath, "utf8"));
19
+ const nextDefault = source.default ?? settings.model ?? "sonnet";
20
+ if (settings.model !== nextDefault) {
21
+ changes.push({
22
+ platform: "claude",
23
+ agent: "default",
24
+ path: settingsPath,
25
+ previousModel: settings.model ?? null,
26
+ model: nextDefault,
27
+ kind: "json",
28
+ next: { ...settings, model: nextDefault }
29
+ });
30
+ }
31
+ }
32
+ for (const phase of SDD_PHASES) {
33
+ if (!source[phase]) continue;
34
+ const path = join(homeDir, ".claude", "agents", `${phase}.md`);
35
+ if (!(await pathExists(path))) continue;
36
+ const raw = await read(path, "utf8");
37
+ const previousModel = parseFrontmatterModel(raw).model;
38
+ const model = source[phase];
39
+ if (previousModel === model) continue;
40
+ changes.push({
41
+ platform: "claude",
42
+ agent: phase,
43
+ path,
44
+ previousModel,
45
+ model,
46
+ kind: "text",
47
+ nextText: replaceFrontmatterModel(raw, model)
48
+ });
49
+ }
50
+ return changes;
51
+ }
52
+
53
+ export async function planOpenCodeChanges(opencodeMap, homeDir, pathExists, read) {
54
+ const configPath = join(homeDir, ".config", "opencode", "opencode.json");
55
+ if (!(await pathExists(configPath))) return [];
56
+ const config = JSON.parse(await read(configPath, "utf8"));
57
+ let nextConfig = config;
58
+ const detail = [];
59
+ for (const [agent, model] of Object.entries(opencodeMap)) {
60
+ try {
61
+ const step = buildOpenCodeFleetSetPlan({
62
+ config: nextConfig, agent, model, configPath
63
+ });
64
+ if (step.wouldWrite) {
65
+ detail.push(`${agent}: ${step.previousModel ?? "—"} → ${model}`);
66
+ nextConfig = step.next;
67
+ }
68
+ } catch {
69
+ /* agent missing — skip */
70
+ }
71
+ }
72
+ if (!detail.length) return [];
73
+ return [{
74
+ platform: "opencode",
75
+ agent: "batch",
76
+ path: configPath,
77
+ previousModel: `${detail.length} agents`,
78
+ model: `${detail.length} agents`,
79
+ kind: "json",
80
+ next: nextConfig,
81
+ detail
82
+ }];
83
+ }
84
+
85
+ export async function planCursorChanges(cursorModel, homeDir, pathExists, read) {
86
+ const changes = [];
87
+ const model = cursorModel || "inherit";
88
+ for (const phase of SDD_PHASES) {
89
+ const path = join(homeDir, ".cursor", "agents", `${phase}.md`);
90
+ if (!(await pathExists(path))) continue;
91
+ const raw = await read(path, "utf8");
92
+ const previousModel = parseFrontmatterModel(raw).model;
93
+ if (previousModel === model) continue;
94
+ changes.push({
95
+ platform: "cursor",
96
+ agent: phase,
97
+ path,
98
+ previousModel,
99
+ model,
100
+ kind: "text",
101
+ nextText: replaceFrontmatterModel(raw, model)
102
+ });
103
+ }
104
+ return changes;
105
+ }
106
+
107
+ export async function planCodexChange(codexModel, homeDir, pathExists, read) {
108
+ if (!codexModel) return [];
109
+ const configPath = join(homeDir, ".codex", "config.toml");
110
+ if (!(await pathExists(configPath))) return [];
111
+ const raw = await read(configPath, "utf8");
112
+ const previousModel = parseCodexDefaultModel(raw);
113
+ if (previousModel === codexModel) return [];
114
+ return [{
115
+ platform: "codex",
116
+ agent: "default",
117
+ path: configPath,
118
+ previousModel,
119
+ model: codexModel,
120
+ kind: "text",
121
+ nextText: replaceCodexDefaultModel(raw, codexModel)
122
+ }];
123
+ }
@@ -0,0 +1,303 @@
1
+ /**
2
+ * Cross-platform fleet model configure (Gentle-style assignments).
3
+ * Default: one plan for all multi-agent platforms (claude + opencode + cursor).
4
+ * Codex is single-model — pass --codex-model separately (never mixed into phase map).
5
+ * Plan by default; --yes applies with backups. Never Cursor Auto / state.vscdb.
6
+ */
7
+ import { copyFile, readFile, writeFile, access, mkdir } from "node:fs/promises";
8
+ import { constants as fsConstants } from "node:fs";
9
+ import { resolveHomeDir } from "./paths.js";
10
+ import { writeAtomicJson } from "./runtime/write-atomic-json.js";
11
+ import { printJson } from "./json-output.js";
12
+ import { commandHeader } from "./brand/index.js";
13
+ import { formatCliCommand } from "./brand/cli.js";
14
+ import {
15
+ SDD_PHASES,
16
+ CLAUDE_TO_OPENCODE,
17
+ loadGentleClaudeAssignments,
18
+ mapClaudeAssignmentsToOpenCode
19
+ } from "./fleet-shared.js";
20
+ import {
21
+ loadFleetProfile,
22
+ saveFleetProfile,
23
+ seedFleetProfile,
24
+ profileToPlatformAssignments
25
+ } from "./fleet-models.js";
26
+ import {
27
+ planClaudeChanges,
28
+ planOpenCodeChanges,
29
+ planCursorChanges,
30
+ planCodexChange
31
+ } from "./fleet-configure-plan.js";
32
+
33
+ export {
34
+ SDD_PHASES,
35
+ CLAUDE_TO_OPENCODE,
36
+ loadGentleClaudeAssignments,
37
+ mapClaudeAssignmentsToOpenCode
38
+ };
39
+ export { runFleetModels } from "./fleet-models.js";
40
+
41
+ /** Multi-agent fleet — Codex is opt-in via --codex-model / --platforms codex. */
42
+ const DEFAULT_PLATFORMS = Object.freeze(["claude", "opencode", "cursor"]);
43
+ const MULTI_PLATFORMS = Object.freeze(["claude", "opencode", "cursor"]);
44
+
45
+ function backupPath(path, stamp = Date.now()) {
46
+ return `${path}.kairo-backup.${stamp}`;
47
+ }
48
+
49
+ async function exists(path) {
50
+ try {
51
+ await access(path, fsConstants.F_OK);
52
+ return true;
53
+ } catch {
54
+ return false;
55
+ }
56
+ }
57
+
58
+ export function parseAssignmentList(raw) {
59
+ const out = {};
60
+ if (!raw || typeof raw !== "string") return out;
61
+ for (const part of raw.split(",")) {
62
+ const trimmed = part.trim();
63
+ if (!trimmed) continue;
64
+ const eq = trimmed.indexOf("=");
65
+ if (eq <= 0) throw new Error(`Invalid assignment "${trimmed}". Use agent=model.`);
66
+ const agent = trimmed.slice(0, eq).trim();
67
+ const model = trimmed.slice(eq + 1).trim();
68
+ if (!agent || !model) throw new Error(`Invalid assignment "${trimmed}". Use agent=model.`);
69
+ out[agent] = model;
70
+ }
71
+ return out;
72
+ }
73
+
74
+ export function parsePlatformList(raw) {
75
+ if (!raw || typeof raw !== "string") return [...DEFAULT_PLATFORMS];
76
+ const list = raw.split(",").map((s) => s.trim().toLowerCase()).filter(Boolean);
77
+ for (const p of list) {
78
+ if (!["opencode", "claude", "codex", "cursor"].includes(p)) {
79
+ throw new Error(`Unsupported platform "${p}". Use opencode,claude,cursor,codex.`);
80
+ }
81
+ }
82
+ return list.length ? list : [...DEFAULT_PLATFORMS];
83
+ }
84
+
85
+ /**
86
+ * Build a multi-platform configure plan.
87
+ * Prefer --from profile (disk-seeded) so OpenCode keeps tuned models;
88
+ * --from gentle remaps OpenCode via Claude tiers.
89
+ */
90
+ export async function buildFleetConfigurePlan({
91
+ homeDir = resolveHomeDir(),
92
+ platforms = DEFAULT_PLATFORMS,
93
+ from = "profile",
94
+ assignments = null,
95
+ codexModel = null,
96
+ remapOpenCode = null,
97
+ read = readFile,
98
+ pathExists = exists
99
+ } = {}) {
100
+ const platformSet = new Set(platforms);
101
+ let sourceLabel = "explicit";
102
+ let claudeSource = null;
103
+ let opencodeMap = {};
104
+ let cursorAgentModel = "inherit";
105
+ let profileSnapshot = null;
106
+ let nextCodex = codexModel;
107
+
108
+ if (assignments && Object.keys(assignments).length) {
109
+ sourceLabel = "explicit";
110
+ claudeSource = { ...assignments };
111
+ opencodeMap = mapClaudeAssignmentsToOpenCode(claudeSource);
112
+ if (assignments.codex_default) nextCodex = nextCodex || assignments.codex_default;
113
+ } else if (from === "gentle") {
114
+ sourceLabel = "gentle";
115
+ claudeSource = await loadGentleClaudeAssignments(homeDir, read);
116
+ if (!claudeSource) {
117
+ throw new Error(
118
+ "No Gentle assignments found. Pass --assignments …, use --from profile, or configure Gentle first."
119
+ );
120
+ }
121
+ opencodeMap = remapOpenCode !== false ? mapClaudeAssignmentsToOpenCode(claudeSource) : {};
122
+ } else {
123
+ sourceLabel = "profile";
124
+ const { profile } = await loadFleetProfile({ homeDir, read, seedIfMissing: true });
125
+ profileSnapshot = profile;
126
+ const maps = profileToPlatformAssignments(profile);
127
+ claudeSource = maps.claude;
128
+ opencodeMap = maps.opencode;
129
+ cursorAgentModel = maps.cursorAgentModel;
130
+ if (platformSet.has("codex") && !nextCodex && profile.codexDefault) {
131
+ nextCodex = profile.codexDefault;
132
+ }
133
+ }
134
+
135
+ if (!claudeSource || !Object.keys(claudeSource).length) {
136
+ if (!platformSet.has("codex") || !nextCodex) {
137
+ throw new Error(
138
+ "No model assignments found. Use --from profile|gentle, --assignments, or --codex-model."
139
+ );
140
+ }
141
+ claudeSource = {};
142
+ }
143
+
144
+ const changes = [];
145
+ if (platformSet.has("claude") && Object.keys(claudeSource).length) {
146
+ changes.push(...await planClaudeChanges(claudeSource, homeDir, pathExists, read));
147
+ }
148
+ if (platformSet.has("opencode") && Object.keys(opencodeMap).length) {
149
+ changes.push(...await planOpenCodeChanges(opencodeMap, homeDir, pathExists, read));
150
+ }
151
+ if (platformSet.has("cursor")) {
152
+ changes.push(...await planCursorChanges(cursorAgentModel, homeDir, pathExists, read));
153
+ }
154
+ if (platformSet.has("codex") || nextCodex) {
155
+ changes.push(...await planCodexChange(nextCodex, homeDir, pathExists, read));
156
+ }
157
+
158
+ const multi = [...platformSet].filter((p) => MULTI_PLATFORMS.includes(p));
159
+ return {
160
+ ok: true,
161
+ applied: false,
162
+ source: sourceLabel,
163
+ platforms: [...platformSet],
164
+ multiAgentPlatforms: multi,
165
+ assignments: claudeSource,
166
+ opencodeMap,
167
+ codexModel: nextCodex ?? null,
168
+ cursorAgentModel,
169
+ profile: profileSnapshot,
170
+ changes,
171
+ note: [
172
+ "Default: one plan for multi-agent tools (Claude + OpenCode + Cursor agents).",
173
+ "Codex is single-model — use --codex-model <id> (or --platforms codex).",
174
+ "Cursor Auto chat model stays IDE-managed; agents use inherit.",
175
+ "See also: kairo fleet models"
176
+ ].join(" "),
177
+ applyWith: formatCliCommand(
178
+ nextCodex
179
+ ? `fleet configure --yes --codex-model ${nextCodex}`
180
+ : "fleet configure --yes"
181
+ )
182
+ };
183
+ }
184
+
185
+ export async function runFleetConfigure({
186
+ yes = false,
187
+ json = false,
188
+ platforms = null,
189
+ from = "profile",
190
+ assignmentsRaw = null,
191
+ codexModel = null,
192
+ remapOpenCode = null,
193
+ homeDir = resolveHomeDir(),
194
+ read = readFile,
195
+ writeText = writeFile,
196
+ copyFileFn = copyFile,
197
+ writeAtomicJsonFn = writeAtomicJson,
198
+ mkdirFn = mkdir,
199
+ now = () => Date.now()
200
+ } = {}) {
201
+ let finalPlatforms;
202
+ if (platforms != null && String(platforms).trim() !== "") {
203
+ finalPlatforms = parsePlatformList(platforms);
204
+ if (codexModel && !finalPlatforms.includes("codex")) {
205
+ finalPlatforms = [...finalPlatforms, "codex"];
206
+ }
207
+ } else if (codexModel && !assignmentsRaw) {
208
+ finalPlatforms = ["codex"];
209
+ } else {
210
+ finalPlatforms = [...DEFAULT_PLATFORMS];
211
+ }
212
+
213
+ const assignments = assignmentsRaw ? parseAssignmentList(assignmentsRaw) : null;
214
+ const plan = await buildFleetConfigurePlan({
215
+ homeDir,
216
+ platforms: finalPlatforms,
217
+ from: assignments ? "explicit" : from,
218
+ assignments,
219
+ codexModel,
220
+ remapOpenCode,
221
+ read
222
+ });
223
+
224
+ if (!yes) {
225
+ if (json) printJson(plan);
226
+ else {
227
+ console.log(commandHeader("Fleet configure"));
228
+ console.log(`Source · ${plan.source}`);
229
+ console.log(`Multi-agent · ${(plan.multiAgentPlatforms ?? []).join(", ") || "—"}`);
230
+ console.log(`Platforms · ${plan.platforms.join(", ")}`);
231
+ if (plan.codexModel) console.log(`Codex · ${plan.codexModel}`);
232
+ if (plan.changes.length === 0) {
233
+ console.log("No changes needed — assignments already match disk.");
234
+ } else {
235
+ for (const c of plan.changes) {
236
+ if (c.detail) {
237
+ console.log(`${c.platform} · ${c.path}`);
238
+ for (const line of c.detail) console.log(` ${line}`);
239
+ } else {
240
+ console.log(`${c.platform} · ${c.agent}: ${c.previousModel ?? "—"} → ${c.model}`);
241
+ }
242
+ }
243
+ }
244
+ console.log(plan.note);
245
+ console.log(`Apply · ${plan.applyWith}`);
246
+ }
247
+ return plan;
248
+ }
249
+
250
+ const stamp = now();
251
+ const receipts = [];
252
+ const written = new Set();
253
+ for (const change of plan.changes) {
254
+ if (written.has(change.path)) continue;
255
+ written.add(change.path);
256
+ const bak = backupPath(change.path, stamp);
257
+ await copyFileFn(change.path, bak);
258
+ if (change.kind === "json") {
259
+ await writeAtomicJsonFn(change.path, change.next);
260
+ } else {
261
+ await writeText(change.path, change.nextText, "utf8");
262
+ }
263
+ receipts.push({ path: change.path, backupPath: bak, platform: change.platform });
264
+ }
265
+
266
+ let profilePath = null;
267
+ let profile = plan.profile ?? await seedFleetProfile({ homeDir, read });
268
+ if (plan.assignments?.default) profile.claudeDefault = plan.assignments.default;
269
+ for (const id of SDD_PHASES) {
270
+ if (plan.assignments?.[id]) {
271
+ profile.phases[id] = profile.phases[id] ?? {};
272
+ profile.phases[id].claude = plan.assignments[id];
273
+ }
274
+ if (plan.opencodeMap?.[id]) {
275
+ profile.phases[id] = profile.phases[id] ?? {};
276
+ profile.phases[id].opencode = plan.opencodeMap[id];
277
+ }
278
+ }
279
+ if (plan.codexModel) profile.codexDefault = plan.codexModel;
280
+ profile.cursorAgentModel = plan.cursorAgentModel ?? "inherit";
281
+ const saved = await saveFleetProfile(profile, { homeDir, writeAtomicJsonFn, mkdirFn });
282
+ profilePath = saved.path;
283
+
284
+ const result = {
285
+ ok: true,
286
+ applied: true,
287
+ source: plan.source,
288
+ platforms: plan.platforms,
289
+ changeCount: plan.changes.length,
290
+ receipts,
291
+ profilePath,
292
+ note: "Applied. Multi-agent tools share the phase map; Codex is single-default. Refresh Fleet. kairo fleet models shows available/enabled."
293
+ };
294
+ if (json) printJson(result);
295
+ else {
296
+ console.log(commandHeader("Fleet configure"));
297
+ console.log(`Wrote · ${receipts.length} file(s)`);
298
+ for (const r of receipts) console.log(` ${r.path} (backup ${r.backupPath})`);
299
+ if (profilePath) console.log(`Profile · ${profilePath}`);
300
+ console.log(result.note);
301
+ }
302
+ return result;
303
+ }