@kal-elsam/kairo-runtime 0.14.0 → 0.16.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 (56) hide show
  1. package/CHANGELOG.md +110 -0
  2. package/bin/kairo-runtime.js +0 -0
  3. package/bin/kairo.js +0 -0
  4. package/package.json +1 -1
  5. package/src/cli.js +182 -8
  6. package/src/global/check-resolutions.js +31 -0
  7. package/src/global/cli-help.js +21 -2
  8. package/src/global/component-ecosystem-checks.js +2 -0
  9. package/src/global/component-integration-cli.js +29 -10
  10. package/src/global/components-resolve-cli.js +246 -0
  11. package/src/global/connection-actions.js +147 -0
  12. package/src/global/connections.js +269 -0
  13. package/src/global/control-plane/attention.js +141 -0
  14. package/src/global/control-plane/build-report.js +146 -0
  15. package/src/global/control-plane/cli.js +36 -0
  16. package/src/global/control-plane/constants.js +38 -0
  17. package/src/global/control-plane/gentle-adapters.js +183 -0
  18. package/src/global/control-plane/provider.js +69 -0
  19. package/src/global/control-plane/review-status.js +115 -0
  20. package/src/global/control-plane/sdd-status.js +49 -0
  21. package/src/global/control-plane/team.js +63 -0
  22. package/src/global/fleet-configure-plan.js +123 -0
  23. package/src/global/fleet-configure.js +303 -0
  24. package/src/global/fleet-models.js +188 -0
  25. package/src/global/fleet-set.js +219 -0
  26. package/src/global/fleet-shared.js +38 -0
  27. package/src/global/ink/cockpit-controller.js +1 -1
  28. package/src/global/ink/cockpit-models.js +4 -1
  29. package/src/global/ink/orchestrator-app.js +21 -2
  30. package/src/global/ink/ux/live-overview.js +5 -11
  31. package/src/global/ink/ux/overview-needs.js +1 -1
  32. package/src/global/integrations/engram-evidence.js +7 -2
  33. package/src/global/integrations/sdd-apply.js +17 -7
  34. package/src/global/integrations/sdd-evidence.js +22 -3
  35. package/src/global/integrations/sdd-plan.js +21 -3
  36. package/src/global/integrations/sdd-resolutions.js +73 -0
  37. package/src/global/integrations/sdd-state.js +69 -0
  38. package/src/global/integrations/sdd-verify.js +9 -4
  39. package/src/global/mcp/kairo-mcp.js +56 -5
  40. package/src/global/mcp/resolve-mcp-workspace.js +51 -0
  41. package/src/global/mcp/work-snapshot-rule.js +89 -0
  42. package/src/global/mcp/work-snapshot-tool.js +49 -0
  43. package/src/global/mcp-install.js +239 -0
  44. package/src/global/next/next-cli.js +35 -0
  45. package/src/global/next/next-report.js +145 -0
  46. package/src/global/next/project-key.js +36 -0
  47. package/src/global/next/publish-work-snapshot.js +116 -0
  48. package/src/global/next/work-enroll.js +91 -0
  49. package/src/global/next/work-snapshot.js +216 -0
  50. package/src/global/observability/fleet-activity.js +197 -0
  51. package/src/global/observability/fleet-models-catalog.js +137 -0
  52. package/src/global/observability/fleet-platforms.js +166 -0
  53. package/src/global/observability/fleet-probe.js +229 -0
  54. package/src/global/observability/gentle-probe.js +30 -2
  55. package/src/global/observability/index.js +2 -1
  56. package/src/global/paths.js +2 -1
