@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
@@ -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
+ }
@@ -0,0 +1,35 @@
1
+ import { resolveHomeDir } from "../paths.js";
2
+ import { printJson } from "../json-output.js";
3
+ import { commandHeader } from "../brand/index.js";
4
+ import { buildNextReport } from "./next-report.js";
5
+
6
+ export async function runNextCli(options = {}) {
7
+ const report = await buildNextReport({
8
+ homeDir: options.homeDir ?? resolveHomeDir(),
9
+ cwd: options.cwd ?? process.cwd(),
10
+ provider: options.provider ?? "cursor",
11
+ client: options.mcpClient ?? options.client ?? "cursor"
12
+ });
13
+
14
+ if (options.json) {
15
+ printJson(report);
16
+ return report;
17
+ }
18
+
19
+ console.log(commandHeader("Next"));
20
+ console.log(`Integration · ${report.integration.state}`);
21
+ if (report.goal) console.log(`Goal · ${report.goal}`);
22
+ if (report.now) console.log(`Now · ${report.now}`);
23
+ if (report.next) console.log(`Next · ${report.next}`);
24
+ if (report.blockers?.length) {
25
+ console.log("Blockers");
26
+ for (const item of report.blockers) console.log(`- ${item}`);
27
+ }
28
+ if (!report.goal && !report.now && !report.next) {
29
+ console.log("No published work snapshot for this workspace.");
30
+ }
31
+ if (report.integration.showRepair) {
32
+ console.log("Repair · MCP configuration looks broken. Re-run: kairo mcp install --yes");
33
+ }
34
+ return report;
35
+ }
@@ -0,0 +1,145 @@
1
+ /**
2
+ * kairo.next/v1 — selected work snapshot + honest integration state for the panel.
3
+ * Never invents Goal/Progress/Now/Blockers/Next when data is absent or corrupt.
4
+ */
5
+ import { detectAgentMcpRegistration } from "../connections.js";
6
+ import { resolveHomeDir } from "../paths.js";
7
+ import { loadEnrollment } from "./work-enroll.js";
8
+ import {
9
+ listWorkSnapshots,
10
+ snapshotIsComplete
11
+ } from "./work-snapshot.js";
12
+
13
+ export const NEXT_SCHEMA = "kairo.next/v1";
14
+
15
+ export const INTEGRATION_STATE = Object.freeze({
16
+ MISSING: "missing",
17
+ READY: "ready",
18
+ ACTIVE: "active",
19
+ BROKEN: "broken"
20
+ });
21
+
22
+ function teamFromDelegations(delegations) {
23
+ if (!Array.isArray(delegations) || delegations.length === 0) return undefined;
24
+ const members = delegations
25
+ .map((row) => {
26
+ if (!row || typeof row !== "object") return null;
27
+ const title = typeof row.title === "string" ? row.title.slice(0, 160) : null;
28
+ const workId = typeof row.workId === "string" ? row.workId : null;
29
+ if (!title && !workId) return null;
30
+ return {
31
+ ...(workId ? { workId } : {}),
32
+ ...(title ? { title } : {}),
33
+ ...(row.role ? { role: row.role } : {}),
34
+ ...(row.state ? { state: row.state } : {})
35
+ };
36
+ })
37
+ .filter(Boolean);
38
+ return members.length > 0 ? { members } : undefined;
39
+ }
40
+
41
+ export function resolveIntegrationState({ mcp, hasUsableSnapshot }) {
42
+ if (mcp?.state === "error") {
43
+ return {
44
+ state: INTEGRATION_STATE.BROKEN,
45
+ mcpConnected: false,
46
+ showRepair: true,
47
+ detail: mcp.detail ?? "MCP configuration could not be read."
48
+ };
49
+ }
50
+ if (!mcp?.connected) {
51
+ return {
52
+ state: INTEGRATION_STATE.MISSING,
53
+ mcpConnected: false,
54
+ showRepair: false,
55
+ detail: mcp?.detail ?? "Kairo MCP is not registered."
56
+ };
57
+ }
58
+ if (hasUsableSnapshot) {
59
+ return {
60
+ state: INTEGRATION_STATE.ACTIVE,
61
+ mcpConnected: true,
62
+ showRepair: false,
63
+ detail: "MCP connected with a usable work snapshot."
64
+ };
65
+ }
66
+ return {
67
+ state: INTEGRATION_STATE.READY,
68
+ mcpConnected: true,
69
+ showRepair: false,
70
+ detail: "MCP connected; waiting for a published work snapshot."
71
+ };
72
+ }
73
+
74
+ function viewFromSnapshot(snapshot) {
75
+ if (!snapshot) {
76
+ return {
77
+ goal: null,
78
+ progress: [],
79
+ now: null,
80
+ blockers: [],
81
+ next: null,
82
+ conversationId: null,
83
+ updatedAt: null
84
+ };
85
+ }
86
+ const team = teamFromDelegations(snapshot.delegations);
87
+ return {
88
+ goal: snapshot.goal ?? null,
89
+ progress: Array.isArray(snapshot.progress) ? snapshot.progress : [],
90
+ now: snapshot.now ?? null,
91
+ blockers: Array.isArray(snapshot.blockers) ? snapshot.blockers : [],
92
+ next: snapshot.next ?? null,
93
+ conversationId: snapshot.conversationId ?? null,
94
+ updatedAt: snapshot.updatedAt ?? null,
95
+ ...(team ? { team } : {})
96
+ };
97
+ }
98
+
99
+ /**
100
+ * Build the next report for the runtime workspace (deps.cwd / process.cwd()).
101
+ */
102
+ export async function buildNextReport({
103
+ homeDir = resolveHomeDir(),
104
+ cwd = process.cwd(),
105
+ provider = "cursor",
106
+ client = "cursor",
107
+ detectAgent = detectAgentMcpRegistration,
108
+ listSnapshots = listWorkSnapshots,
109
+ loadEnrollmentFn = loadEnrollment
110
+ } = {}) {
111
+ const mcp = await detectAgent({ client, homeDir });
112
+ const listed = await listSnapshots(homeDir, cwd);
113
+ // Prefer newest complete snapshot — incomplete records must not hide valid work.
114
+ const snapshot = listed.find((row) => snapshotIsComplete(row)) ?? null;
115
+ const complete = snapshotIsComplete(snapshot);
116
+ const integrationCore = resolveIntegrationState({
117
+ mcp,
118
+ hasUsableSnapshot: complete
119
+ });
120
+ const view = viewFromSnapshot(snapshot);
121
+
122
+ let enrolled = false;
123
+ if (view.conversationId) {
124
+ const enrollment = await loadEnrollmentFn(homeDir, cwd, view.conversationId);
125
+ enrolled = Boolean(enrollment);
126
+ }
127
+
128
+ return {
129
+ schema: NEXT_SCHEMA,
130
+ ok: integrationCore.state !== INTEGRATION_STATE.BROKEN,
131
+ ...view,
132
+ integration: {
133
+ state: integrationCore.state,
134
+ provider,
135
+ client,
136
+ mcpConnected: integrationCore.mcpConnected,
137
+ enrolled,
138
+ showRepair: integrationCore.showRepair === true,
139
+ detail: integrationCore.detail
140
+ },
141
+ diagnostics: integrationCore.state === INTEGRATION_STATE.BROKEN
142
+ ? ["integration_broken"]
143
+ : []
144
+ };
145
+ }
@@ -0,0 +1,36 @@
1
+ import { createHash } from "node:crypto";
2
+ import { realpathSync } from "node:fs";
3
+ import { resolve, sep } from "node:path";
4
+
5
+ /**
6
+ * Canonical absolute path for workspace identity.
7
+ * On macOS `/tmp` → `/private/tmp`; without realpath those alias to different keys.
8
+ * Walks up to an existing ancestor when the leaf path does not exist yet.
9
+ */
10
+ export function canonicalizeProjectPath(projectPath) {
11
+ const resolved = resolve(String(projectPath ?? ""));
12
+ try {
13
+ return realpathSync(resolved);
14
+ } catch {
15
+ const parts = resolved.split(sep);
16
+ for (let i = parts.length - 1; i > 0; i -= 1) {
17
+ const prefix = parts.slice(0, i).join(sep) || sep;
18
+ try {
19
+ const realPrefix = realpathSync(prefix);
20
+ return resolve(realPrefix, ...parts.slice(i));
21
+ } catch {
22
+ // keep walking up
23
+ }
24
+ }
25
+ return resolved;
26
+ }
27
+ }
28
+
29
+ /**
30
+ * Stable workspace key derived from an absolute project path.
31
+ * Agents must not supply this — callers compute it from runtime cwd/workspace.
32
+ */
33
+ export function projectKeyForPath(projectPath) {
34
+ const normalized = canonicalizeProjectPath(projectPath).toLowerCase();
35
+ return createHash("sha256").update(normalized).digest("hex").slice(0, 16);
36
+ }
@@ -0,0 +1,116 @@
1
+ /**
2
+ * Publish kairo.work-snapshot/v1 from runtime-derived workspace identity.
3
+ */
4
+ import { resolveHomeDir } from "../paths.js";
5
+ import { projectKeyForPath } from "./project-key.js";
6
+ import { enrollConversation } from "./work-enroll.js";
7
+ import {
8
+ assertNoWorkPrivatePayload,
9
+ createWorkSnapshot,
10
+ isIgnoredSmokeConversationId,
11
+ saveWorkSnapshot,
12
+ selectLatestWorkSnapshot,
13
+ snapshotIsComplete
14
+ } from "./work-snapshot.js";
15
+
16
+ const FORBIDDEN = Object.freeze([
17
+ "projectKey", "projectPath", "cwd", "homeDir", "workspaceRoot"
18
+ ]);
19
+
20
+ function fail(code) {
21
+ return { ok: false, code, data: null, diagnostics: [code] };
22
+ }
23
+
24
+ /**
25
+ * Validate → enroll → atomic snapshot write.
26
+ * Workspace comes only from deps.cwd / process.cwd().
27
+ */
28
+ export async function publishWorkSnapshot(input = {}, deps = {}) {
29
+ try {
30
+ assertNoWorkPrivatePayload(input);
31
+ } catch {
32
+ return fail("private_payload");
33
+ }
34
+ for (const key of FORBIDDEN) {
35
+ if (Object.prototype.hasOwnProperty.call(input, key)) {
36
+ return fail("forbidden_identity_fields");
37
+ }
38
+ }
39
+
40
+ const conversationId = typeof input.conversationId === "string"
41
+ ? input.conversationId.trim()
42
+ : "";
43
+ if (!conversationId) return fail("conversation_required");
44
+ if (isIgnoredSmokeConversationId(conversationId)) return fail("ignored_conversation");
45
+
46
+ const provider = typeof input.provider === "string" && input.provider.trim()
47
+ ? input.provider.trim().slice(0, 40)
48
+ : null;
49
+ if (!provider) return fail("provider_required");
50
+
51
+ const draft = createWorkSnapshot({
52
+ goal: input.goal,
53
+ progress: input.progress,
54
+ now: input.now,
55
+ blockers: input.blockers,
56
+ next: input.next,
57
+ delegations: input.delegations,
58
+ conversationId,
59
+ provider
60
+ });
61
+ if (!snapshotIsComplete(draft)) return fail("incomplete_snapshot");
62
+
63
+ const homeDir = deps.homeDir ?? resolveHomeDir();
64
+ const projectPath = deps.cwd ?? process.cwd();
65
+ const projectKey = projectKeyForPath(projectPath);
66
+ const io = { now: deps.now, writeAtomic: deps.writeAtomic };
67
+
68
+ let enrollmentResult;
69
+ try {
70
+ enrollmentResult = await (deps.enrollConversation ?? enrollConversation)(
71
+ homeDir, projectPath, { conversationId, provider }, io
72
+ );
73
+ } catch {
74
+ return fail("enroll_failed");
75
+ }
76
+
77
+ let saved;
78
+ try {
79
+ saved = await (deps.saveWorkSnapshot ?? saveWorkSnapshot)(
80
+ homeDir, projectPath, conversationId, draft, io
81
+ );
82
+ } catch {
83
+ return fail("snapshot_write_failed");
84
+ }
85
+
86
+ if (saved.projectKey !== projectKey || saved.conversationId !== conversationId) {
87
+ return fail("identity_mismatch");
88
+ }
89
+
90
+ const latest = await (deps.selectLatestWorkSnapshot ?? selectLatestWorkSnapshot)(
91
+ homeDir, projectPath
92
+ );
93
+
94
+ return {
95
+ ok: true,
96
+ code: enrollmentResult.created ? "enrolled" : "updated",
97
+ data: {
98
+ projectKey,
99
+ conversationId,
100
+ enrolled: true,
101
+ created: enrollmentResult.created === true,
102
+ snapshot: {
103
+ schema: saved.schema,
104
+ goal: saved.goal,
105
+ progress: saved.progress,
106
+ now: saved.now,
107
+ blockers: saved.blockers,
108
+ next: saved.next,
109
+ ...(saved.delegations ? { delegations: saved.delegations } : {}),
110
+ updatedAt: saved.updatedAt
111
+ },
112
+ selected: latest?.conversationId === conversationId
113
+ },
114
+ diagnostics: []
115
+ };
116
+ }
@@ -0,0 +1,91 @@
1
+ /**
2
+ * Idempotent conversation enrollment scoped by runtime projectKey + conversationId.
3
+ */
4
+ import { mkdir, readFile } from "node:fs/promises";
5
+ import { join } from "node:path";
6
+ import { harnessHomePaths } from "../paths.js";
7
+ import { writeAtomicJson } from "../runtime/write-atomic-json.js";
8
+ import { projectKeyForPath } from "./project-key.js";
9
+ import { snapshotFileId } from "./work-snapshot.js";
10
+
11
+ export const WORK_ENROLLMENT_SCHEMA = "kairo.work-enrollment/v1";
12
+
13
+ function enrollmentPath(homeDir, projectKey, conversationId) {
14
+ return join(
15
+ harnessHomePaths(homeDir).sessionsDir,
16
+ projectKey,
17
+ "enrollments",
18
+ `${snapshotFileId(conversationId)}.json`
19
+ );
20
+ }
21
+
22
+ /** Never trusts agent-supplied projectKey — derives it from projectPath. */
23
+ export async function enrollConversation(
24
+ homeDir,
25
+ projectPath,
26
+ { conversationId, provider = null } = {},
27
+ deps = {}
28
+ ) {
29
+ if (typeof conversationId !== "string" || !conversationId.trim()) {
30
+ throw new Error("conversationId is required to enroll.");
31
+ }
32
+ const id = conversationId.trim().slice(0, 160);
33
+ const projectKey = projectKeyForPath(projectPath);
34
+ const path = enrollmentPath(homeDir, projectKey, id);
35
+ await mkdir(join(path, ".."), { recursive: true });
36
+ const nowIso = deps.now ? deps.now() : new Date().toISOString();
37
+ const writeAtomic = deps.writeAtomic ?? writeAtomicJson;
38
+
39
+ let existing = null;
40
+ try {
41
+ existing = JSON.parse(await readFile(path, "utf8"));
42
+ } catch {
43
+ existing = null;
44
+ }
45
+
46
+ if (existing) {
47
+ if (
48
+ existing.schema !== WORK_ENROLLMENT_SCHEMA
49
+ || existing.projectKey !== projectKey
50
+ || existing.conversationId !== id
51
+ ) {
52
+ throw new Error("enrollment_identity_mismatch");
53
+ }
54
+ const refreshed = {
55
+ ...existing,
56
+ provider: provider ? String(provider).slice(0, 40) : existing.provider,
57
+ updatedAt: nowIso
58
+ };
59
+ await writeAtomic(path, refreshed);
60
+ return { created: false, enrollment: refreshed };
61
+ }
62
+
63
+ const enrollment = {
64
+ schema: WORK_ENROLLMENT_SCHEMA,
65
+ projectKey,
66
+ conversationId: id,
67
+ provider: provider ? String(provider).slice(0, 40) : null,
68
+ enrolledAt: nowIso,
69
+ updatedAt: nowIso
70
+ };
71
+ await writeAtomic(path, enrollment);
72
+ return { created: true, enrollment };
73
+ }
74
+
75
+ export async function loadEnrollment(homeDir, projectPath, conversationId) {
76
+ if (typeof conversationId !== "string" || !conversationId.trim()) return null;
77
+ const projectKey = projectKeyForPath(projectPath);
78
+ try {
79
+ const raw = JSON.parse(
80
+ await readFile(enrollmentPath(homeDir, projectKey, conversationId.trim()), "utf8")
81
+ );
82
+ if (
83
+ raw?.schema !== WORK_ENROLLMENT_SCHEMA
84
+ || raw.projectKey !== projectKey
85
+ || raw.conversationId !== conversationId.trim()
86
+ ) return null;
87
+ return raw;
88
+ } catch {
89
+ return null;
90
+ }
91
+ }