@kal-elsam/kairo-runtime 0.14.0 → 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 (45) hide show
  1. package/CHANGELOG.md +84 -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 +172 -8
  6. package/src/global/check-resolutions.js +31 -0
  7. package/src/global/cli-help.js +19 -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/fleet-configure-plan.js +123 -0
  14. package/src/global/fleet-configure.js +303 -0
  15. package/src/global/fleet-models.js +188 -0
  16. package/src/global/fleet-set.js +219 -0
  17. package/src/global/fleet-shared.js +38 -0
  18. package/src/global/ink/cockpit-controller.js +1 -1
  19. package/src/global/ink/cockpit-models.js +4 -1
  20. package/src/global/ink/orchestrator-app.js +21 -2
  21. package/src/global/ink/ux/live-overview.js +5 -11
  22. package/src/global/ink/ux/overview-needs.js +1 -1
  23. package/src/global/integrations/engram-evidence.js +7 -2
  24. package/src/global/integrations/sdd-apply.js +17 -7
  25. package/src/global/integrations/sdd-evidence.js +22 -3
  26. package/src/global/integrations/sdd-plan.js +21 -3
  27. package/src/global/integrations/sdd-resolutions.js +73 -0
  28. package/src/global/integrations/sdd-state.js +69 -0
  29. package/src/global/integrations/sdd-verify.js +9 -4
  30. package/src/global/mcp/kairo-mcp.js +56 -5
  31. package/src/global/mcp/resolve-mcp-workspace.js +51 -0
  32. package/src/global/mcp/work-snapshot-rule.js +89 -0
  33. package/src/global/mcp/work-snapshot-tool.js +49 -0
  34. package/src/global/mcp-install.js +239 -0
  35. package/src/global/next/next-cli.js +35 -0
  36. package/src/global/next/next-report.js +145 -0
  37. package/src/global/next/project-key.js +36 -0
  38. package/src/global/next/publish-work-snapshot.js +116 -0
  39. package/src/global/next/work-enroll.js +91 -0
  40. package/src/global/next/work-snapshot.js +216 -0
  41. package/src/global/observability/fleet-activity.js +197 -0
  42. package/src/global/observability/fleet-models-catalog.js +137 -0
  43. package/src/global/observability/fleet-platforms.js +166 -0
  44. package/src/global/observability/fleet-probe.js +229 -0
  45. package/src/global/paths.js +2 -1