@@ -0,0 +1,115 @@
1
+ /**
2
+ * Official Gentle review status v2/v3 only. Never read authority inventory.
3
+ */
4
+ import { isAbsolute } from "node:path";
5
+ import { SUPPORTED_CONTRACT } from "../observability/gentle-probe.js";
6
+
7
+ export const REVIEW_STATUS_SCHEMAS = Object.freeze([
8
+ "gentle-ai.review-integration.status/v2",
9
+ "gentle-ai.review-integration.status/v3"
10
+ ]);
11
+
12
+ const INVENTORY_SCHEMA = "gentle-ai.review-authority-status/v1";
13
+ const UNSAFE_TOKEN = /[|;&$`\n\r]/;
14
+ const PLACEHOLDER = /^<[^>]+>$/;
15
+
16
+ export const GENTLE_224_BOOTSTRAP =
17
+ "gentle-ai review status --cwd <repo> --contract gentle-ai.review-integration/v2 --next-transition";
18
+ export const GENTLE_230_BOOTSTRAP =
19
+ "gentle-ai review status --cwd <repo> --contract gentle-ai.review-integration/v2 --agent claude-code --next-transition";
20
+
21
+ const failArgv = () => ({ ok: false, error: "gentle_incompatible", binary: null, argv: null });
22
+
23
+ export function bootstrapCommandFromProbe(probe) {
24
+ return probe?.evidence?.find((row) => row?.kind === "bootstrap" && row.command)?.command ?? null;
25
+ }
26
+
27
+ export function argvFromBootstrap(command, { repo, binaryPath }) {
28
+ if (typeof command !== "string" || !command.trim() || UNSAFE_TOKEN.test(command)) return failArgv();
29
+ if (typeof binaryPath !== "string" || !isAbsolute(binaryPath)) return failArgv();
30
+ if (typeof repo !== "string" || !repo) return failArgv();
31
+ const tokens = command.trim().split(/\s+/);
32
+ if (tokens[0] !== "gentle-ai") return failArgv();
33
+ const args = [];
34
+ const rest = tokens.slice(1);
35
+ for (let i = 0; i < rest.length; i += 1) {
36
+ const tok = rest[i];
37
+ if (UNSAFE_TOKEN.test(tok)) return failArgv();
38
+ if (tok === "--cwd") {
39
+ const next = rest[i + 1];
40
+ const needsRepo = next === "<repo>" || next == null || next.startsWith("-");
41
+ args.push("--cwd", needsRepo ? repo : next);
42
+ if (!needsRepo || next === "<repo>") i += 1;
43
+ continue;
44
+ }
45
+ if (PLACEHOLDER.test(tok)) return failArgv();
46
+ args.push(tok);
47
+ }
48
+ if (args[0] !== "review" || args[1] !== "status") return failArgv();
49
+ return { ok: true, error: null, binary: binaryPath, argv: args };
50
+ }
51
+
52
+ function isObject(value) {
53
+ return value != null && typeof value === "object" && !Array.isArray(value);
54
+ }
55
+
56
+ function publishedReceipt(receiptField) {
57
+ if (typeof receiptField === "string" && receiptField) return receiptField;
58
+ if (!isObject(receiptField)) return null;
59
+ if (typeof receiptField.id === "string" && receiptField.id) return receiptField.id;
60
+ if (typeof receiptField.digest === "string" && receiptField.digest) return receiptField.digest;
61
+ return null;
62
+ }
63
+
64
+ function publishedGate(payload) {
65
+ if (typeof payload.gate === "string" && payload.gate) return payload.gate;
66
+ if (isObject(payload.receipt) && typeof payload.receipt.gate === "string" && payload.receipt.gate) {
67
+ return payload.receipt.gate;
68
+ }
69
+ return null;
70
+ }
71
+
72
+ /**
73
+ * Fail closed unless schema/contract are official. Pass next_transition through unaltered.
74
+ */
75
+ export function mapOfficialReviewStatus(payload) {
76
+ if (!isObject(payload)) {
77
+ return { ok: false, error: "gentle_incompatible", review: null, nextTransition: null };
78
+ }
79
+ if (
80
+ payload.schema === INVENTORY_SCHEMA
81
+ || payload.authoritative === true
82
+ || Array.isArray(payload.entries)
83
+ ) {
84
+ return { ok: false, error: "gentle_incompatible", review: null, nextTransition: null };
85
+ }
86
+ if (!REVIEW_STATUS_SCHEMAS.includes(payload.schema)) {
87
+ return { ok: false, error: "gentle_incompatible", review: null, nextTransition: null };
88
+ }
89
+ if (payload.contract != null && payload.contract !== SUPPORTED_CONTRACT) {
90
+ return { ok: false, error: "gentle_incompatible", review: null, nextTransition: null };
91
+ }
92
+
93
+ const nextTransition = Object.prototype.hasOwnProperty.call(payload, "next_transition")
94
+ ? payload.next_transition
95
+ : null;
96
+ const receipt = publishedReceipt(payload.receipt);
97
+ const gate = publishedGate(payload);
98
+ const applicability = typeof payload.applicability === "string" ? payload.applicability : null;
99
+ const action = typeof payload.action === "string" ? payload.action : null;
100
+ const receiptStatus = isObject(payload.receipt) && typeof payload.receipt.status === "string"
101
+ ? payload.receipt.status
102
+ : null;
103
+
104
+ const review = (receipt || gate || applicability || action)
105
+ ? {
106
+ lineageId: null,
107
+ state: applicability ?? action,
108
+ status: receiptStatus,
109
+ receipt,
110
+ gate
111
+ }
112
+ : null;
113
+
114
+ return { ok: true, error: null, review, nextTransition };
115
+ }
@@ -0,0 +1,49 @@
1
+ /**
2
+ * Official `sdd-status --json` projection. No inferred phase/route/next.
3
+ */
4
+ import { NO_ACTIVE_WORKFLOW, WORKFLOW_KIND } from "./constants.js";
5
+
6
+ export const SDD_STATUS_SCHEMA_PREFIX = "gentle-ai.sdd-status";
7
+ export const SDD_STATUS_ARGS = Object.freeze(["sdd-status", "--json"]);
8
+
9
+ function isObject(value) {
10
+ return value != null && typeof value === "object" && !Array.isArray(value);
11
+ }
12
+
13
+ export function mapOfficialSddStatus(payload) {
14
+ if (!isObject(payload)) {
15
+ return { ok: false, error: "gentle_incompatible", projection: null };
16
+ }
17
+ const schemaName = typeof payload.schemaName === "string"
18
+ ? payload.schemaName
19
+ : (typeof payload.schema === "string" ? payload.schema : null);
20
+ if (!schemaName || !schemaName.startsWith(SDD_STATUS_SCHEMA_PREFIX)) {
21
+ return { ok: false, error: "gentle_incompatible", projection: null };
22
+ }
23
+ const changeName = typeof payload.changeName === "string" && payload.changeName
24
+ ? payload.changeName
25
+ : null;
26
+ const nextRecommended = typeof payload.nextRecommended === "string" && payload.nextRecommended
27
+ ? payload.nextRecommended
28
+ : null;
29
+ return {
30
+ ok: true,
31
+ error: null,
32
+ projection: { schemaName, changeName, nextRecommended }
33
+ };
34
+ }
35
+
36
+ export function applySddProjection(workflow, projection) {
37
+ workflow.sdd = projection;
38
+ workflow.changeName = projection.changeName;
39
+ workflow.phase = null;
40
+ if (projection.changeName) {
41
+ workflow.kind = WORKFLOW_KIND.SDD;
42
+ workflow.active = true;
43
+ workflow.label = "SDD";
44
+ } else if (workflow.kind !== WORKFLOW_KIND.REVIEW) {
45
+ workflow.kind = WORKFLOW_KIND.NONE;
46
+ workflow.active = false;
47
+ workflow.label = NO_ACTIVE_WORKFLOW;
48
+ }
49
+ }
@@ -0,0 +1,63 @@
1
+ /**
2
+ * Normalize declared fleets into honest control-plane team nodes.
3
+ * Live requires OpenCode active-agent evidence — never idle sessions.
4
+ */
5
+ import { HONESTY } from "./constants.js";
6
+
7
+ function honestyForNode(node, { platform, hasLiveActivity }) {
8
+ if (node?.opaque === true) return HONESTY.OPAQUE;
9
+ if (platform === "opencode" && hasLiveActivity) return HONESTY.LIVE;
10
+ return HONESTY.DECLARED;
11
+ }
12
+
13
+ export function normalizeTeam(connectionsReport = {}) {
14
+ const fleets = Array.isArray(connectionsReport.fleets) ? connectionsReport.fleets : [];
15
+ const activity = connectionsReport.activity ?? null;
16
+ const hasLiveActivity = Boolean(
17
+ activity
18
+ && (
19
+ (Array.isArray(activity.agents) && activity.agents.some((a) => a?.state === "active"))
20
+ || (typeof activity.activeCount === "number" && activity.activeCount > 0)
21
+ || activity.active === true
22
+ )
23
+ );
24
+
25
+ // Cursor configured agents are declared topology (not live); only opaque nodes stay opaque.
26
+ const platforms = fleets.map((fleet) => {
27
+ const platform = fleet?.platform ?? "unknown";
28
+ const orch = fleet?.orchestrator ?? null;
29
+ return {
30
+ platform,
31
+ honesty: honestyForNode(fleet, { platform, hasLiveActivity }),
32
+ source: fleet?.source ?? null,
33
+ orchestrator: orch
34
+ ? {
35
+ id: orch.id ?? null,
36
+ model: orch.modelShort ?? orch.model ?? null,
37
+ honesty: honestyForNode(orch, { platform, hasLiveActivity }),
38
+ role: orch.mode ?? "orchestrator"
39
+ }
40
+ : null,
41
+ agents: (Array.isArray(fleet?.minions) ? fleet.minions : []).map((m) => ({
42
+ id: m.id,
43
+ model: m.modelShort ?? m.model ?? null,
44
+ role: m.role ?? null,
45
+ honesty: m?.opaque === true && platform === "cursor"
46
+ ? HONESTY.DECLARED
47
+ : honestyForNode(m, { platform, hasLiveActivity: false })
48
+ }))
49
+ };
50
+ });
51
+
52
+ return {
53
+ platforms,
54
+ // Only surface activity when there is live evidence (active workers).
55
+ activity: hasLiveActivity ? activity : null,
56
+ fleetNote: connectionsReport.fleetNote
57
+ ?? "Declared config topology — not live token usage.",
58
+ orchestratorAuthority: connectionsReport.orchestratorAuthority ?? null,
59
+ connections: Array.isArray(connectionsReport.connections)
60
+ ? connectionsReport.connections
61
+ : []
62
+ };
63
+ }
@@ -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
+ }