@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,219 @@
1
+ /**
2
+ * Consent-gated fleet model writes (OpenCode / Claude / Codex).
3
+ * Plan by default; --yes applies with backup. Never touches Cursor Auto / state.vscdb.
4
+ */
5
+ import { copyFile, readFile, writeFile } from "node:fs/promises";
6
+ import { join } from "node:path";
7
+ import { resolveHomeDir } from "./paths.js";
8
+ import { writeAtomicJson } from "./runtime/write-atomic-json.js";
9
+ import { printJson } from "./json-output.js";
10
+ import { commandHeader } from "./brand/index.js";
11
+ import { formatCliCommand } from "./brand/cli.js";
12
+ import {
13
+ parseFrontmatterModel,
14
+ replaceFrontmatterModel,
15
+ replaceCodexDefaultModel
16
+ } from "./observability/fleet-platforms.js";
17
+
18
+ const PLATFORMS = new Set(["opencode", "claude", "codex"]);
19
+
20
+ function backupPath(path, stamp = Date.now()) {
21
+ return `${path}.kairo-backup.${stamp}`;
22
+ }
23
+
24
+ function requirePlatform(platform) {
25
+ const p = String(platform ?? "").toLowerCase();
26
+ if (!PLATFORMS.has(p)) {
27
+ throw new Error(`Unsupported platform "${platform}". Use opencode, claude, or codex.`);
28
+ }
29
+ return p;
30
+ }
31
+
32
+ function requireAgent(agent) {
33
+ const id = String(agent ?? "").trim();
34
+ if (!id) throw new Error("Missing --agent <id>.");
35
+ return id;
36
+ }
37
+
38
+ function requireModel(model) {
39
+ const m = String(model ?? "").trim();
40
+ if (!m) throw new Error("Missing --model <id>.");
41
+ return m;
42
+ }
43
+
44
+ export function buildOpenCodeFleetSetPlan({ config, agent, model, configPath }) {
45
+ const agents = config?.agent ?? config?.agents;
46
+ if (!agents || typeof agents !== "object" || !(agent in agents)) {
47
+ throw new Error(`OpenCode agent "${agent}" not found in ${configPath}.`);
48
+ }
49
+ const prev = agents[agent];
50
+ const previousModel = typeof prev?.model === "string" ? prev.model : (config?.model ?? null);
51
+ const nextAgents = {
52
+ ...agents,
53
+ [agent]: { ...prev, model }
54
+ };
55
+ const next = { ...config, agent: nextAgents };
56
+ if (config.agents && !config.agent) {
57
+ delete next.agent;
58
+ next.agents = nextAgents;
59
+ }
60
+ return {
61
+ platform: "opencode",
62
+ agent,
63
+ model,
64
+ previousModel,
65
+ path: configPath,
66
+ wouldWrite: previousModel !== model,
67
+ next,
68
+ note: `Set OpenCode agent.${agent}.model → ${model}`
69
+ };
70
+ }
71
+
72
+ export async function runFleetSet({
73
+ platform,
74
+ agent,
75
+ model,
76
+ yes = false,
77
+ json = false,
78
+ dryRun = false,
79
+ homeDir = resolveHomeDir(),
80
+ read = readFile,
81
+ writeText = writeFile,
82
+ copyFileFn = copyFile,
83
+ writeAtomicJsonFn = writeAtomicJson,
84
+ now = () => Date.now()
85
+ } = {}) {
86
+ const p = requirePlatform(platform);
87
+ const nextModel = requireModel(model);
88
+ const agentId = p === "codex"
89
+ ? (String(agent ?? "default").trim() || "default")
90
+ : requireAgent(agent);
91
+ const apply = yes === true && dryRun !== true;
92
+
93
+ let plan;
94
+ let path;
95
+ let applyFn;
96
+
97
+ if (p === "opencode") {
98
+ path = join(homeDir, ".config", "opencode", "opencode.json");
99
+ const raw = await read(path, "utf8");
100
+ const config = JSON.parse(raw);
101
+ plan = buildOpenCodeFleetSetPlan({
102
+ config, agent: agentId, model: nextModel, configPath: path
103
+ });
104
+ applyFn = async () => {
105
+ await writeAtomicJsonFn(path, plan.next);
106
+ };
107
+ } else if (p === "claude") {
108
+ if (agentId === "default") {
109
+ path = join(homeDir, ".claude", "settings.json");
110
+ const existing = JSON.parse(await read(path, "utf8"));
111
+ const previousModel = typeof existing.model === "string" ? existing.model : null;
112
+ plan = {
113
+ platform: "claude",
114
+ agent: agentId,
115
+ model: nextModel,
116
+ previousModel,
117
+ path,
118
+ wouldWrite: previousModel !== nextModel,
119
+ next: { ...existing, model: nextModel },
120
+ note: `Set Claude settings.model → ${nextModel}`
121
+ };
122
+ applyFn = async () => {
123
+ await writeAtomicJsonFn(path, plan.next);
124
+ };
125
+ } else {
126
+ path = join(homeDir, ".claude", "agents", `${agentId}.md`);
127
+ const raw = await read(path, "utf8");
128
+ const previousModel = parseFrontmatterModel(raw).model;
129
+ const nextText = replaceFrontmatterModel(raw, nextModel);
130
+ plan = {
131
+ platform: "claude",
132
+ agent: agentId,
133
+ model: nextModel,
134
+ previousModel,
135
+ path,
136
+ wouldWrite: previousModel !== nextModel,
137
+ nextText,
138
+ note: `Set Claude agent ${agentId} frontmatter model → ${nextModel}`
139
+ };
140
+ applyFn = async () => {
141
+ await writeText(path, plan.nextText, "utf8");
142
+ };
143
+ }
144
+ } else {
145
+ path = join(homeDir, ".codex", "config.toml");
146
+ const raw = await read(path, "utf8");
147
+ const previousMatch = raw.match(/^\s*model\s*=\s*"([^"]+)"/m);
148
+ const previousModel = previousMatch ? previousMatch[1] : null;
149
+ const nextText = replaceCodexDefaultModel(raw, nextModel);
150
+ plan = {
151
+ platform: "codex",
152
+ agent: "default",
153
+ model: nextModel,
154
+ previousModel,
155
+ path,
156
+ wouldWrite: previousModel !== nextModel,
157
+ nextText,
158
+ note: `Set Codex config.toml model → ${nextModel}`
159
+ };
160
+ applyFn = async () => {
161
+ await writeText(path, plan.nextText, "utf8");
162
+ };
163
+ }
164
+
165
+ plan.backupPath = backupPath(path, now());
166
+ plan.applyWith = formatCliCommand(
167
+ `fleet set --platform ${p} --agent ${plan.agent} --model ${nextModel} --yes`
168
+ );
169
+
170
+ if (!apply) {
171
+ const payload = {
172
+ ok: true,
173
+ applied: false,
174
+ plan: {
175
+ platform: plan.platform,
176
+ agent: plan.agent,
177
+ model: plan.model,
178
+ previousModel: plan.previousModel,
179
+ path: plan.path,
180
+ wouldWrite: plan.wouldWrite,
181
+ note: plan.note,
182
+ applyWith: plan.applyWith
183
+ }
184
+ };
185
+ if (json) printJson(payload);
186
+ else {
187
+ console.log(commandHeader("Fleet set"));
188
+ console.log(plan.note);
189
+ console.log(`Path · ${plan.path}`);
190
+ console.log(`Was · ${plan.previousModel ?? "—"}`);
191
+ console.log(`Now · ${plan.model}`);
192
+ console.log(`Apply · ${plan.applyWith}`);
193
+ }
194
+ return payload;
195
+ }
196
+
197
+ await copyFileFn(path, plan.backupPath);
198
+ await applyFn();
199
+
200
+ const receipt = {
201
+ ok: true,
202
+ applied: true,
203
+ platform: plan.platform,
204
+ agent: plan.agent,
205
+ model: plan.model,
206
+ previousModel: plan.previousModel,
207
+ path: plan.path,
208
+ backupPath: plan.backupPath,
209
+ note: "Model assignment updated. Refresh Kairo Fleet to see declared changes."
210
+ };
211
+ if (json) printJson(receipt);
212
+ else {
213
+ console.log(commandHeader("Fleet set"));
214
+ console.log(`Wrote · ${receipt.path}`);
215
+ console.log(`Backup · ${receipt.backupPath}`);
216
+ console.log(receipt.note);
217
+ }
218
+ return receipt;
219
+ }
@@ -0,0 +1,38 @@
1
+ /**
2
+ * Shared fleet constants + Gentle assignment loader (no circular imports).
3
+ */
4
+ import { readFile } from "node:fs/promises";
5
+ import { join } from "node:path";
6
+
7
+ export const SDD_PHASES = Object.freeze([
8
+ "sdd-apply", "sdd-archive", "sdd-design", "sdd-explore", "sdd-init",
9
+ "sdd-onboard", "sdd-propose", "sdd-spec", "sdd-tasks", "sdd-verify"
10
+ ]);
11
+
12
+ /** Map Gentle/Claude tier names → OpenCode provider/model ids. */
13
+ export const CLAUDE_TO_OPENCODE = Object.freeze({
14
+ opus: "opencode-go/deepseek-v4-pro",
15
+ sonnet: "opencode-go/qwen3.5-plus",
16
+ haiku: "opencode-go/deepseek-v4-flash"
17
+ });
18
+
19
+ export async function loadGentleClaudeAssignments(homeDir, read = readFile) {
20
+ try {
21
+ const state = JSON.parse(await read(join(homeDir, ".gentle-ai", "state.json"), "utf8"));
22
+ const map = state?.claude_model_assignments;
23
+ if (!map || typeof map !== "object") return null;
24
+ return { ...map };
25
+ } catch {
26
+ return null;
27
+ }
28
+ }
29
+
30
+ export function mapClaudeAssignmentsToOpenCode(assignments = {}) {
31
+ const out = {};
32
+ for (const [agent, tier] of Object.entries(assignments)) {
33
+ if (agent === "default" || agent === "codex_default") continue;
34
+ const key = String(tier ?? "").toLowerCase();
35
+ out[agent] = CLAUDE_TO_OPENCODE[key] ?? CLAUDE_TO_OPENCODE.sonnet;
36
+ }
37
+ return out;
38
+ }
@@ -29,7 +29,7 @@ export {
29
29
  export function createCockpitUiState({
30
30
  layoutMode = LAYOUT_MODES.COMPACT,
31
31
  view = ORCHESTRATOR_VIEWS.HOME,
32
- region = COCKPIT_REGIONS.NAV,
32
+ region = null,
33
33
  navIndex = 0,
34
34
  listIndex = 0,
35
35
  helpOpen = false,
@@ -328,8 +328,11 @@ export function buildFooterModel({
328
328
 
329
329
  // HOME footer must stay on one line at 80 cols (frame already near 24 rows).
330
330
  if (view === ORCHESTRATOR_VIEWS.HOME) {
331
+ const homeParts = region === COCKPIT_REGIONS.NAV
332
+ ? ["↑↓ Section", "Enter Open", "? Help", "Esc Exit"]
333
+ : ["1·2 Select", "Enter Run", "? Help", "Esc Exit"];
331
334
  return {
332
- text: ["↑↓", "Enter", "Tab", "Space", "R", "?", "/", "Esc"].join(` ${glyphs.bullet} `),
335
+ text: homeParts.join(` ${glyphs.bullet} `),
333
336
  columns: footerColumns
334
337
  };
335
338
  }
@@ -63,8 +63,7 @@ export function OrchestratorApp({
63
63
  const [ui, dispatch] = useReducer(
64
64
  reduceCockpitUi,
65
65
  createCockpitUiState({
66
- layoutMode: layoutMode ?? LAYOUT_MODES.COMPACT,
67
- region: COCKPIT_REGIONS.NAV
66
+ layoutMode: layoutMode ?? LAYOUT_MODES.COMPACT
68
67
  })
69
68
  );
70
69
  const data = useOrchestratorData({
@@ -166,6 +165,26 @@ export function OrchestratorApp({
166
165
  return;
167
166
  }
168
167
 
168
+ if (ui.view === ORCHESTRATOR_VIEWS.HOME && !ui.paletteOpen) {
169
+ const digit = inputKey === "1" ? 0 : inputKey === "2" ? 1 : -1;
170
+ if (digit >= 0) {
171
+ const buttons = buildOverviewButtons({
172
+ hasGlobalState,
173
+ snapshot: data.snapshot,
174
+ diagnostics: data.diagnostics,
175
+ dashboard: data.dashboard
176
+ });
177
+ const selected = buttons[digit];
178
+ const intent = selected?.intent ?? null;
179
+ if (intent === "setup") {
180
+ finish({ cancelled: false, action: "setup" });
181
+ return;
182
+ }
183
+ if (intent && openDestination(intent)) return;
184
+ return;
185
+ }
186
+ }
187
+
169
188
  if (inputKey === " " && ui.view === ORCHESTRATOR_VIEWS.HOME && !ui.paletteOpen) {
170
189
  dispatch({ type: "toggle-overview-details" });
171
190
  return;
@@ -94,12 +94,6 @@ export function adaptControlCenterToOverview(model = {}, options = {}) {
94
94
  label: `Alerts · ${model.alerts.headline ?? `${model.alerts.count} open`}`
95
95
  });
96
96
  }
97
- if (rest.length > 0) {
98
- metrics.push({
99
- id: "more",
100
- label: `${rest.length} more in Details · Space`
101
- });
102
- }
103
97
  if (metrics.length === 0) {
104
98
  metrics.push({ id: "quiet", label: "Nothing else needs you right now" });
105
99
  }
@@ -201,13 +195,13 @@ export function SemanticOverviewPanel({
201
195
  marginBottom: index === view.buttons.length - 1 ? 0 : 1
202
196
  },
203
197
  React.createElement(Text, {
204
- bold: true,
205
- color: focused && colorEnabled ? COCKPIT_COLORS.interactive : undefined
206
- }, `${focused ? glyphs.focus : " "} ${button.label}`),
198
+ bold: selected,
199
+ color: colorEnabled && selected ? COCKPIT_COLORS.interactive : undefined
200
+ }, `${selected ? glyphs.focus : " "} [${index + 1}] ${button.label}${focused ? " ← Press Enter" : ""}`),
207
201
  button.detail
208
202
  ? React.createElement(Text, {
209
203
  color: colorEnabled ? COCKPIT_COLORS.muted : undefined
210
- }, ` ${button.detail}`)
204
+ }, ` ${button.detail}`)
211
205
  : null
212
206
  );
213
207
  })
@@ -223,7 +217,7 @@ export function SemanticOverviewPanel({
223
217
  ),
224
218
  React.createElement(Details, {
225
219
  open: detailsOpen,
226
- summary: "More info",
220
+ summary: `More info (${view.details.length})`,
227
221
  lines: view.details,
228
222
  colorEnabled,
229
223
  focused: false,
@@ -4,7 +4,7 @@
4
4
  import { CONTROL_PLANE_HEALTH } from "../../control-plane-snapshot.js";
5
5
  import { formatCliCommand } from "../../brand/cli.js";
6
6
 
7
- export const OVERVIEW_NEED_LIMIT = 3;
7
+ export const OVERVIEW_NEED_LIMIT = 2;
8
8
 
9
9
  /** Prefixes that never belong on the first screen (machine / internals). */
10
10
  export const DETAILS_ONLY_PREFIXES = [
@@ -215,12 +215,17 @@ function jsonKeyEvidence(path, keyPath, kind) {
215
215
  try {
216
216
  let cursor = JSON.parse(readFileSync(path, "utf8"));
217
217
  for (const key of keyPath) {
218
- if (cursor == null || typeof cursor !== "object") {
218
+ if (cursor == null || typeof cursor !== "object" || Array.isArray(cursor)) {
219
219
  return { path, kind, present: false, conflict: true, keyPath, detail: "invalid structure" };
220
220
  }
221
+ // Missing key = unconfigured evidence, not a conflict (file may use another MCP path).
222
+ if (!Object.prototype.hasOwnProperty.call(cursor, key)) {
223
+ return { path, kind, present: false, conflict: false, keyPath };
224
+ }
221
225
  cursor = cursor[key];
222
226
  }
223
- return { path, kind, present: cursor != null && typeof cursor === "object", conflict: false, keyPath };
227
+ const present = cursor != null && typeof cursor === "object" && !Array.isArray(cursor);
228
+ return { path, kind, present, conflict: false, keyPath };
224
229
  } catch {
225
230
  return { path, kind, present: false, conflict: true, keyPath, detail: "unreadable json" };
226
231
  }
@@ -17,7 +17,8 @@ const APPLYING_ACTIONS = new Set([SDD_PLAN_ACTIONS.CREATE, SDD_PLAN_ACTIONS.UPDA
17
17
 
18
18
  export async function applySddConfigure({
19
19
  requestedAgentIds = null, detectedAgentIds = [], homeDir, packageRoot, persona = "off",
20
- personaAgentIds = [], trackedFiles = {}, preservePersona = false, dryRun = false, yes = false,
20
+ personaAgentIds = [], trackedFiles = {}, adoptedFiles = {}, overwriteConflicts = false,
21
+ preservePersona = false, dryRun = false, yes = false,
21
22
  json = false, interactive = null, receiptId = null, plan = planSddConfigure,
22
23
  confirm = promptApplyConfirmation, saveReceipt = saveSddReceipt,
23
24
  now = () => new Date().toISOString()
@@ -28,14 +29,16 @@ export async function applySddConfigure({
28
29
 
29
30
  const planned = await plan({
30
31
  requestedAgentIds, detectedAgentIds, homeDir, packageRoot, persona, personaAgentIds,
31
- trackedFiles, preservePersona, dryRun: true
32
+ trackedFiles, adoptedFiles, overwriteConflicts, preservePersona, dryRun: true
32
33
  });
33
34
  if (dryRun) return { ...planned, applied: false, cancelled: false, receipt: null };
34
35
 
35
36
  if (shouldPromptApplyConfirmation({ applying: true, dryRun, json, confirm: yes, interactive })) {
36
37
  const accepted = await confirm({
37
38
  command: "components configure sdd-core",
38
- question: "Materialize SDD skills for the planned agents? [Y/n]: "
39
+ question: overwriteConflicts
40
+ ? "Overwrite conflicting SDD skills with canonical Kairo copies (backups first)? [Y/n]: "
41
+ : "Materialize SDD skills for the planned agents? [Y/n]: "
39
42
  });
40
43
  if (!accepted) return { ...planned, applied: false, cancelled: true, receipt: null };
41
44
  }
@@ -66,7 +69,8 @@ export async function applySddConfigure({
66
69
  try {
67
70
  const managedRoot = resolveSddSkillRoot(action.agentIds[0], homeDir);
68
71
  const outcome = await materializeOne(action, {
69
- homeDir, packageRoot, managedRoot, receiptId: resolvedReceiptId
72
+ homeDir, packageRoot, managedRoot, receiptId: resolvedReceiptId,
73
+ overwriteConflicts: Boolean(action.overwrote || overwriteConflicts)
70
74
  });
71
75
  if (outcome.conflict) {
72
76
  files.push({
@@ -78,7 +82,8 @@ export async function applySddConfigure({
78
82
  if (outcome.backup) backups.push(outcome.backup);
79
83
  files.push({
80
84
  ...record, applied: true, skipped: false, outcome: SDD_FILE_OUTCOMES.APPLIED,
81
- afterHash: outcome.afterHash, parentRealpath: outcome.parentRealpath
85
+ afterHash: outcome.afterHash, parentRealpath: outcome.parentRealpath,
86
+ overwrote: Boolean(action.overwrote)
82
87
  });
83
88
  } catch (error) {
84
89
  failed = { skillId: action.skillId, destinationPath: action.destinationPath, error: error.message };
@@ -141,7 +146,9 @@ async function readCanonicalBytes(action, packageRoot) {
141
146
  ));
142
147
  }
143
148
 
144
- async function materializeOne(action, { homeDir, packageRoot, managedRoot, receiptId }) {
149
+ async function materializeOne(action, {
150
+ homeDir, packageRoot, managedRoot, receiptId, overwriteConflicts = false
151
+ }) {
145
152
  const chain = await assertSafePathChain(action.destinationPath, managedRoot, homeDir);
146
153
  if (!chain.ok) return { conflict: chain.reason };
147
154
 
@@ -169,7 +176,10 @@ async function materializeOne(action, { homeDir, packageRoot, managedRoot, recei
169
176
  return { conflict: "Managed destination disappeared after planning; preserving byte-for-byte." };
170
177
  }
171
178
  const snap = await snapshotRegularFile(action.destinationPath);
172
- if (snap.hash !== action.trackedHash) {
179
+ const expectedHash = overwriteConflicts || action.overwrote
180
+ ? snap.hash
181
+ : action.trackedHash;
182
+ if (snap.hash !== expectedHash) {
173
183
  return { conflict: "Managed file changed after planning; preserving byte-for-byte." };
174
184
  }
175
185
  const parent = await parentRealpath(action.destinationPath);
@@ -16,6 +16,7 @@ export const SDD_FILE_OUTCOMES = Object.freeze({
16
16
 
17
17
  export const SDD_HEALTH = Object.freeze({
18
18
  CONFIGURED: "configured",
19
+ ADOPTED: "adopted",
19
20
  MISSING: "missing",
20
21
  DRIFTED: "drifted",
21
22
  CONFLICT: "conflict"
@@ -23,13 +24,16 @@ export const SDD_HEALTH = Object.freeze({
23
24
 
24
25
  /**
25
26
  * Classify one destination file against canonical bytes and optional tracked hash.
26
- * Untracked or user-modified files are conflicts and must never be overwritten.
27
+ * Untracked or user-modified files are conflicts and must never be overwritten
28
+ * unless overwriteConflicts is requested at apply time.
29
+ * adoptedHash (disk content accepted as-is) yields NOOP without claiming managed.
27
30
  */
28
31
  export function classifySddSkillFile({
29
32
  exists,
30
33
  canonicalHash,
31
34
  diskHash = null,
32
- trackedHash = null
35
+ trackedHash = null,
36
+ adoptedHash = null
33
37
  } = {}) {
34
38
  if (!exists) {
35
39
  return {
@@ -38,6 +42,13 @@ export function classifySddSkillFile({
38
42
  };
39
43
  }
40
44
 
45
+ if (adoptedHash != null && adoptedHash === diskHash) {
46
+ return {
47
+ action: SDD_PLAN_ACTIONS.NOOP,
48
+ reason: "Adopted disk bytes; preserving byte-for-byte."
49
+ };
50
+ }
51
+
41
52
  if (trackedHash == null) {
42
53
  return {
43
54
  action: SDD_PLAN_ACTIONS.CONFLICT,
@@ -70,11 +81,19 @@ export function classifySddVerifyHealth({
70
81
  exists,
71
82
  canonicalHash,
72
83
  diskHash = null,
73
- trackedHash = null
84
+ trackedHash = null,
85
+ adoptedHash = null
74
86
  } = {}) {
75
87
  if (!exists) {
76
88
  return { status: SDD_HEALTH.MISSING, drift: null, reason: "Destination missing on disk." };
77
89
  }
90
+ if (adoptedHash != null && adoptedHash === diskHash) {
91
+ return {
92
+ status: SDD_HEALTH.ADOPTED,
93
+ drift: null,
94
+ reason: "Disk matches adopted hash (not Kairo-managed)."
95
+ };
96
+ }
78
97
  if (trackedHash == null) {
79
98
  return { status: SDD_HEALTH.CONFLICT, drift: null, reason: "Pre-existing untracked file." };
80
99
  }
@@ -25,6 +25,8 @@ export async function planSddConfigure({
25
25
  persona = "off",
26
26
  personaAgentIds = [],
27
27
  trackedFiles = {},
28
+ adoptedFiles = {},
29
+ overwriteConflicts = false,
28
30
  preservePersona = false,
29
31
  dryRun = true,
30
32
  exists = existsSync,
@@ -50,16 +52,31 @@ export async function planSddConfigure({
50
52
  for (const group of destinationGroups) {
51
53
  const destinationPath = join(group.root, skillId, ...file.relativePath.split("/"));
52
54
  const trackedHash = trackedFiles[destinationPath] ?? null;
55
+ const adoptedHash = adoptedFiles[destinationPath] ?? null;
53
56
  const fileExists = exists(destinationPath);
54
57
  const diskHash = fileExists ? hashBuffer(await readFileImpl(destinationPath)) : null;
55
- const classification = classifySddSkillFile({
56
- exists: fileExists, canonicalHash, diskHash, trackedHash
58
+ let classification = classifySddSkillFile({
59
+ exists: fileExists, canonicalHash, diskHash, trackedHash, adoptedHash
57
60
  });
61
+ let overwrote = false;
62
+ if (
63
+ overwriteConflicts
64
+ && classification.action === SDD_PLAN_ACTIONS.CONFLICT
65
+ && fileExists
66
+ ) {
67
+ classification = {
68
+ action: SDD_PLAN_ACTIONS.UPDATE,
69
+ reason: "Overwrite conflicts requested; backup then replace with canonical."
70
+ };
71
+ overwrote = true;
72
+ }
58
73
  actions.push({
59
74
  skillId, relativePath: file.relativePath, destinationPath,
60
75
  agentIds: [...group.agentIds], kind: group.kind,
61
76
  action: classification.action, reason: classification.reason,
62
- canonicalHash, skillHash, diskHash, trackedHash, writes: false, executes: false
77
+ canonicalHash, skillHash, diskHash, trackedHash, adoptedHash,
78
+ overwrote, overwriteConflicts: Boolean(overwriteConflicts),
79
+ writes: false, executes: false
63
80
  });
64
81
  }
65
82
  }
@@ -78,6 +95,7 @@ export async function planSddConfigure({
78
95
  return {
79
96
  provider: "sdd-core", componentId: "sdd-core", dryRun: Boolean(dryRun),
80
97
  executes: false, writes: false, requestedPersona: persona, preservePersona,
98
+ overwriteConflicts: Boolean(overwriteConflicts),
81
99
  persona: personaTransition.persona,
82
100
  personaPath: resolveCanonicalTeachingPersonaPath(packageRoot),
83
101
  personaActive: personaTransition.after.length > 0, personaTransition, agentIds, actions,
@@ -0,0 +1,73 @@
1
+ import { formatCliCommand } from "../brand/cli.js";
2
+ import { resolution, RESOLUTION_KIND, RESOLUTION_SAFETY } from "../check-resolutions.js";
3
+ import { SDD_HEALTH } from "./sdd-evidence.js";
4
+
5
+ /**
6
+ * Build panel/CLI resolution buttons for sdd-core:skills from verify findings.
7
+ * Agent list is derived from conflict/drifted findings so buttons scope correctly.
8
+ */
9
+ export function buildSddSkillResolutions(verification = {}) {
10
+ const summary = verification.summary ?? {};
11
+ const conflictCount = summary.conflict ?? 0;
12
+ const driftedCount = summary.drifted ?? 0;
13
+ if (conflictCount === 0 && driftedCount === 0) return [];
14
+
15
+ const agentIds = conflictAgentIds(verification.findings ?? []);
16
+ const agentsFlag = agentIds.length ? ` --agents ${agentIds.join(",")}` : "";
17
+
18
+ return [
19
+ resolution(
20
+ "sdd-diff",
21
+ "Ver diff",
22
+ formatCliCommand(`components diff sdd-core${agentsFlag}`),
23
+ {
24
+ kind: RESOLUTION_KIND.RUN,
25
+ safety: RESOLUTION_SAFETY.READ_ONLY,
26
+ detail: "Read-only: canonical Kairo skills vs disk."
27
+ }
28
+ ),
29
+ resolution(
30
+ "sdd-adopt",
31
+ "Conservar el mío",
32
+ formatCliCommand(`components adopt sdd-core${agentsFlag} --yes`),
33
+ {
34
+ kind: RESOLUTION_KIND.CONFIGURE,
35
+ safety: RESOLUTION_SAFETY.CONSENT,
36
+ detail: "Adopt disk bytes into Kairo state without overwriting files. Button click is consent."
37
+ }
38
+ ),
39
+ resolution(
40
+ "sdd-overwrite",
41
+ "Usar versión Kairo",
42
+ formatCliCommand(`components configure sdd-core${agentsFlag} --overwrite-conflicts --yes`),
43
+ {
44
+ kind: RESOLUTION_KIND.CONFIGURE,
45
+ safety: RESOLUTION_SAFETY.DESTRUCTIVE,
46
+ detail: "Backup then replace conflicting files with canonical Kairo skills."
47
+ }
48
+ ),
49
+ resolution(
50
+ "doctor",
51
+ "Doctor",
52
+ formatCliCommand("doctor"),
53
+ { kind: RESOLUTION_KIND.RUN, safety: RESOLUTION_SAFETY.READ_ONLY }
54
+ ),
55
+ resolution(
56
+ "refresh",
57
+ "Refresh",
58
+ null,
59
+ { kind: RESOLUTION_KIND.REFRESH, safety: RESOLUTION_SAFETY.READ_ONLY }
60
+ )
61
+ ];
62
+ }
63
+
64
+ function conflictAgentIds(findings) {
65
+ const ids = new Set();
66
+ for (const finding of findings) {
67
+ if (finding?.status !== SDD_HEALTH.CONFLICT && finding?.status !== SDD_HEALTH.DRIFTED) {
68
+ continue;
69
+ }
70
+ for (const id of finding.agentIds ?? []) ids.add(id);
71
+ }
72
+ return [...ids].sort();
73
+ }