@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.
- package/CHANGELOG.md +110 -0
- package/bin/kairo-runtime.js +0 -0
- package/bin/kairo.js +0 -0
- package/package.json +1 -1
- package/src/cli.js +182 -8
- package/src/global/check-resolutions.js +31 -0
- package/src/global/cli-help.js +21 -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/control-plane/attention.js +141 -0
- package/src/global/control-plane/build-report.js +146 -0
- package/src/global/control-plane/cli.js +36 -0
- package/src/global/control-plane/constants.js +38 -0
- package/src/global/control-plane/gentle-adapters.js +183 -0
- package/src/global/control-plane/provider.js +69 -0
- package/src/global/control-plane/review-status.js +115 -0
- package/src/global/control-plane/sdd-status.js +49 -0
- package/src/global/control-plane/team.js +63 -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/observability/gentle-probe.js +30 -2
- package/src/global/observability/index.js +2 -1
- 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
|
+
}
|