@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
@@ -46,6 +46,7 @@ import { LAYOUT_MODES } from "./layout.js";
46
46
  import { CHANGES_PHASE } from "./cockpit-changes.js";
47
47
  import { RECOVERY_PHASE, listRecoverySnapshots } from "./cockpit-recovery.js";
48
48
  import { SETTINGS_PHASE } from "./cockpit-settings.js";
49
+ import { buildOverviewButtons, OVERVIEW_BUTTON_COUNT } from "./ux/overview-actions.js";
49
50
 
50
51
  export function OrchestratorApp({
51
52
  homeDir,
@@ -62,8 +63,7 @@ export function OrchestratorApp({
62
63
  const [ui, dispatch] = useReducer(
63
64
  reduceCockpitUi,
64
65
  createCockpitUiState({
65
- layoutMode: layoutMode ?? LAYOUT_MODES.COMPACT,
66
- region: COCKPIT_REGIONS.NAV
66
+ layoutMode: layoutMode ?? LAYOUT_MODES.COMPACT
67
67
  })
68
68
  );
69
69
  const data = useOrchestratorData({
@@ -165,6 +165,26 @@ export function OrchestratorApp({
165
165
  return;
166
166
  }
167
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
+
168
188
  if (inputKey === " " && ui.view === ORCHESTRATOR_VIEWS.HOME && !ui.paletteOpen) {
169
189
  dispatch({ type: "toggle-overview-details" });
170
190
  return;
@@ -245,7 +265,9 @@ export function OrchestratorApp({
245
265
  return;
246
266
  }
247
267
 
248
- const listLength = ui.view === ORCHESTRATOR_VIEWS.RUNS
268
+ const listLength = ui.view === ORCHESTRATOR_VIEWS.HOME
269
+ ? OVERVIEW_BUTTON_COUNT
270
+ : ui.view === ORCHESTRATOR_VIEWS.RUNS
249
271
  ? RUNS_HUB_ITEMS.length
250
272
  : ui.view === ORCHESTRATOR_VIEWS.ACTIVE_RUNS
251
273
  ? (data.dashboard?.activeRuns ?? []).length
@@ -301,6 +323,21 @@ export function OrchestratorApp({
301
323
  return;
302
324
  }
303
325
  }
326
+ if (routed.type === "enter-home-button") {
327
+ const buttons = buildOverviewButtons({
328
+ hasGlobalState,
329
+ snapshot: data.snapshot,
330
+ diagnostics: data.diagnostics,
331
+ dashboard: data.dashboard
332
+ });
333
+ const selected = buttons[Math.min(Math.max(0, ui.listIndex), buttons.length - 1)];
334
+ const intent = selected?.intent ?? null;
335
+ if (intent === "setup") {
336
+ finish({ cancelled: false, action: "setup" });
337
+ return;
338
+ }
339
+ if (intent && openDestination(intent)) return;
340
+ }
304
341
  dispatch(routed);
305
342
  return;
306
343
  }
@@ -583,7 +620,8 @@ export function OrchestratorApp({
583
620
  governanceDetailsOpen: ui.governanceDetailsOpen,
584
621
  activityDetailsOpen: ui.activityDetailsOpen,
585
622
  contentFocused: ui.region === COCKPIT_REGIONS.CONTENT,
586
- homeDir
623
+ homeDir,
624
+ hasGlobalState
587
625
  })
588
626
  )
589
627
  );
@@ -1,16 +1,16 @@
1
1
  /**
2
2
  * Live semantic Overview for Cockpit HOME — product cover.
3
- * Nav owns the sole focus mark this panel never renders `>`.
3
+ * Content owns the button focus mark when region=content.
4
4
  * ASCII wordmark only here (wide/compact); minimal is textual.
5
5
  *
6
- * Rule: show purpose + one next step + a few plain-language needs.
6
+ * Rule: show purpose + two buttons + a few plain-language needs.
7
7
  * Machine/system noise stays out of the first screen (Details only).
8
8
  */
9
9
  import React from "react";
10
10
  import { Box, Text } from "ink";
11
11
  import { DASHBOARD_PURPOSE } from "../../dashboard-guidance.js";
12
12
  import { LAYOUT_MODES } from "../layout.js";
13
- import { COCKPIT_COLORS } from "../theme.js";
13
+ import { COCKPIT_COLORS, resolveGlyphs } from "../theme.js";
14
14
  import {
15
15
  overviewBrandTitle,
16
16
  shouldShowWordmark,
@@ -24,6 +24,7 @@ import {
24
24
  mapHealthTone,
25
25
  partitionCompanionLines
26
26
  } from "./overview-needs.js";
27
+ import { buildOverviewButtons, OVERVIEW_BUTTON_COUNT } from "./overview-actions.js";
27
28
 
28
29
  export {
29
30
  humanizeCompanionNeed,
@@ -33,6 +34,8 @@ export {
33
34
  partitionCompanionLines
34
35
  } from "./overview-needs.js";
35
36
 
37
+ export { buildOverviewButtons, OVERVIEW_BUTTON_COUNT } from "./overview-actions.js";
38
+
36
39
  /** Safe Details lines — leftovers + raw signals; never invent paths/IDs. */
37
40
  export function buildOverviewDetails(model = {}, companionRest = []) {
38
41
  const lines = [];
@@ -59,13 +62,19 @@ export function buildOverviewDetails(model = {}, companionRest = []) {
59
62
 
60
63
  /**
61
64
  * Pure adapter: buildControlCenterModel → semantic overview props.
62
- * First screen = purpose + next step + plain needs. Machine noise → Details.
65
+ * First screen = purpose + two buttons + plain needs. Machine noise → Details.
63
66
  */
64
- export function adaptControlCenterToOverview(model = {}) {
67
+ export function adaptControlCenterToOverview(model = {}, options = {}) {
65
68
  const status = model.status ?? model.health ?? {};
66
69
  const next = model.nextAction ?? model.cta ?? {};
67
70
  const { needs, rest } = partitionCompanionLines(model.companion?.lines ?? []);
68
71
  const primary = humanizePrimary(next);
72
+ const buttons = buildOverviewButtons({
73
+ hasGlobalState: options.hasGlobalState,
74
+ snapshot: options.snapshot,
75
+ diagnostics: options.diagnostics,
76
+ dashboard: options.dashboard
77
+ });
69
78
 
70
79
  const activity = model.activity?.headline;
71
80
  const metrics = [];
@@ -85,12 +94,6 @@ export function adaptControlCenterToOverview(model = {}) {
85
94
  label: `Alerts · ${model.alerts.headline ?? `${model.alerts.count} open`}`
86
95
  });
87
96
  }
88
- if (rest.length > 0) {
89
- metrics.push({
90
- id: "more",
91
- label: `${rest.length} more in Details · Space`
92
- });
93
- }
94
97
  if (metrics.length === 0) {
95
98
  metrics.push({ id: "quiet", label: "Nothing else needs you right now" });
96
99
  }
@@ -104,6 +107,7 @@ export function adaptControlCenterToOverview(model = {}) {
104
107
  body: status.summaryLine ?? ""
105
108
  },
106
109
  primary,
110
+ buttons,
107
111
  metrics,
108
112
  details: buildOverviewDetails(model, rest)
109
113
  };
@@ -136,14 +140,30 @@ export function SemanticOverviewPanel({
136
140
  detailsOpen = false,
137
141
  colorEnabled = true,
138
142
  unicode = true,
139
- layoutMode = LAYOUT_MODES.COMPACT
143
+ layoutMode = LAYOUT_MODES.COMPACT,
144
+ selectedIndex = 0,
145
+ contentFocused = false,
146
+ hasGlobalState = false,
147
+ snapshot = null,
148
+ diagnostics = null,
149
+ dashboard = null
140
150
  }) {
141
- const view = adaptControlCenterToOverview(model);
151
+ const view = adaptControlCenterToOverview(model, {
152
+ hasGlobalState,
153
+ snapshot,
154
+ diagnostics,
155
+ dashboard
156
+ });
142
157
  const showArt = shouldShowWordmark(layoutMode);
143
158
  const brandTitle = overviewBrandTitle(layoutMode);
144
159
  const isWide = layoutMode === LAYOUT_MODES.WIDE;
145
160
  const mark = renderWordmark({ layoutMode, colorEnabled, unicode });
146
161
  const status = renderCallout(view, colorEnabled);
162
+ const safeIndex = Math.min(
163
+ Math.max(0, selectedIndex),
164
+ Math.max(0, view.buttons.length - 1)
165
+ );
166
+ const glyphs = resolveGlyphs(unicode);
147
167
 
148
168
  const hero = showArt
149
169
  ? (isWide
@@ -166,18 +186,25 @@ export function SemanticOverviewPanel({
166
186
  color: colorEnabled ? COCKPIT_COLORS.muted : undefined
167
187
  }, view.purpose),
168
188
  React.createElement(Box, { marginTop: 1, flexDirection: "column" },
169
- React.createElement(Text, {
170
- bold: true,
171
- color: colorEnabled ? COCKPIT_COLORS.interactive : undefined
172
- }, `→ ${view.primary.label}`),
173
- view.primary.detail
174
- ? React.createElement(Text, null, view.primary.detail)
175
- : null,
176
- view.primary.hint
177
- ? React.createElement(Text, {
178
- color: colorEnabled ? COCKPIT_COLORS.muted : undefined
179
- }, view.primary.hint)
180
- : null
189
+ ...view.buttons.map((button, index) => {
190
+ const selected = index === safeIndex;
191
+ const focused = contentFocused && selected;
192
+ return React.createElement(Box, {
193
+ key: button.id,
194
+ flexDirection: "column",
195
+ marginBottom: index === view.buttons.length - 1 ? 0 : 1
196
+ },
197
+ React.createElement(Text, {
198
+ bold: selected,
199
+ color: colorEnabled && selected ? COCKPIT_COLORS.interactive : undefined
200
+ }, `${selected ? glyphs.focus : " "} [${index + 1}] ${button.label}${focused ? " ← Press Enter" : ""}`),
201
+ button.detail
202
+ ? React.createElement(Text, {
203
+ color: colorEnabled ? COCKPIT_COLORS.muted : undefined
204
+ }, ` ${button.detail}`)
205
+ : null
206
+ );
207
+ })
181
208
  ),
182
209
  React.createElement(Box, { marginTop: 1, flexDirection: "column" },
183
210
  React.createElement(ActionList, {
@@ -190,7 +217,7 @@ export function SemanticOverviewPanel({
190
217
  ),
191
218
  React.createElement(Details, {
192
219
  open: detailsOpen,
193
- summary: "More info",
220
+ summary: `More info (${view.details.length})`,
194
221
  lines: view.details,
195
222
  colorEnabled,
196
223
  focused: false,
@@ -0,0 +1,80 @@
1
+ /**
2
+ * Two explicit Home buttons — prepare + configure.
3
+ * No writes here: intents only open existing consent-gated flows.
4
+ */
5
+
6
+ export const OVERVIEW_BUTTON_COUNT = 2;
7
+
8
+ function changeCount(snapshot) {
9
+ if (!snapshot?.diff?.hasChanges) return 0;
10
+ return snapshot.diff.changeCount
11
+ ?? snapshot.diff.changes?.length
12
+ ?? 0;
13
+ }
14
+
15
+ function detectedAgentCount({ snapshot = null, diagnostics = null, dashboard = null } = {}) {
16
+ if (Number.isFinite(snapshot?.coverage?.detectedAgents)) {
17
+ return snapshot.coverage.detectedAgents;
18
+ }
19
+ if (Number.isFinite(diagnostics?.diagnostics?.detected)) {
20
+ return diagnostics.diagnostics.detected;
21
+ }
22
+ return (dashboard?.providers ?? []).filter((entry) => entry?.available).length;
23
+ }
24
+
25
+ function buildPrepareButton({
26
+ hasGlobalState = false,
27
+ snapshot = null,
28
+ diagnostics = null,
29
+ dashboard = null
30
+ } = {}) {
31
+ const detected = detectedAgentCount({ snapshot, diagnostics, dashboard });
32
+ const needsSetup = hasGlobalState === false || detected === 0;
33
+ if (needsSetup) {
34
+ return {
35
+ id: "prepare",
36
+ label: "Set up Kairo",
37
+ detail: "Detect your agents and install what is missing.",
38
+ intent: "setup"
39
+ };
40
+ }
41
+
42
+ const pending = changeCount(snapshot);
43
+ if (pending > 0) {
44
+ return {
45
+ id: "prepare",
46
+ label: `Repair ${pending} change${pending === 1 ? "" : "s"}`,
47
+ detail: "You will see the exact plan before anything is written.",
48
+ intent: "governance"
49
+ };
50
+ }
51
+
52
+ return {
53
+ id: "prepare",
54
+ label: "Everything is ready",
55
+ detail: "Open History to see what changed, or Configure to adjust.",
56
+ intent: "history"
57
+ };
58
+ }
59
+
60
+ const CONFIGURE_BUTTON = Object.freeze({
61
+ id: "configure",
62
+ label: "Configure",
63
+ detail: "Agents, Obsidian vault, integrations.",
64
+ intent: "settings"
65
+ });
66
+
67
+ /**
68
+ * Exactly two buttons for Home. First button changes face by state.
69
+ */
70
+ export function buildOverviewButtons({
71
+ hasGlobalState = false,
72
+ snapshot = null,
73
+ diagnostics = null,
74
+ dashboard = null
75
+ } = {}) {
76
+ return [
77
+ buildPrepareButton({ hasGlobalState, snapshot, diagnostics, dashboard }),
78
+ { ...CONFIGURE_BUTTON }
79
+ ];
80
+ }
@@ -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
+ }