@@ -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
+ }
@@ -0,0 +1,188 @@
1
+ /**
2
+ * Unified fleet model profile (~/.harness/fleet-models.json).
3
+ * Multi-agent platforms (Claude + OpenCode) share phase keys.
4
+ * Codex keeps a single default model. Cursor Auto stays IDE-managed.
5
+ */
6
+ import { readFile, mkdir } from "node:fs/promises";
7
+ import { dirname, join } from "node:path";
8
+ import { resolveHomeDir } from "./paths.js";
9
+ import { writeAtomicJson } from "./runtime/write-atomic-json.js";
10
+ import {
11
+ SDD_PHASES,
12
+ loadGentleClaudeAssignments
13
+ } from "./fleet-shared.js";
14
+ import { parseFrontmatterModel, parseCodexDefaultModel } from "./observability/fleet-platforms.js";
15
+
16
+ export const FLEET_MODELS_VERSION = 1;
17
+
18
+ export function fleetModelsPath(homeDir = resolveHomeDir()) {
19
+ return join(homeDir, ".harness", "fleet-models.json");
20
+ }
21
+
22
+ export function emptyFleetProfile() {
23
+ const phases = {};
24
+ for (const id of SDD_PHASES) {
25
+ phases[id] = { claude: null, opencode: null };
26
+ }
27
+ return {
28
+ version: FLEET_MODELS_VERSION,
29
+ claudeDefault: null,
30
+ codexDefault: null,
31
+ cursorAgentModel: "inherit",
32
+ phases,
33
+ note: "Multi-agent phases for Claude + OpenCode. Codex uses codexDefault only. Cursor agents use inherit (Auto is IDE-managed)."
34
+ };
35
+ }
36
+
37
+ async function readJsonSafe(path, read) {
38
+ try {
39
+ return JSON.parse(await read(path, "utf8"));
40
+ } catch {
41
+ return null;
42
+ }
43
+ }
44
+
45
+ /**
46
+ * Seed profile from Gentle + current on-disk OpenCode/Claude/Codex.
47
+ * Preserves tuned OpenCode models when present.
48
+ */
49
+ export async function seedFleetProfile({
50
+ homeDir = resolveHomeDir(),
51
+ read = readFile
52
+ } = {}) {
53
+ const profile = emptyFleetProfile();
54
+ const gentle = await loadGentleClaudeAssignments(homeDir, read);
55
+ if (gentle) {
56
+ profile.claudeDefault = gentle.default ?? "sonnet";
57
+ for (const id of SDD_PHASES) {
58
+ if (gentle[id]) profile.phases[id].claude = gentle[id];
59
+ }
60
+ }
61
+
62
+ const settings = await readJsonSafe(join(homeDir, ".claude", "settings.json"), read);
63
+ if (settings?.model && !profile.claudeDefault) profile.claudeDefault = settings.model;
64
+
65
+ for (const id of SDD_PHASES) {
66
+ try {
67
+ const raw = await read(join(homeDir, ".claude", "agents", `${id}.md`), "utf8");
68
+ const model = parseFrontmatterModel(raw).model;
69
+ if (model && model !== "inherit") profile.phases[id].claude = model;
70
+ } catch { /* missing */ }
71
+ }
72
+
73
+ const oc = await readJsonSafe(join(homeDir, ".config", "opencode", "opencode.json"), read);
74
+ const agents = oc?.agent ?? oc?.agents ?? {};
75
+ for (const id of SDD_PHASES) {
76
+ const model = agents[id]?.model;
77
+ // Only declare OpenCode models that exist on disk — never invent from Claude tiers.
78
+ if (typeof model === "string") profile.phases[id].opencode = model;
79
+ }
80
+
81
+ try {
82
+ const toml = await read(join(homeDir, ".codex", "config.toml"), "utf8");
83
+ profile.codexDefault = parseCodexDefaultModel(toml);
84
+ } catch { /* missing */ }
85
+
86
+ return profile;
87
+ }
88
+
89
+ export async function loadFleetProfile({
90
+ homeDir = resolveHomeDir(),
91
+ read = readFile,
92
+ seedIfMissing = true
93
+ } = {}) {
94
+ const path = fleetModelsPath(homeDir);
95
+ const existing = await readJsonSafe(path, read);
96
+ if (existing?.phases) {
97
+ return { profile: existing, path, seeded: false };
98
+ }
99
+ if (!seedIfMissing) {
100
+ return { profile: emptyFleetProfile(), path, seeded: false };
101
+ }
102
+ const profile = await seedFleetProfile({ homeDir, read });
103
+ return { profile, path, seeded: true };
104
+ }
105
+
106
+ export async function saveFleetProfile(profile, {
107
+ homeDir = resolveHomeDir(),
108
+ writeAtomicJsonFn = writeAtomicJson,
109
+ mkdirFn = mkdir
110
+ } = {}) {
111
+ const path = fleetModelsPath(homeDir);
112
+ await mkdirFn(dirname(path), { recursive: true });
113
+ const next = {
114
+ ...profile,
115
+ version: FLEET_MODELS_VERSION,
116
+ updatedAt: new Date().toISOString()
117
+ };
118
+ await writeAtomicJsonFn(path, next);
119
+ return { path, profile: next };
120
+ }
121
+
122
+ /** Expand profile → assignment maps used by apply. */
123
+ export function profileToPlatformAssignments(profile) {
124
+ const claude = {};
125
+ const opencode = {};
126
+ if (profile.claudeDefault) claude.default = profile.claudeDefault;
127
+ for (const id of SDD_PHASES) {
128
+ const row = profile.phases?.[id] ?? {};
129
+ if (row.claude) claude[id] = row.claude;
130
+ if (row.opencode) opencode[id] = row.opencode;
131
+ }
132
+ return {
133
+ claude,
134
+ opencode,
135
+ codex: profile.codexDefault ? { codex_default: profile.codexDefault } : {},
136
+ cursorAgentModel: profile.cursorAgentModel ?? "inherit"
137
+ };
138
+ }
139
+
140
+ export function formatFleetProfileText(profile, { path = null } = {}) {
141
+ const lines = ["Fleet profile (multi-agent)", ""];
142
+ if (path) lines.push(`Path · ${path}`);
143
+ lines.push(`Claude default · ${profile.claudeDefault ?? "—"}`);
144
+ lines.push(`Codex default · ${profile.codexDefault ?? "—"} (single-model tool)`);
145
+ lines.push(`Cursor agents · ${profile.cursorAgentModel ?? "inherit"} (Auto IDE-managed)`);
146
+ lines.push("");
147
+ for (const id of SDD_PHASES) {
148
+ const row = profile.phases?.[id] ?? {};
149
+ lines.push(`${id} · claude ${row.claude ?? "—"} · opencode ${row.opencode ?? "—"}`);
150
+ }
151
+ if (profile.note) {
152
+ lines.push("");
153
+ lines.push(profile.note);
154
+ }
155
+ return lines.join("\n").trimEnd();
156
+ }
157
+
158
+ export async function runFleetModels({
159
+ json = false,
160
+ profile = false,
161
+ homeDir = resolveHomeDir()
162
+ } = {}) {
163
+ const { printJson } = await import("./json-output.js");
164
+ const { commandHeader } = await import("./brand/index.js");
165
+ const { buildFleetModelsCatalog, formatFleetModelsText } = await import(
166
+ "./observability/fleet-models-catalog.js"
167
+ );
168
+ const catalog = await buildFleetModelsCatalog({ homeDir });
169
+ if (!profile) {
170
+ if (json) printJson(catalog);
171
+ else {
172
+ console.log(commandHeader("Fleet models"));
173
+ console.log(formatFleetModelsText(catalog));
174
+ }
175
+ return catalog;
176
+ }
177
+
178
+ const loaded = await loadFleetProfile({ homeDir, seedIfMissing: true });
179
+ const payload = { ok: true, profile: loaded.profile, path: loaded.path, catalog };
180
+ if (json) printJson(payload);
181
+ else {
182
+ console.log(commandHeader("Fleet models"));
183
+ console.log(formatFleetProfileText(loaded.profile, { path: loaded.path }));
184
+ console.log("");
185
+ console.log(formatFleetModelsText(catalog));
186
+ }
187
+ return payload;
188
+ }