@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
@@ -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
+ }
@@ -8,6 +8,7 @@ export function defaultSddState() {
8
8
  personaAgentIds: [],
9
9
  agentIds: [],
10
10
  files: [],
11
+ adopted: [],
11
12
  lastReceiptId: null,
12
13
  updatedAt: null
13
14
  };
@@ -25,6 +26,7 @@ export function normalizeSddState(raw) {
25
26
  personaAgentIds,
26
27
  agentIds: Array.isArray(raw.agentIds) ? [...raw.agentIds] : [],
27
28
  files: Array.isArray(raw.files) ? raw.files.map(normalizeSddFile) : [],
29
+ adopted: Array.isArray(raw.adopted) ? raw.adopted.map(normalizeAdoptedFile).filter(Boolean) : [],
28
30
  lastReceiptId: typeof raw.lastReceiptId === "string" ? raw.lastReceiptId : null,
29
31
  updatedAt: typeof raw.updatedAt === "string" ? raw.updatedAt : null
30
32
  };
@@ -42,6 +44,72 @@ function normalizeSddFile(entry) {
42
44
  };
43
45
  }
44
46
 
47
+ function normalizeAdoptedFile(entry) {
48
+ if (!entry || typeof entry !== "object") return null;
49
+ if (typeof entry.destinationPath !== "string" || !entry.destinationPath) return null;
50
+ if (typeof entry.hash !== "string" || !entry.hash) return null;
51
+ return {
52
+ destinationPath: entry.destinationPath,
53
+ hash: entry.hash,
54
+ skillId: typeof entry.skillId === "string" ? entry.skillId : null,
55
+ agentIds: Array.isArray(entry.agentIds) ? [...entry.agentIds] : [],
56
+ relativePath: typeof entry.relativePath === "string" ? entry.relativePath : "SKILL.md",
57
+ adoptedAt: typeof entry.adoptedAt === "string" ? entry.adoptedAt : null,
58
+ reason: typeof entry.reason === "string" ? entry.reason : null
59
+ };
60
+ }
61
+
62
+ /** Map destinationPath → adopted hash for verify/plan. */
63
+ export function adoptedHashesFromState(sdd) {
64
+ const adopted = normalizeSddState(sdd).adopted;
65
+ return Object.fromEntries(adopted.map((entry) => [entry.destinationPath, entry.hash]));
66
+ }
67
+
68
+ /** Record conflict findings as adopted disk hashes (no file writes). */
69
+ export function recordSddAdoptions(state, {
70
+ adoptions = [],
71
+ now = () => new Date().toISOString()
72
+ } = {}) {
73
+ const current = normalizeSddState(state?.sdd);
74
+ const byPath = new Map(current.adopted.map((entry) => [entry.destinationPath, entry]));
75
+ for (const entry of adoptions) {
76
+ const normalized = normalizeAdoptedFile({
77
+ ...entry,
78
+ adoptedAt: entry.adoptedAt ?? now()
79
+ });
80
+ if (!normalized) continue;
81
+ byPath.set(normalized.destinationPath, normalized);
82
+ }
83
+ const adopted = [...byPath.values()].sort((a, b) =>
84
+ a.destinationPath.localeCompare(b.destinationPath)
85
+ );
86
+ return {
87
+ ...(state ?? {}),
88
+ sdd: {
89
+ ...current,
90
+ adopted,
91
+ updatedAt: now()
92
+ }
93
+ };
94
+ }
95
+
96
+ /** Drop adopted entries for paths that become Kairo-managed (overwrite/apply). */
97
+ export function clearSddAdoptionsForPaths(state, paths = [], {
98
+ now = () => new Date().toISOString()
99
+ } = {}) {
100
+ const current = normalizeSddState(state?.sdd);
101
+ const drop = new Set(paths);
102
+ const adopted = current.adopted.filter((entry) => !drop.has(entry.destinationPath));
103
+ return {
104
+ ...(state ?? {}),
105
+ sdd: {
106
+ ...current,
107
+ adopted,
108
+ updatedAt: now()
109
+ }
110
+ };
111
+ }
112
+
45
113
  function verifiedNoopHash(file) {
46
114
  const disk = file.afterHash ?? file.diskHash ?? file.beforeHash ?? null;
47
115
  if (disk == null || file.canonicalHash == null || disk !== file.canonicalHash) return null;
@@ -116,6 +184,7 @@ export function recordSddMaterialization(state, { receipt, now = () => new Date(
116
184
  personaAgentIds,
117
185
  agentIds: collectAgentIds(files),
118
186
  files,
187
+ adopted: current.adopted,
119
188
  lastReceiptId: receipt.id ?? current.lastReceiptId,
120
189
  updatedAt: now()
121
190
  }
@@ -26,6 +26,7 @@ export async function verifySddConfigure({
26
26
  homeDir,
27
27
  packageRoot,
28
28
  trackedFiles = {},
29
+ adoptedFiles = {},
29
30
  personaAgentIds = [],
30
31
  exists = existsSync,
31
32
  readFileImpl = readFile
@@ -49,14 +50,15 @@ export async function verifySddConfigure({
49
50
  const fileExists = exists(destinationPath);
50
51
  const diskHash = fileExists ? hashBuffer(await readFileImpl(destinationPath)) : null;
51
52
  const trackedHash = trackedFiles[destinationPath] ?? null;
53
+ const adoptedHash = adoptedFiles[destinationPath] ?? null;
52
54
  const health = classifySddVerifyHealth({
53
- exists: fileExists, canonicalHash, diskHash, trackedHash
55
+ exists: fileExists, canonicalHash, diskHash, trackedHash, adoptedHash
54
56
  });
55
57
  findings.push({
56
58
  skillId, relativePath: file.relativePath, destinationPath,
57
59
  agentIds: [...group.agentIds], kind: group.kind,
58
60
  status: health.status, drift: health.drift, reason: health.reason,
59
- canonicalHash, skillHash, diskHash, trackedHash
61
+ canonicalHash, skillHash, diskHash, trackedHash, adoptedHash
60
62
  });
61
63
  }
62
64
  }
@@ -66,13 +68,15 @@ export async function verifySddConfigure({
66
68
  || compareSkillPaths(a.relativePath, b.relativePath)
67
69
  || compareSkillPaths(a.destinationPath, b.destinationPath));
68
70
 
69
- const summary = { configured: 0, missing: 0, drifted: 0, conflict: 0 };
71
+ const summary = { configured: 0, adopted: 0, missing: 0, drifted: 0, conflict: 0 };
70
72
  for (const entry of findings) summary[entry.status] += 1;
71
73
 
72
74
  const consumers = normalizePersonaAgentIds(personaAgentIds);
73
75
  const incompleteAgentIds = consumers.filter((id) => {
74
76
  const mine = findings.filter((e) => e.agentIds.includes(id));
75
- return !mine.length || mine.some((e) => e.status !== SDD_HEALTH.CONFIGURED);
77
+ return !mine.length || mine.some((e) =>
78
+ e.status !== SDD_HEALTH.CONFIGURED && e.status !== SDD_HEALTH.ADOPTED
79
+ );
76
80
  });
77
81
  let gatePresent = true;
78
82
  for (const id of consumers) {
@@ -100,6 +104,7 @@ export function summarizeSddHealth(summary) {
100
104
  if (summary.conflict > 0) return SDD_HEALTH.CONFLICT;
101
105
  if (summary.missing > 0) return SDD_HEALTH.MISSING;
102
106
  if (summary.drifted > 0) return SDD_HEALTH.DRIFTED;
107
+ if ((summary.adopted ?? 0) > 0 && (summary.configured ?? 0) === 0) return SDD_HEALTH.ADOPTED;
103
108
  return SDD_HEALTH.CONFIGURED;
104
109
  }
105
110
 
@@ -12,10 +12,20 @@ import { runGraphifyOp } from "../observability/graphify-ops.js";
12
12
  import { resolveGitHeadSha } from "../observability/graphify-probe.js";
13
13
  import { runPassiveObservabilitySnapshot } from "../observability/passive-snapshot-flight.js";
14
14
  import { inspectEngramIntegration } from "../integrations/engram-evidence.js";
15
+ import { buildFleetReport } from "../observability/fleet-probe.js";
16
+ import {
17
+ createPublishWorkSnapshotHandler,
18
+ workSnapshotPublishSchema
19
+ } from "./work-snapshot-tool.js";
20
+ import { resolveMcpWorkspaceCwd } from "./resolve-mcp-workspace.js";
21
+
22
+ /** Sole MCP write tool for companion snapshots. */
23
+ export const KAIRO_MCP_WRITE_TOOLS = Object.freeze(["kairo_publish_work_snapshot"]);
15
24
 
16
25
  export const KAIRO_MCP_TOOLS = Object.freeze([
17
26
  "kairo_status", "kairo_runs", "kairo_alerts", "kairo_gentle_status",
18
- "kairo_graph_query", "kairo_graph_path", "kairo_context_summary"
27
+ "kairo_graph_query", "kairo_graph_path", "kairo_context_summary", "kairo_fleet",
28
+ ...KAIRO_MCP_WRITE_TOOLS
19
29
  ]);
20
30
 
21
31
  const empty = z.object({});
@@ -30,7 +40,8 @@ export const mcpSchemas = Object.freeze({
30
40
  graph: z.string().min(1), question: z.string().min(1),
31
41
  budget: z.number().int().min(1).max(8000).default(2000)
32
42
  }),
33
- graphPath: z.object({ graph: z.string().min(1), from: z.string().min(1), to: z.string().min(1) })
43
+ graphPath: z.object({ graph: z.string().min(1), from: z.string().min(1), to: z.string().min(1) }),
44
+ workSnapshotPublish: workSnapshotPublishSchema
34
45
  });
35
46
 
36
47
  const CODE_RE = /^(?:[a-z][a-z0-9_]{0,48}|status=\d+)$/;
@@ -94,7 +105,11 @@ function graphEnvelope(result) {
94
105
 
95
106
  export function createToolHandlers(deps = {}) {
96
107
  const homeDir = deps.homeDir ?? resolveHomeDir();
97
- const cwd = deps.cwd ?? process.cwd();
108
+ // Cursor may spawn MCP under $HOME; prefer VSCODE_CWD / WORKSPACE_FOLDER_PATHS.
109
+ const cwd = resolveMcpWorkspaceCwd({
110
+ cwd: deps.cwd,
111
+ env: deps.env ?? process.env
112
+ });
98
113
  const listRuns = deps.listRuns ?? ((o) => listRunRecords(homeDir, o));
99
114
  const listAlertRows = deps.listAlerts ?? ((o) => listAlerts({ homeDir, ...o }));
100
115
  const listReviews = deps.listReviews ?? (() => listReviewReceipts({ homeDir, limit: 20 }));
@@ -117,6 +132,7 @@ export function createToolHandlers(deps = {}) {
117
132
  observabilityContext: { cwd, homeDir, workspaceRoot: cwd, headSha: requestHead() }
118
133
  }));
119
134
  const gentleProbe = deps.probeGentle ?? ((ctx) => probeGentle(ctx));
135
+ const fleetProbe = deps.buildFleet ?? ((ctx) => buildFleetReport(ctx));
120
136
  const graphOp = deps.runGraphifyOp ?? runGraphifyOp;
121
137
  const gOpts = () => ({
122
138
  cwd, workspaceRoot: cwd, headSha: requestHead(), whichCommand: deps.whichCommand,
@@ -200,7 +216,36 @@ export function createToolHandlers(deps = {}) {
200
216
  } catch {
201
217
  return soft("degraded", { signals: null, engram: null, links: [], alertsCount: null, nextSafeAction: null });
202
218
  }
203
- }
219
+ },
220
+ async kairo_fleet() {
221
+ try {
222
+ const report = await fleetProbe({ homeDir });
223
+ return mcpResult({
224
+ ok: true,
225
+ code: "ok",
226
+ data: {
227
+ kind: report?.kind ?? "declared",
228
+ note: report?.note ?? null,
229
+ orchestratorAuthority: report?.orchestratorAuthority ?? null,
230
+ fleets: Array.isArray(report?.fleets) ? report.fleets : [],
231
+ activity: report?.activity ?? null,
232
+ generatedAt: report?.generatedAt ?? null
233
+ }
234
+ });
235
+ } catch {
236
+ return soft("degraded", {
237
+ kind: "declared", fleets: [], activity: null, note: null, orchestratorAuthority: null
238
+ });
239
+ }
240
+ },
241
+ kairo_publish_work_snapshot: createPublishWorkSnapshotHandler({
242
+ homeDir,
243
+ cwd,
244
+ now: deps.now,
245
+ writeAtomic: deps.writeAtomic,
246
+ publishWorkSnapshot: deps.publishWorkSnapshot,
247
+ mcpResult
248
+ })
204
249
  };
205
250
  }
206
251
 
@@ -213,7 +258,13 @@ export function registerKairoMcpTools(registerTool, deps = {}) {
213
258
  ["kairo_gentle_status", "Gentle probe / companion gentle signal", empty],
214
259
  ["kairo_graph_query", "Read-only Graphify query", mcpSchemas.graphQuery],
215
260
  ["kairo_graph_path", "Read-only Graphify path", mcpSchemas.graphPath],
216
- ["kairo_context_summary", "Companion + soft links + alerts count", empty]
261
+ ["kairo_context_summary", "Companion + soft links + alerts count", empty],
262
+ ["kairo_fleet", "Declared fleet topology + OpenCode live activity", empty],
263
+ [
264
+ "kairo_publish_work_snapshot",
265
+ "Publish kairo.work-snapshot/v1 for the runtime workspace (enrolls conversation)",
266
+ mcpSchemas.workSnapshotPublish
267
+ ]
217
268
  ]) registerTool(name, { description, inputSchema }, h[name]);
218
269
  return h;
219
270
  }
@@ -0,0 +1,51 @@
1
+ /**
2
+ * Resolve the workspace path for Kairo MCP identity.
3
+ *
4
+ * Cursor IDE launches global `mcpServers.kairo` with process cwd = $HOME even
5
+ * when the entry sets `cwd: "."`. It does inject the open folder via
6
+ * VSCODE_CWD / WORKSPACE_FOLDER_PATHS — prefer those over process.cwd().
7
+ */
8
+ import { resolve } from "node:path";
9
+ import { canonicalizeProjectPath } from "../next/project-key.js";
10
+
11
+ function firstNonEmpty(value) {
12
+ if (typeof value !== "string") return null;
13
+ const trimmed = value.trim();
14
+ return trimmed ? trimmed : null;
15
+ }
16
+
17
+ /**
18
+ * Parse Cursor/VS Code workspace folder env into an ordered path list.
19
+ * WORKSPACE_FOLDER_PATHS uses commas when Cursor opens multiple roots.
20
+ */
21
+ export function parseWorkspaceFolderPaths(raw) {
22
+ const text = firstNonEmpty(raw);
23
+ if (!text) return [];
24
+ return text
25
+ .split(",")
26
+ .map((part) => part.trim())
27
+ .filter(Boolean);
28
+ }
29
+
30
+ /**
31
+ * @param {{ cwd?: string, env?: NodeJS.ProcessEnv }} [options]
32
+ * @returns {string} absolute workspace path
33
+ */
34
+ export function resolveMcpWorkspaceCwd({
35
+ cwd,
36
+ env = process.env
37
+ } = {}) {
38
+ let chosen;
39
+ if (typeof cwd === "string" && cwd.trim()) {
40
+ chosen = resolve(cwd.trim());
41
+ } else {
42
+ const fromFolders = parseWorkspaceFolderPaths(env.WORKSPACE_FOLDER_PATHS);
43
+ if (fromFolders.length > 0) {
44
+ chosen = resolve(fromFolders[0]);
45
+ } else {
46
+ const vscodeCwd = firstNonEmpty(env.VSCODE_CWD);
47
+ chosen = vscodeCwd ? resolve(vscodeCwd) : resolve(process.cwd());
48
+ }
49
+ }
50
+ return canonicalizeProjectPath(chosen);
51
+ }
@@ -0,0 +1,89 @@
1
+ /**
2
+ * Managed Cursor rule: instruct agents to publish kairo.work-snapshot/v1.
3
+ */
4
+ import { mkdir, readFile, writeFile, rename, copyFile } from "node:fs/promises";
5
+ import { dirname, join } from "node:path";
6
+ import { homedir } from "node:os";
7
+ import { randomBytes } from "node:crypto";
8
+
9
+ export const WORK_SNAPSHOT_RULE_FILENAME = "kairo-work-snapshot.mdc";
10
+
11
+ export function resolveCursorRulesDir(homeDir = homedir()) {
12
+ return join(homeDir, ".cursor", "rules");
13
+ }
14
+
15
+ export function resolveWorkSnapshotRulePath(homeDir = homedir()) {
16
+ return join(resolveCursorRulesDir(homeDir), WORK_SNAPSHOT_RULE_FILENAME);
17
+ }
18
+
19
+ export const WORK_SNAPSHOT_RULE_BODY = `# Kairo work snapshot
20
+
21
+ After each significant turn, publish the true work state with MCP \`kairo_publish_work_snapshot\`:
22
+
23
+ - Required: \`conversationId\`, \`provider\` (\`cursor\`), \`goal\`, \`now\`, \`next\`
24
+ - Optional: \`progress\` (≤3), \`blockers\`, \`delegations\` (only real ones)
25
+ - Workspace identity is derived by Kairo from the runtime — never send \`projectKey\`, paths, or \`cwd\`
26
+ - Never invent work. Never send prompts, transcripts, or tool dumps
27
+ - Reuse the same \`conversationId\` for later turns in this chat
28
+ `;
29
+
30
+ export function buildWorkSnapshotRuleFile() {
31
+ return `---
32
+ description: Publish Kairo work snapshot after significant Cursor turns
33
+ alwaysApply: true
34
+ ---
35
+
36
+ ${WORK_SNAPSHOT_RULE_BODY}
37
+ `;
38
+ }
39
+
40
+ async function writeAtomicText(targetPath, text, deps = {}) {
41
+ const write = deps.writeFileFn ?? writeFile;
42
+ const renameFn = deps.renameFn ?? rename;
43
+ const tempPath = join(
44
+ dirname(targetPath),
45
+ `.${WORK_SNAPSHOT_RULE_FILENAME}.${process.pid}.${randomBytes(4).toString("hex")}.tmp`
46
+ );
47
+ await write(tempPath, text, "utf8");
48
+ await renameFn(tempPath, targetPath);
49
+ }
50
+
51
+ /**
52
+ * Plan or apply the managed work-snapshot rule under ~/.cursor/rules/.
53
+ */
54
+ export async function ensureWorkSnapshotRule({
55
+ homeDir = homedir(),
56
+ apply = false,
57
+ now = () => Date.now(),
58
+ readFileFn = readFile,
59
+ mkdirFn = mkdir,
60
+ copyFileFn = copyFile,
61
+ writeFileFn = writeFile,
62
+ renameFn = rename
63
+ } = {}) {
64
+ const path = resolveWorkSnapshotRulePath(homeDir);
65
+ const desired = buildWorkSnapshotRuleFile();
66
+ let existing = null;
67
+ try {
68
+ existing = await readFileFn(path, "utf8");
69
+ } catch (error) {
70
+ if (error?.code !== "ENOENT") throw error;
71
+ }
72
+ const wouldWrite = existing !== desired;
73
+ const backupPath = wouldWrite && existing != null
74
+ ? `${path}.kairo-backup.${now()}`
75
+ : null;
76
+
77
+ if (!apply) {
78
+ return { path, wouldWrite, wrote: false, backupPath: null };
79
+ }
80
+
81
+ await mkdirFn(dirname(path), { recursive: true });
82
+ if (wouldWrite && existing != null) {
83
+ await copyFileFn(path, backupPath);
84
+ }
85
+ if (wouldWrite) {
86
+ await writeAtomicText(path, desired, { writeFileFn, renameFn });
87
+ }
88
+ return { path, wouldWrite, wrote: wouldWrite, backupPath };
89
+ }
@@ -0,0 +1,49 @@
1
+ import * as z from "zod";
2
+ import { publishWorkSnapshot } from "../next/publish-work-snapshot.js";
3
+
4
+ export const workSnapshotPublishSchema = z.object({
5
+ conversationId: z.string().min(1).max(160),
6
+ provider: z.enum(["cursor", "codex", "claude", "opencode", "pi", "other"]),
7
+ goal: z.string().min(1).max(160),
8
+ now: z.string().min(1).max(240),
9
+ next: z.string().min(1).max(240),
10
+ progress: z.array(z.string().max(160)).max(3).optional(),
11
+ blockers: z.array(z.string().max(200)).max(12).optional(),
12
+ delegations: z.array(z.object({
13
+ workId: z.string().max(64).optional(),
14
+ title: z.string().max(160).optional(),
15
+ role: z.enum(["orchestrator", "worker"]).optional(),
16
+ state: z.enum(["assigned", "working", "blocked", "completed", "failed"]).optional()
17
+ }).strict()).max(12).optional()
18
+ }).strict();
19
+
20
+ export function createPublishWorkSnapshotHandler(deps = {}) {
21
+ const publishSnapshot = deps.publishWorkSnapshot ?? ((input) => publishWorkSnapshot(input, {
22
+ homeDir: deps.homeDir,
23
+ cwd: deps.cwd,
24
+ now: deps.now,
25
+ writeAtomic: deps.writeAtomic
26
+ }));
27
+ const toResult = deps.mcpResult;
28
+ if (typeof toResult !== "function") {
29
+ throw new Error("mcpResult dependency is required");
30
+ }
31
+
32
+ return async function kairo_publish_work_snapshot(args = {}) {
33
+ try {
34
+ const result = await publishSnapshot(args);
35
+ return toResult({
36
+ ok: Boolean(result?.ok),
37
+ code: result?.code ?? "publish_failed",
38
+ data: result?.data ?? null,
39
+ diagnostics: result?.diagnostics ?? [],
40
+ isError: !result?.ok
41
+ });
42
+ } catch {
43
+ return toResult({
44
+ ok: false, code: "publish_failed", data: null,
45
+ diagnostics: ["publish_failed"], isError: true
46
+ });
47
+ }
48
+ };
49
+ }