@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.
- package/CHANGELOG.md +84 -0
- package/bin/kairo-runtime.js +0 -0
- package/bin/kairo.js +0 -0
- package/package.json +1 -1
- package/src/cli.js +172 -8
- package/src/global/check-resolutions.js +31 -0
- package/src/global/cli-help.js +19 -2
- package/src/global/component-ecosystem-checks.js +2 -0
- package/src/global/component-integration-cli.js +29 -10
- package/src/global/components-resolve-cli.js +246 -0
- package/src/global/connection-actions.js +147 -0
- package/src/global/connections.js +269 -0
- package/src/global/fleet-configure-plan.js +123 -0
- package/src/global/fleet-configure.js +303 -0
- package/src/global/fleet-models.js +188 -0
- package/src/global/fleet-set.js +219 -0
- package/src/global/fleet-shared.js +38 -0
- package/src/global/ink/cockpit-controller.js +1 -1
- package/src/global/ink/cockpit-models.js +4 -1
- package/src/global/ink/orchestrator-app.js +21 -2
- package/src/global/ink/ux/live-overview.js +5 -11
- package/src/global/ink/ux/overview-needs.js +1 -1
- package/src/global/integrations/engram-evidence.js +7 -2
- package/src/global/integrations/sdd-apply.js +17 -7
- package/src/global/integrations/sdd-evidence.js +22 -3
- package/src/global/integrations/sdd-plan.js +21 -3
- package/src/global/integrations/sdd-resolutions.js +73 -0
- package/src/global/integrations/sdd-state.js +69 -0
- package/src/global/integrations/sdd-verify.js +9 -4
- package/src/global/mcp/kairo-mcp.js +56 -5
- package/src/global/mcp/resolve-mcp-workspace.js +51 -0
- package/src/global/mcp/work-snapshot-rule.js +89 -0
- package/src/global/mcp/work-snapshot-tool.js +49 -0
- package/src/global/mcp-install.js +239 -0
- package/src/global/next/next-cli.js +35 -0
- package/src/global/next/next-report.js +145 -0
- package/src/global/next/project-key.js +36 -0
- package/src/global/next/publish-work-snapshot.js +116 -0
- package/src/global/next/work-enroll.js +91 -0
- package/src/global/next/work-snapshot.js +216 -0
- package/src/global/observability/fleet-activity.js +197 -0
- package/src/global/observability/fleet-models-catalog.js +137 -0
- package/src/global/observability/fleet-platforms.js +166 -0
- package/src/global/observability/fleet-probe.js +229 -0
- package/src/global/paths.js +2 -1
|
@@ -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) =>
|
|
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
|
-
|
|
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
|
+
}
|
|
@@ -0,0 +1,239 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Register Kairo as an MCP server in a client config (Cursor v1).
|
|
3
|
+
* Consent-gated: plan by default, --yes applies. Atomic write + backup.
|
|
4
|
+
*/
|
|
5
|
+
import { copyFile, mkdir, readFile } from "node:fs/promises";
|
|
6
|
+
import { dirname } from "node:path";
|
|
7
|
+
import { writeAtomicJson } from "./runtime/write-atomic-json.js";
|
|
8
|
+
import {
|
|
9
|
+
MCP_CLIENTS,
|
|
10
|
+
detectAgentMcpRegistration,
|
|
11
|
+
resolveMcpConfigPath
|
|
12
|
+
} from "./connections.js";
|
|
13
|
+
import { printJson } from "./json-output.js";
|
|
14
|
+
import { commandHeader } from "./brand/index.js";
|
|
15
|
+
import { formatCliCommand } from "./brand/cli.js";
|
|
16
|
+
import {
|
|
17
|
+
buildWorkSnapshotRuleFile,
|
|
18
|
+
ensureWorkSnapshotRule,
|
|
19
|
+
resolveWorkSnapshotRulePath
|
|
20
|
+
} from "./mcp/work-snapshot-rule.js";
|
|
21
|
+
import { resolveHomeDir } from "./paths.js";
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Cursor MCP entry. `cwd: "."` is best-effort for clients that honor it.
|
|
25
|
+
* Cursor IDE 3.15+ may still spawn under $HOME — runtime identity then uses
|
|
26
|
+
* VSCODE_CWD / WORKSPACE_FOLDER_PATHS (see resolve-mcp-workspace.js).
|
|
27
|
+
*/
|
|
28
|
+
export const KAIRO_MCP_SERVER_ENTRY = Object.freeze({
|
|
29
|
+
command: "kairo",
|
|
30
|
+
args: Object.freeze(["mcp"]),
|
|
31
|
+
cwd: "."
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
export function buildKairoMcpServerEntry() {
|
|
35
|
+
return {
|
|
36
|
+
command: KAIRO_MCP_SERVER_ENTRY.command,
|
|
37
|
+
args: [...KAIRO_MCP_SERVER_ENTRY.args],
|
|
38
|
+
cwd: KAIRO_MCP_SERVER_ENTRY.cwd
|
|
39
|
+
};
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function backupPath(configPath, stamp = Date.now()) {
|
|
43
|
+
return `${configPath}.kairo-backup.${stamp}`;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export function buildMcpInstallPlan({
|
|
47
|
+
client = "cursor",
|
|
48
|
+
homeDir = resolveHomeDir(),
|
|
49
|
+
existing = null,
|
|
50
|
+
alreadyConnected = false
|
|
51
|
+
} = {}) {
|
|
52
|
+
const clientMeta = MCP_CLIENTS[client] ?? MCP_CLIENTS.cursor;
|
|
53
|
+
const path = resolveMcpConfigPath(client, { homeDir });
|
|
54
|
+
const entry = buildKairoMcpServerEntry();
|
|
55
|
+
const next = {
|
|
56
|
+
...(existing && typeof existing === "object" ? existing : {}),
|
|
57
|
+
mcpServers: {
|
|
58
|
+
...((existing && typeof existing === "object" && existing.mcpServers) || {}),
|
|
59
|
+
kairo: entry
|
|
60
|
+
}
|
|
61
|
+
};
|
|
62
|
+
return {
|
|
63
|
+
client: clientMeta.id,
|
|
64
|
+
clientLabel: clientMeta.label,
|
|
65
|
+
path,
|
|
66
|
+
alreadyConnected,
|
|
67
|
+
wouldWrite: !alreadyConnected || JSON.stringify(existing?.mcpServers?.kairo)
|
|
68
|
+
!== JSON.stringify(next.mcpServers.kairo),
|
|
69
|
+
entry: next.mcpServers.kairo,
|
|
70
|
+
next,
|
|
71
|
+
backupPath: backupPath(path),
|
|
72
|
+
rulePath: resolveWorkSnapshotRulePath(homeDir),
|
|
73
|
+
ruleBody: buildWorkSnapshotRuleFile(),
|
|
74
|
+
note: alreadyConnected
|
|
75
|
+
? "Kairo MCP already registered; --yes rewrites the entry if it drifted."
|
|
76
|
+
: `Will add mcpServers.kairo to ${path}. Reload Cursor MCP after apply.`
|
|
77
|
+
};
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
async function readExistingConfig(path, readFileFn) {
|
|
81
|
+
try {
|
|
82
|
+
const raw = await readFileFn(path, "utf8");
|
|
83
|
+
return JSON.parse(raw);
|
|
84
|
+
} catch (error) {
|
|
85
|
+
if (error?.code === "ENOENT") return null;
|
|
86
|
+
throw error;
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* Plan (default) or apply (--yes) Cursor MCP registration for Kairo.
|
|
92
|
+
*/
|
|
93
|
+
export async function runMcpInstall({
|
|
94
|
+
client = "cursor",
|
|
95
|
+
yes = false,
|
|
96
|
+
json = false,
|
|
97
|
+
homeDir = resolveHomeDir(),
|
|
98
|
+
readFileFn = readFile,
|
|
99
|
+
mkdirFn = mkdir,
|
|
100
|
+
copyFileFn = copyFile,
|
|
101
|
+
writeAtomicJsonFn = writeAtomicJson,
|
|
102
|
+
writeFileFn = null,
|
|
103
|
+
renameFn = null,
|
|
104
|
+
ensureRule = ensureWorkSnapshotRule,
|
|
105
|
+
detectAgent = detectAgentMcpRegistration,
|
|
106
|
+
now = () => Date.now()
|
|
107
|
+
} = {}) {
|
|
108
|
+
if (client !== "cursor") {
|
|
109
|
+
throw new Error(
|
|
110
|
+
`Unsupported MCP client "${client}". v1 supports --client=cursor only.`
|
|
111
|
+
);
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
const detection = await detectAgent({ client, homeDir, readFileFn });
|
|
115
|
+
const path = detection.path ?? resolveMcpConfigPath(client, { homeDir });
|
|
116
|
+
const existing = await readExistingConfig(path, readFileFn);
|
|
117
|
+
const plan = buildMcpInstallPlan({
|
|
118
|
+
client,
|
|
119
|
+
homeDir,
|
|
120
|
+
existing,
|
|
121
|
+
alreadyConnected: detection.connected === true
|
|
122
|
+
});
|
|
123
|
+
plan.backupPath = backupPath(path, now());
|
|
124
|
+
const rulePlan = await ensureRule({
|
|
125
|
+
homeDir,
|
|
126
|
+
apply: false,
|
|
127
|
+
now,
|
|
128
|
+
readFileFn,
|
|
129
|
+
mkdirFn,
|
|
130
|
+
copyFileFn,
|
|
131
|
+
writeFileFn: writeFileFn ?? undefined,
|
|
132
|
+
renameFn: renameFn ?? undefined
|
|
133
|
+
});
|
|
134
|
+
plan.ruleWouldWrite = rulePlan.wouldWrite === true;
|
|
135
|
+
plan.rulePath = rulePlan.path;
|
|
136
|
+
|
|
137
|
+
if (!yes) {
|
|
138
|
+
const payload = {
|
|
139
|
+
ok: true,
|
|
140
|
+
applied: false,
|
|
141
|
+
plan: {
|
|
142
|
+
path: plan.path,
|
|
143
|
+
client: plan.client,
|
|
144
|
+
alreadyConnected: plan.alreadyConnected,
|
|
145
|
+
wouldWrite: plan.wouldWrite,
|
|
146
|
+
entry: plan.entry,
|
|
147
|
+
rulePath: plan.rulePath,
|
|
148
|
+
ruleWouldWrite: plan.ruleWouldWrite,
|
|
149
|
+
note: plan.note,
|
|
150
|
+
applyWith: formatCliCommand("mcp install --yes")
|
|
151
|
+
}
|
|
152
|
+
};
|
|
153
|
+
if (json) {
|
|
154
|
+
printJson(payload);
|
|
155
|
+
} else {
|
|
156
|
+
console.log(commandHeader("MCP install"));
|
|
157
|
+
console.log(`Client · ${plan.clientLabel}`);
|
|
158
|
+
console.log(`Path · ${plan.path}`);
|
|
159
|
+
console.log(`Entry · ${JSON.stringify(plan.entry)}`);
|
|
160
|
+
console.log(`Rule · ${plan.rulePath}${plan.ruleWouldWrite ? " (will write)" : " (up to date)"}`);
|
|
161
|
+
console.log(plan.note);
|
|
162
|
+
console.log(`Apply · ${formatCliCommand("mcp install --yes")}`);
|
|
163
|
+
}
|
|
164
|
+
return payload;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
await mkdirFn(dirname(path), { recursive: true });
|
|
168
|
+
if (existing != null && plan.wouldWrite) {
|
|
169
|
+
await copyFileFn(path, plan.backupPath);
|
|
170
|
+
}
|
|
171
|
+
if (plan.wouldWrite) {
|
|
172
|
+
await writeAtomicJsonFn(path, plan.next);
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
const ruleReceipt = await ensureRule({
|
|
176
|
+
homeDir,
|
|
177
|
+
apply: true,
|
|
178
|
+
now,
|
|
179
|
+
readFileFn,
|
|
180
|
+
mkdirFn,
|
|
181
|
+
copyFileFn,
|
|
182
|
+
writeFileFn: writeFileFn ?? undefined,
|
|
183
|
+
renameFn: renameFn ?? undefined
|
|
184
|
+
});
|
|
185
|
+
|
|
186
|
+
const receipt = {
|
|
187
|
+
ok: true,
|
|
188
|
+
applied: true,
|
|
189
|
+
path,
|
|
190
|
+
backupPath: existing != null && plan.wouldWrite ? plan.backupPath : null,
|
|
191
|
+
entry: plan.entry,
|
|
192
|
+
client: plan.client,
|
|
193
|
+
rulePath: ruleReceipt.path,
|
|
194
|
+
ruleWrote: ruleReceipt.wrote === true,
|
|
195
|
+
ruleBackupPath: ruleReceipt.backupPath,
|
|
196
|
+
note: "Reload Cursor MCP (Command Palette → MCP: Restart) to load kairo_* tools."
|
|
197
|
+
};
|
|
198
|
+
|
|
199
|
+
if (json) {
|
|
200
|
+
printJson(receipt);
|
|
201
|
+
} else {
|
|
202
|
+
console.log(commandHeader("MCP install"));
|
|
203
|
+
if (plan.wouldWrite) console.log(`Wrote · ${path}`);
|
|
204
|
+
else console.log(`MCP · up to date (${path})`);
|
|
205
|
+
if (receipt.backupPath) console.log(`Backup · ${receipt.backupPath}`);
|
|
206
|
+
console.log(`Rule · ${receipt.rulePath}${receipt.ruleWrote ? " (wrote)" : " (up to date)"}`);
|
|
207
|
+
if (receipt.ruleBackupPath) console.log(`Rule backup · ${receipt.ruleBackupPath}`);
|
|
208
|
+
console.log(receipt.note);
|
|
209
|
+
}
|
|
210
|
+
return receipt;
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
export function resolveMcpServeCwd(options = {}) {
|
|
214
|
+
return options.cwdExplicit === false ? undefined : options.cwd;
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
export async function runMcpCli(options = {}) {
|
|
218
|
+
const action = options.mcpAction ?? "serve";
|
|
219
|
+
if (action === "install") {
|
|
220
|
+
return runMcpInstall({
|
|
221
|
+
client: options.mcpClient ?? "cursor",
|
|
222
|
+
yes: options.yes === true,
|
|
223
|
+
json: options.json === true,
|
|
224
|
+
homeDir: options.homeDir ?? resolveHomeDir()
|
|
225
|
+
});
|
|
226
|
+
}
|
|
227
|
+
if (action === "serve" || action == null) {
|
|
228
|
+
const { runKairoMcp } = await import("./mcp/kairo-mcp.js");
|
|
229
|
+
return runKairoMcp({
|
|
230
|
+
cwd: resolveMcpServeCwd(options),
|
|
231
|
+
packageRoot: options.packageRoot,
|
|
232
|
+
packageName: options.packageName,
|
|
233
|
+
version: options.version
|
|
234
|
+
});
|
|
235
|
+
}
|
|
236
|
+
throw new Error(
|
|
237
|
+
`Unknown mcp action "${action}". Use: ${formatCliCommand("mcp")} or ${formatCliCommand("mcp install [--yes]")}`
|
|
238
|
+
);
|
|
239
|
+
}
|