ultra-builder-pro-cli 0.24.1 → 0.25.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 +63 -0
- package/README.md +10 -0
- package/adapters/claude.js +104 -0
- package/adapters/codex.js +363 -40
- package/adapters/grok.js +1254 -58
- package/adapters/kimi.js +682 -13
- package/adapters/opencode.js +132 -11
- package/agents/code-reviewer.md +4 -5
- package/agents/debugger.md +8 -8
- package/docs/AGENT-CONTEXT.md +30 -4
- package/docs/ARCHITECTURE.md +24 -5
- package/docs/ARTIFACT-AUTHORITY.md +13 -0
- package/docs/COMMIT-HASH-BACKFILL.md +25 -18
- package/docs/RUNTIME-COMPAT-MATRIX.md +18 -2
- package/docs/STATE-DB-ACCESS-POLICY.md +8 -1
- package/mcp-server/lib/artifact-registry.cjs +188 -107
- package/mcp-server/lib/baseline-workflow.cjs +160 -23
- package/mcp-server/lib/change-packet.cjs +4 -32
- package/mcp-server/lib/change-workflow.cjs +36 -2755
- package/mcp-server/lib/context-envelope.cjs +124 -51
- package/mcp-server/lib/decision-records.cjs +164 -78
- package/mcp-server/lib/kernel-change-workflow.cjs +2029 -0
- package/mcp-server/lib/legacy-change-workflow.cjs +3032 -0
- package/mcp-server/lib/managed-file-write.cjs +56 -0
- package/mcp-server/lib/prd-parser.cjs +2 -2
- package/mcp-server/lib/stage-checkpoints.cjs +163 -9
- package/mcp-server/lib/state-db.cjs +186 -8
- package/mcp-server/lib/state-ops.cjs +214 -31
- package/mcp-server/lib/task-contract.cjs +404 -0
- package/mcp-server/lib/task-expander.cjs +8 -3
- package/mcp-server/lib/task-ledger.cjs +175 -62
- package/mcp-server/lib/ultra-facade.cjs +1895 -172
- package/mcp-server/lib/worker-packet.cjs +2 -2
- package/mcp-server/server.cjs +24 -5
- package/orchestrator/parallel-orchestrator.cjs +114 -27
- package/orchestrator/session-runner.cjs +25 -136
- package/package.json +1 -1
- package/skills/ultra-change/SKILL.md +6 -1
- package/skills/ultra-dev/SKILL.md +9 -4
- package/skills/ultra-plan/SKILL.md +13 -4
- package/skills/ultra-plan/references/semantic-preflight.md +6 -4
- package/spec/mcp-tools.yaml +114 -9
- package/spec/schemas/context-file.v4.5.schema.json +2 -2
- package/spec/schemas/state-db.sql +10 -6
- package/spec/schemas/task-ledger.v2.schema.json +23 -8
- package/spec/schemas/tasks.v4.5.schema.json +34 -12
package/adapters/kimi.js
CHANGED
|
@@ -3,8 +3,10 @@
|
|
|
3
3
|
/** Build and register a Kimi Code 0.26+ native Ultra Builder Pro plugin. */
|
|
4
4
|
|
|
5
5
|
const fs = require('node:fs');
|
|
6
|
+
const crypto = require('node:crypto');
|
|
6
7
|
const os = require('node:os');
|
|
7
8
|
const path = require('node:path');
|
|
9
|
+
const { spawnSync } = require('node:child_process');
|
|
8
10
|
|
|
9
11
|
const {
|
|
10
12
|
copyTree,
|
|
@@ -21,6 +23,7 @@ const {
|
|
|
21
23
|
const { parse: parseFm, serialize: serializeFm } = require('./_shared/frontmatter.cjs');
|
|
22
24
|
const { adaptInteractionGuidance } = require('./_shared/interaction-contract.cjs');
|
|
23
25
|
const provenance = require('./_shared/provenance.cjs');
|
|
26
|
+
const { PUBLIC_TOOLS } = require('../mcp-server/lib/ultra-facade.cjs');
|
|
24
27
|
const {
|
|
25
28
|
CORE_PUBLIC_SKILLS,
|
|
26
29
|
WORKFLOW_HOOK_FILES,
|
|
@@ -33,6 +36,262 @@ const MCP_SERVER_NAME = PLUGIN_ID;
|
|
|
33
36
|
const PROVENANCE_FILE = 'provenance.json';
|
|
34
37
|
const REGISTRY_RELATIVE = path.join('plugins', 'installed.json');
|
|
35
38
|
const MANAGED_RELATIVE = path.join('plugins', 'managed', PLUGIN_ID);
|
|
39
|
+
const KIMI_PLUGIN_PROBE = String.raw`
|
|
40
|
+
'use strict';
|
|
41
|
+
const { spawn } = require('node:child_process');
|
|
42
|
+
|
|
43
|
+
const kimiBin = process.argv[1];
|
|
44
|
+
const pluginRoot = process.argv[2];
|
|
45
|
+
const pluginId = process.argv[3];
|
|
46
|
+
const sessionCwd = process.argv[4];
|
|
47
|
+
const kimiHome = process.argv[5];
|
|
48
|
+
const probeTimeoutMs = Number(process.argv[6]);
|
|
49
|
+
const cleanupHttpTimeoutMs = Number(process.argv[7]);
|
|
50
|
+
const termWaitMs = Number(process.argv[8]);
|
|
51
|
+
const killWaitMs = Number(process.argv[9]);
|
|
52
|
+
let child = null;
|
|
53
|
+
let activeBaseUrl = '';
|
|
54
|
+
let activeHeaders = {};
|
|
55
|
+
let activeSessionId = '';
|
|
56
|
+
let cleanupPromise = null;
|
|
57
|
+
|
|
58
|
+
async function readJson(url, headers, options = {}, attempts = 1) {
|
|
59
|
+
let lastError;
|
|
60
|
+
for (let attempt = 0; attempt < attempts; attempt += 1) {
|
|
61
|
+
try {
|
|
62
|
+
const response = await fetch(url, { ...options, headers: {
|
|
63
|
+
...headers,
|
|
64
|
+
...(options.headers || {}),
|
|
65
|
+
} });
|
|
66
|
+
if (!response.ok) throw new Error('HTTP ' + response.status);
|
|
67
|
+
return await response.json();
|
|
68
|
+
} catch (error) {
|
|
69
|
+
lastError = error;
|
|
70
|
+
if (attempt + 1 < attempts) {
|
|
71
|
+
await new Promise((resolve) => setTimeout(resolve, 100));
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
throw lastError;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function childExited() {
|
|
79
|
+
return !child || child.exitCode !== null || child.signalCode !== null;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
async function waitForExitWithin(timeoutMs) {
|
|
83
|
+
if (childExited()) return true;
|
|
84
|
+
return await new Promise((resolve) => {
|
|
85
|
+
const onExit = () => {
|
|
86
|
+
clearTimeout(timer);
|
|
87
|
+
resolve(true);
|
|
88
|
+
};
|
|
89
|
+
const timer = setTimeout(() => {
|
|
90
|
+
child.removeListener('exit', onExit);
|
|
91
|
+
resolve(childExited());
|
|
92
|
+
}, timeoutMs);
|
|
93
|
+
child.once('exit', onExit);
|
|
94
|
+
});
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
function signalChild(signal) {
|
|
98
|
+
if (childExited()) return;
|
|
99
|
+
if (process.platform !== 'win32' && child.pid) {
|
|
100
|
+
try {
|
|
101
|
+
process.kill(-child.pid, signal);
|
|
102
|
+
return;
|
|
103
|
+
} catch (error) {
|
|
104
|
+
if (error.code !== 'ESRCH') throw error;
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
try {
|
|
108
|
+
child.kill(signal);
|
|
109
|
+
} catch (error) {
|
|
110
|
+
if (error.code !== 'ESRCH') throw error;
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
async function stopChild() {
|
|
115
|
+
if (!child || child.exitCode !== null || child.signalCode !== null) return;
|
|
116
|
+
signalChild('SIGTERM');
|
|
117
|
+
if (!await waitForExitWithin(termWaitMs)) {
|
|
118
|
+
signalChild('SIGKILL');
|
|
119
|
+
if (!await waitForExitWithin(killWaitMs)) {
|
|
120
|
+
throw new Error('Kimi probe process group did not exit after SIGKILL');
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
async function cleanup() {
|
|
126
|
+
if (cleanupPromise) return cleanupPromise;
|
|
127
|
+
cleanupPromise = (async () => {
|
|
128
|
+
let cleanupError = null;
|
|
129
|
+
if (activeBaseUrl && activeSessionId) {
|
|
130
|
+
try {
|
|
131
|
+
const archived = await readJson(
|
|
132
|
+
activeBaseUrl + '/api/v1/sessions/' + encodeURIComponent(activeSessionId) + ':archive',
|
|
133
|
+
activeHeaders,
|
|
134
|
+
{
|
|
135
|
+
method: 'POST',
|
|
136
|
+
headers: { 'content-type': 'application/json' },
|
|
137
|
+
body: '{}',
|
|
138
|
+
signal: AbortSignal.timeout(cleanupHttpTimeoutMs),
|
|
139
|
+
},
|
|
140
|
+
);
|
|
141
|
+
if (archived.code !== 0 || archived.data?.archived !== true) {
|
|
142
|
+
throw new Error('Kimi failed to archive probe session ' + activeSessionId);
|
|
143
|
+
}
|
|
144
|
+
activeSessionId = '';
|
|
145
|
+
} catch (error) {
|
|
146
|
+
cleanupError = error;
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
if (activeBaseUrl) {
|
|
150
|
+
try {
|
|
151
|
+
await fetch(activeBaseUrl + '/api/v1/shutdown', {
|
|
152
|
+
method: 'POST',
|
|
153
|
+
headers: activeHeaders,
|
|
154
|
+
signal: AbortSignal.timeout(cleanupHttpTimeoutMs),
|
|
155
|
+
});
|
|
156
|
+
} catch (_) {
|
|
157
|
+
// The server may close the socket before returning its shutdown response.
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
try {
|
|
161
|
+
await stopChild();
|
|
162
|
+
} catch (error) {
|
|
163
|
+
cleanupError ||= error;
|
|
164
|
+
}
|
|
165
|
+
if (cleanupError) {
|
|
166
|
+
throw new Error('KIMI_PLUGIN_PROBE_TIMEOUT: ' + cleanupError.message);
|
|
167
|
+
}
|
|
168
|
+
})();
|
|
169
|
+
return cleanupPromise;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
async function main() {
|
|
173
|
+
let stdout = '';
|
|
174
|
+
let stderr = '';
|
|
175
|
+
child = spawn(kimiBin, [
|
|
176
|
+
'web',
|
|
177
|
+
'--port', '0',
|
|
178
|
+
'--host', '127.0.0.1',
|
|
179
|
+
'--no-open',
|
|
180
|
+
'--debug-endpoints',
|
|
181
|
+
], {
|
|
182
|
+
cwd: sessionCwd,
|
|
183
|
+
env: { ...process.env, KIMI_CODE_HOME: kimiHome },
|
|
184
|
+
detached: process.platform !== 'win32',
|
|
185
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
186
|
+
});
|
|
187
|
+
child.stdout.on('data', (chunk) => { stdout += chunk; });
|
|
188
|
+
child.stderr.on('data', (chunk) => { stderr += chunk; });
|
|
189
|
+
|
|
190
|
+
try {
|
|
191
|
+
let token = null;
|
|
192
|
+
let baseUrl = null;
|
|
193
|
+
for (let attempt = 0; attempt < 200 && (!token || !baseUrl); attempt += 1) {
|
|
194
|
+
token = stdout.match(/Token:\s+(\S+)/)?.[1] || null;
|
|
195
|
+
baseUrl = stdout.match(/Local:\s+(http:\/\/127\.0\.0\.1:\d+)/)?.[1] || null;
|
|
196
|
+
if (!token || !baseUrl) await new Promise((resolve) => setTimeout(resolve, 100));
|
|
197
|
+
}
|
|
198
|
+
if (!token) throw new Error('Kimi web did not publish a loopback bearer token');
|
|
199
|
+
if (!baseUrl) throw new Error('Kimi web did not publish its loopback URL');
|
|
200
|
+
const headers = { Authorization: 'Bearer ' + token };
|
|
201
|
+
activeBaseUrl = baseUrl;
|
|
202
|
+
activeHeaders = headers;
|
|
203
|
+
await readJson(baseUrl + '/api/v1/debug/channels', headers, {}, 200);
|
|
204
|
+
const response = await readJson(
|
|
205
|
+
baseUrl + '/api/v1/debug/pluginService/listPlugins',
|
|
206
|
+
headers,
|
|
207
|
+
);
|
|
208
|
+
if (response.code !== 0 || !Array.isArray(response.data)) {
|
|
209
|
+
throw new Error('Kimi pluginService.listPlugins returned an invalid response');
|
|
210
|
+
}
|
|
211
|
+
const plugin = response.data.find((entry) => entry && entry.id === pluginId);
|
|
212
|
+
const mcpResponse = await readJson(
|
|
213
|
+
baseUrl + '/api/v1/debug/pluginService/enabledMcpServers',
|
|
214
|
+
headers,
|
|
215
|
+
);
|
|
216
|
+
if (mcpResponse.code !== 0 || !mcpResponse.data || typeof mcpResponse.data !== 'object') {
|
|
217
|
+
throw new Error('Kimi pluginService.enabledMcpServers returned an invalid response');
|
|
218
|
+
}
|
|
219
|
+
const sessionResponse = await readJson(
|
|
220
|
+
baseUrl + '/api/v1/sessions',
|
|
221
|
+
headers,
|
|
222
|
+
{
|
|
223
|
+
method: 'POST',
|
|
224
|
+
headers: { 'content-type': 'application/json' },
|
|
225
|
+
body: JSON.stringify({ metadata: { cwd: sessionCwd } }),
|
|
226
|
+
},
|
|
227
|
+
);
|
|
228
|
+
const sessionId = sessionResponse.data?.id;
|
|
229
|
+
if (sessionResponse.code !== 0 || !sessionId) {
|
|
230
|
+
throw new Error('Kimi failed to create a probe session');
|
|
231
|
+
}
|
|
232
|
+
activeSessionId = sessionId;
|
|
233
|
+
const agentBase = baseUrl
|
|
234
|
+
+ '/api/v1/debug/session/' + encodeURIComponent(sessionId)
|
|
235
|
+
+ '/agent/main/agentMcpService/';
|
|
236
|
+
const initialLoad = await readJson(
|
|
237
|
+
agentBase + 'waitForInitialLoad',
|
|
238
|
+
headers,
|
|
239
|
+
);
|
|
240
|
+
if (initialLoad.code !== 0) {
|
|
241
|
+
throw new Error('Kimi agentMcpService.waitForInitialLoad failed');
|
|
242
|
+
}
|
|
243
|
+
const connectionResponse = await readJson(agentBase + 'list', headers);
|
|
244
|
+
if (connectionResponse.code !== 0 || !Array.isArray(connectionResponse.data)) {
|
|
245
|
+
throw new Error('Kimi agentMcpService.list returned an invalid response');
|
|
246
|
+
}
|
|
247
|
+
const toolsResponse = await readJson(
|
|
248
|
+
baseUrl + '/api/v1/tools?session_id=' + encodeURIComponent(sessionId),
|
|
249
|
+
headers,
|
|
250
|
+
);
|
|
251
|
+
if (toolsResponse.code !== 0 || !Array.isArray(toolsResponse.data?.tools)) {
|
|
252
|
+
throw new Error('Kimi session tool registry returned an invalid response');
|
|
253
|
+
}
|
|
254
|
+
process.stdout.write(JSON.stringify({
|
|
255
|
+
plugin,
|
|
256
|
+
mcpServers: mcpResponse.data,
|
|
257
|
+
mcpConnections: connectionResponse.data,
|
|
258
|
+
tools: toolsResponse.data.tools,
|
|
259
|
+
}));
|
|
260
|
+
} catch (error) {
|
|
261
|
+
const detail = stderr.trim();
|
|
262
|
+
throw new Error(error.message + (detail ? ': ' + detail : ''));
|
|
263
|
+
} finally {
|
|
264
|
+
await cleanup();
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
let handlingSignal = false;
|
|
269
|
+
for (const signal of ['SIGINT', 'SIGTERM']) {
|
|
270
|
+
process.on(signal, () => {
|
|
271
|
+
if (handlingSignal) return;
|
|
272
|
+
handlingSignal = true;
|
|
273
|
+
void cleanup().finally(() => process.exit(1));
|
|
274
|
+
});
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
let watchdogFired = false;
|
|
278
|
+
const watchdog = setTimeout(() => {
|
|
279
|
+
watchdogFired = true;
|
|
280
|
+
void cleanup().catch(() => {}).finally(() => {
|
|
281
|
+
process.stderr.write('KIMI_PLUGIN_PROBE_TIMEOUT');
|
|
282
|
+
process.exit(124);
|
|
283
|
+
});
|
|
284
|
+
}, probeTimeoutMs);
|
|
285
|
+
|
|
286
|
+
main().then(() => {
|
|
287
|
+
clearTimeout(watchdog);
|
|
288
|
+
}).catch((error) => {
|
|
289
|
+
clearTimeout(watchdog);
|
|
290
|
+
if (watchdogFired) return;
|
|
291
|
+
process.stderr.write(error.stack || error.message);
|
|
292
|
+
process.exitCode = 1;
|
|
293
|
+
});
|
|
294
|
+
`;
|
|
36
295
|
|
|
37
296
|
function resolveTarget(ctx = {}) {
|
|
38
297
|
if (ctx.configDir) return path.resolve(ctx.configDir);
|
|
@@ -54,6 +313,296 @@ function resolveRepoRoot(ctx = {}) {
|
|
|
54
313
|
return path.resolve(ctx.repoRoot || path.join(__dirname, '..'));
|
|
55
314
|
}
|
|
56
315
|
|
|
316
|
+
function shouldRunHostCli(ctx = {}) {
|
|
317
|
+
if (typeof ctx.runHostCli === 'boolean') return ctx.runHostCli;
|
|
318
|
+
return ctx.scope === 'global' && !ctx.configDir;
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
function countSkillRoots(pluginRoot) {
|
|
322
|
+
const root = path.join(pluginRoot, 'skills');
|
|
323
|
+
if (!fs.existsSync(root)) return 0;
|
|
324
|
+
return fs.readdirSync(root, { withFileTypes: true })
|
|
325
|
+
.filter((entry) => (
|
|
326
|
+
entry.isDirectory()
|
|
327
|
+
&& fs.existsSync(path.join(root, entry.name, 'SKILL.md'))
|
|
328
|
+
))
|
|
329
|
+
.length;
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
function probeKimiPlugin(ctx = {}, consumer = {}) {
|
|
333
|
+
const probeTimeoutMs = ctx.hostCliTimeoutMs || 45000;
|
|
334
|
+
const cleanupHttpTimeoutMs = Math.min(
|
|
335
|
+
1000,
|
|
336
|
+
Math.max(10, Math.floor(probeTimeoutMs / 8)),
|
|
337
|
+
);
|
|
338
|
+
const termWaitMs = Math.min(1000, Math.max(10, Math.floor(probeTimeoutMs / 8)));
|
|
339
|
+
const killWaitMs = Math.min(1000, Math.max(10, Math.floor(probeTimeoutMs / 8)));
|
|
340
|
+
const outerTimeoutMs = probeTimeoutMs
|
|
341
|
+
+ (cleanupHttpTimeoutMs * 2)
|
|
342
|
+
+ termWaitMs
|
|
343
|
+
+ killWaitMs
|
|
344
|
+
+ 250;
|
|
345
|
+
const result = spawnSync(
|
|
346
|
+
process.execPath,
|
|
347
|
+
[
|
|
348
|
+
'-e',
|
|
349
|
+
KIMI_PLUGIN_PROBE,
|
|
350
|
+
ctx.kimiBin || 'kimi',
|
|
351
|
+
consumer.pluginRoot || resolvePluginRoot(ctx),
|
|
352
|
+
PLUGIN_ID,
|
|
353
|
+
path.resolve(ctx.cwd || process.cwd()),
|
|
354
|
+
consumer.kimiHome || resolveTarget(ctx),
|
|
355
|
+
String(probeTimeoutMs),
|
|
356
|
+
String(cleanupHttpTimeoutMs),
|
|
357
|
+
String(termWaitMs),
|
|
358
|
+
String(killWaitMs),
|
|
359
|
+
],
|
|
360
|
+
{
|
|
361
|
+
encoding: 'utf8',
|
|
362
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
363
|
+
killSignal: 'SIGKILL',
|
|
364
|
+
env: {
|
|
365
|
+
...process.env,
|
|
366
|
+
},
|
|
367
|
+
timeout: outerTimeoutMs,
|
|
368
|
+
},
|
|
369
|
+
);
|
|
370
|
+
if (result.error) {
|
|
371
|
+
if (result.error.code === 'ETIMEDOUT') {
|
|
372
|
+
const error = new Error('Kimi native plugin probe timed out');
|
|
373
|
+
error.code = 'HOST_CLI_TIMEOUT';
|
|
374
|
+
throw error;
|
|
375
|
+
}
|
|
376
|
+
throw result.error;
|
|
377
|
+
}
|
|
378
|
+
if (result.status === 124 || result.stderr.includes('KIMI_PLUGIN_PROBE_TIMEOUT')) {
|
|
379
|
+
const error = new Error('Kimi native plugin probe timed out');
|
|
380
|
+
error.code = 'HOST_CLI_TIMEOUT';
|
|
381
|
+
throw error;
|
|
382
|
+
}
|
|
383
|
+
if (result.status !== 0) {
|
|
384
|
+
const detail = (result.stderr || result.stdout || '').trim();
|
|
385
|
+
throw new Error(`Kimi native plugin probe failed${detail ? `: ${detail}` : ''}`);
|
|
386
|
+
}
|
|
387
|
+
const payload = JSON.parse(result.stdout);
|
|
388
|
+
return payload;
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
function stableHash8(input) {
|
|
392
|
+
let hash = 2166136261;
|
|
393
|
+
for (let index = 0; index < input.length; index += 1) {
|
|
394
|
+
hash ^= input.codePointAt(index);
|
|
395
|
+
hash = Math.trunc(Math.imul(hash, 16777619));
|
|
396
|
+
}
|
|
397
|
+
return hash.toString(16).padStart(8, '0');
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
function sanitizeKimiMcpNamePart(value) {
|
|
401
|
+
return value.replaceAll(/[^a-zA-Z0-9_-]/g, '_').replaceAll(/_+/g, '_');
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
function matchesQualifiedKimiToolName(observed, serverId, publicName) {
|
|
405
|
+
if (typeof observed !== 'string') return false;
|
|
406
|
+
const full = `mcp__${sanitizeKimiMcpNamePart(serverId)}__${sanitizeKimiMcpNamePart(publicName)}`;
|
|
407
|
+
if (observed === full) return true;
|
|
408
|
+
const truncated = /^(.*)_(-?[0-9a-f]{8})$/.exec(observed);
|
|
409
|
+
return !!truncated
|
|
410
|
+
&& truncated[2] === stableHash8(full)
|
|
411
|
+
&& truncated[1] === full.slice(0, truncated[1].length);
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
function normalizeKimiToolIdentity(descriptor, serverId, expectedTools) {
|
|
415
|
+
if (typeof serverId !== 'string' || !serverId) return null;
|
|
416
|
+
const matches = expectedTools.filter((name) => (
|
|
417
|
+
matchesQualifiedKimiToolName(descriptor?.name, serverId, name)
|
|
418
|
+
));
|
|
419
|
+
return matches.length === 1 ? matches[0] : null;
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
function sameStringSet(actual, expected) {
|
|
423
|
+
if (!Array.isArray(actual) || actual.length !== expected.length) return false;
|
|
424
|
+
const actualSet = new Set(actual);
|
|
425
|
+
return actualSet.size === expected.length
|
|
426
|
+
&& expected.every((value) => actualSet.has(value));
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
function inspectActualKimiRegistration(ctx = {}) {
|
|
430
|
+
const registryFile = resolveRegistryFile(ctx);
|
|
431
|
+
const pluginRoot = resolvePluginRoot(ctx);
|
|
432
|
+
if (!fs.existsSync(registryFile)) {
|
|
433
|
+
return {
|
|
434
|
+
registryFile,
|
|
435
|
+
pluginRoot,
|
|
436
|
+
registryBytes: null,
|
|
437
|
+
record: null,
|
|
438
|
+
registryOk: false,
|
|
439
|
+
pluginRootOk: false,
|
|
440
|
+
};
|
|
441
|
+
}
|
|
442
|
+
const registryBytes = fs.readFileSync(registryFile);
|
|
443
|
+
const registry = loadRegistry(registryFile);
|
|
444
|
+
const record = registry.plugins.find((entry) => entry && entry.id === PLUGIN_ID) || null;
|
|
445
|
+
const registryOk = !!record
|
|
446
|
+
&& path.resolve(record.root || '') === path.resolve(pluginRoot)
|
|
447
|
+
&& record.source === 'local-path'
|
|
448
|
+
&& record.enabled === true;
|
|
449
|
+
let pluginRootOk = false;
|
|
450
|
+
if (registryOk) {
|
|
451
|
+
try {
|
|
452
|
+
const stat = fs.lstatSync(pluginRoot);
|
|
453
|
+
pluginRootOk = stat.isDirectory()
|
|
454
|
+
&& !stat.isSymbolicLink()
|
|
455
|
+
&& isManaged(pluginRoot)
|
|
456
|
+
&& fs.existsSync(path.join(pluginRoot, 'kimi.plugin.json'));
|
|
457
|
+
} catch (error) {
|
|
458
|
+
if (error.code !== 'ENOENT') throw error;
|
|
459
|
+
}
|
|
460
|
+
}
|
|
461
|
+
return {
|
|
462
|
+
registryFile,
|
|
463
|
+
pluginRoot,
|
|
464
|
+
registryBytes,
|
|
465
|
+
record,
|
|
466
|
+
registryOk,
|
|
467
|
+
pluginRootOk,
|
|
468
|
+
};
|
|
469
|
+
}
|
|
470
|
+
|
|
471
|
+
function createIsolatedKimiRegistry(actual) {
|
|
472
|
+
const kimiHome = fs.mkdtempSync(path.join(os.tmpdir(), 'ubp-kimi-doctor-'));
|
|
473
|
+
const registryFile = path.join(kimiHome, REGISTRY_RELATIVE);
|
|
474
|
+
ensureDir(path.dirname(registryFile));
|
|
475
|
+
fs.writeFileSync(registryFile, actual.registryBytes);
|
|
476
|
+
if (!fs.readFileSync(registryFile).equals(actual.registryBytes)) {
|
|
477
|
+
removeTree(kimiHome);
|
|
478
|
+
throw new Error('failed to create a byte-for-byte Kimi registry clone');
|
|
479
|
+
}
|
|
480
|
+
return { kimiHome, registryFile };
|
|
481
|
+
}
|
|
482
|
+
|
|
483
|
+
function invalidActualKimiRegistration(actual) {
|
|
484
|
+
const error = new Error(
|
|
485
|
+
`Kimi actual registration is invalid: ${actual.registryFile} -> ${actual.pluginRoot}`,
|
|
486
|
+
);
|
|
487
|
+
error.code = 'ACTUAL_KIMI_REGISTRATION_INVALID';
|
|
488
|
+
error.actual = actual;
|
|
489
|
+
return error;
|
|
490
|
+
}
|
|
491
|
+
|
|
492
|
+
function inspectKimiHost(ctx = {}) {
|
|
493
|
+
const actual = inspectActualKimiRegistration(ctx);
|
|
494
|
+
if (!actual.registryOk || !actual.pluginRootOk || !actual.registryBytes) {
|
|
495
|
+
throw invalidActualKimiRegistration(actual);
|
|
496
|
+
}
|
|
497
|
+
const target = actual.pluginRoot;
|
|
498
|
+
const manifest = JSON.parse(fs.readFileSync(path.join(target, 'kimi.plugin.json'), 'utf8'));
|
|
499
|
+
const isolated = createIsolatedKimiRegistry(actual);
|
|
500
|
+
try {
|
|
501
|
+
const doctorResult = spawnSync(ctx.kimiBin || 'kimi', ['doctor'], {
|
|
502
|
+
encoding: 'utf8',
|
|
503
|
+
timeout: ctx.hostCliTimeoutMs || 30000,
|
|
504
|
+
killSignal: 'SIGKILL',
|
|
505
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
506
|
+
env: {
|
|
507
|
+
...process.env,
|
|
508
|
+
KIMI_CODE_HOME: isolated.kimiHome,
|
|
509
|
+
},
|
|
510
|
+
});
|
|
511
|
+
if (doctorResult.error) {
|
|
512
|
+
if (doctorResult.error.code === 'ETIMEDOUT') {
|
|
513
|
+
const error = new Error('kimi doctor timed out');
|
|
514
|
+
error.code = 'HOST_CLI_TIMEOUT';
|
|
515
|
+
throw error;
|
|
516
|
+
}
|
|
517
|
+
throw doctorResult.error;
|
|
518
|
+
}
|
|
519
|
+
if (doctorResult.status !== 0) {
|
|
520
|
+
const detail = (doctorResult.stderr || doctorResult.stdout || '').trim();
|
|
521
|
+
throw new Error(`kimi doctor failed${detail ? `: ${detail}` : ''}`);
|
|
522
|
+
}
|
|
523
|
+
if (!fs.readFileSync(isolated.registryFile).equals(actual.registryBytes)) {
|
|
524
|
+
throw new Error('isolated Kimi doctor mutated its registry clone');
|
|
525
|
+
}
|
|
526
|
+
const probe = probeKimiPlugin(ctx, {
|
|
527
|
+
kimiHome: isolated.kimiHome,
|
|
528
|
+
pluginRoot: actual.pluginRoot,
|
|
529
|
+
});
|
|
530
|
+
const plugin = probe.plugin || null;
|
|
531
|
+
const expected = {
|
|
532
|
+
skillCount: countSkillRoots(target),
|
|
533
|
+
mcpServerCount: Object.keys(manifest.mcpServers || {}).length,
|
|
534
|
+
hookCount: Array.isArray(manifest.hooks) ? manifest.hooks.length : 0,
|
|
535
|
+
commandCount: fs.readdirSync(path.join(target, 'commands'))
|
|
536
|
+
.filter((name) => name.endsWith('.md')).length,
|
|
537
|
+
};
|
|
538
|
+
const pluginOk = !!plugin
|
|
539
|
+
&& plugin.enabled === true
|
|
540
|
+
&& plugin.state === 'ok'
|
|
541
|
+
&& plugin.hasErrors === false
|
|
542
|
+
&& plugin.version === manifest.version
|
|
543
|
+
&& plugin.skillCount === expected.skillCount
|
|
544
|
+
&& plugin.hookCount === expected.hookCount
|
|
545
|
+
&& plugin.commandCount === expected.commandCount;
|
|
546
|
+
const nativeServerName = `plugin-${PLUGIN_ID}:${MCP_SERVER_NAME}`;
|
|
547
|
+
const nativeMcp = probe.mcpServers?.[nativeServerName];
|
|
548
|
+
const mcpConnection = (probe.mcpConnections || []).find((entry) => (
|
|
549
|
+
entry?.name === nativeServerName
|
|
550
|
+
));
|
|
551
|
+
const enabledTools = manifest.mcpServers?.[MCP_SERVER_NAME]?.enabledTools;
|
|
552
|
+
const expectedTools = [...PUBLIC_TOOLS];
|
|
553
|
+
const connectionToolServerId = mcpConnection?.name?.replaceAll(':', '_');
|
|
554
|
+
const connectedTools = (probe.tools || []).filter((entry) => (
|
|
555
|
+
entry?.source === 'mcp'
|
|
556
|
+
&& entry.mcp_server_id === connectionToolServerId
|
|
557
|
+
&& entry.active !== false
|
|
558
|
+
));
|
|
559
|
+
const normalizedTools = connectedTools.map((entry) => (
|
|
560
|
+
normalizeKimiToolIdentity(entry, connectionToolServerId, expectedTools)
|
|
561
|
+
));
|
|
562
|
+
const mcpOk = pluginOk
|
|
563
|
+
&& plugin.mcpServerCount === expected.mcpServerCount
|
|
564
|
+
&& plugin.enabledMcpServerCount === expected.mcpServerCount
|
|
565
|
+
&& nativeMcp?.enabled === true
|
|
566
|
+
&& path.resolve(nativeMcp.cwd || '') === path.resolve(target)
|
|
567
|
+
&& nativeMcp.command === manifest.mcpServers?.[MCP_SERVER_NAME]?.command
|
|
568
|
+
&& JSON.stringify(nativeMcp.args) === JSON.stringify(
|
|
569
|
+
manifest.mcpServers?.[MCP_SERVER_NAME]?.args,
|
|
570
|
+
)
|
|
571
|
+
&& sameStringSet(nativeMcp.enabledTools, expectedTools)
|
|
572
|
+
&& sameStringSet(enabledTools, expectedTools)
|
|
573
|
+
&& mcpConnection?.status === 'connected'
|
|
574
|
+
&& Number.isInteger(mcpConnection.toolCount)
|
|
575
|
+
&& mcpConnection.toolCount === expectedTools.length
|
|
576
|
+
&& normalizedTools.every((name) => name !== null)
|
|
577
|
+
&& sameStringSet(normalizedTools, expectedTools);
|
|
578
|
+
return {
|
|
579
|
+
stdout: doctorResult.stdout,
|
|
580
|
+
plugin,
|
|
581
|
+
pluginOk,
|
|
582
|
+
mcpOk,
|
|
583
|
+
nativeMcp,
|
|
584
|
+
mcpConnection,
|
|
585
|
+
expected,
|
|
586
|
+
actualRegistry: {
|
|
587
|
+
file: actual.registryFile,
|
|
588
|
+
bytes: actual.registryBytes.length,
|
|
589
|
+
sha256: crypto.createHash('sha256').update(actual.registryBytes).digest('hex'),
|
|
590
|
+
},
|
|
591
|
+
actualPluginRoot: actual.pluginRoot,
|
|
592
|
+
isolatedConsumer: { kimiHome: isolated.kimiHome },
|
|
593
|
+
};
|
|
594
|
+
} finally {
|
|
595
|
+
const unchanged = fs.existsSync(actual.registryFile)
|
|
596
|
+
&& fs.readFileSync(actual.registryFile).equals(actual.registryBytes);
|
|
597
|
+
removeTree(isolated.kimiHome);
|
|
598
|
+
if (!unchanged) {
|
|
599
|
+
const error = new Error(`Kimi doctor mutated the actual registry: ${actual.registryFile}`);
|
|
600
|
+
error.code = 'ACTUAL_KIMI_REGISTRY_MUTATED';
|
|
601
|
+
throw error;
|
|
602
|
+
}
|
|
603
|
+
}
|
|
604
|
+
}
|
|
605
|
+
|
|
57
606
|
function loadRegistry(file) {
|
|
58
607
|
if (!fs.existsSync(file)) return { version: 1, plugins: [] };
|
|
59
608
|
let parsed;
|
|
@@ -237,12 +786,25 @@ function copyHooks(repoRoot, pluginRoot) {
|
|
|
237
786
|
}
|
|
238
787
|
|
|
239
788
|
function mcpManifest() {
|
|
789
|
+
const enabledTools = [...PUBLIC_TOOLS];
|
|
240
790
|
if (process.platform === 'win32') {
|
|
241
|
-
return {
|
|
791
|
+
return {
|
|
792
|
+
transport: 'stdio',
|
|
793
|
+
command: 'node.exe',
|
|
794
|
+
args: ['./runtime/launch.cjs'],
|
|
795
|
+
enabled: true,
|
|
796
|
+
enabledTools,
|
|
797
|
+
};
|
|
242
798
|
}
|
|
243
799
|
// Kimi's built-in `node` fallback uses the host binary's ABI. Ultra bundles
|
|
244
800
|
// better-sqlite3 for the Node that runs `ubp`, so use PATH Node explicitly.
|
|
245
|
-
return {
|
|
801
|
+
return {
|
|
802
|
+
transport: 'stdio',
|
|
803
|
+
command: 'env',
|
|
804
|
+
args: ['node', './runtime/launch.cjs'],
|
|
805
|
+
enabled: true,
|
|
806
|
+
enabledTools,
|
|
807
|
+
};
|
|
246
808
|
}
|
|
247
809
|
|
|
248
810
|
function writeManifest(repoRoot, pluginRoot) {
|
|
@@ -447,12 +1009,68 @@ function doctor(ctx = {}) {
|
|
|
447
1009
|
report.checks.plugin_manifest = { status: manifestOk ? 'pass' : 'fail' };
|
|
448
1010
|
report.checks.mcp_registration = { status: mcpOk ? 'pass' : 'fail' };
|
|
449
1011
|
report.checks.hook_registration = { status: hooksOk ? 'pass' : 'fail' };
|
|
1012
|
+
if (shouldRunHostCli(ctx)) {
|
|
1013
|
+
let actual = null;
|
|
1014
|
+
try {
|
|
1015
|
+
actual = inspectActualKimiRegistration(ctx);
|
|
1016
|
+
} catch (error) {
|
|
1017
|
+
addIssue(report, 'ACTUAL_REGISTRY_INVALID', {
|
|
1018
|
+
path: registryFile,
|
|
1019
|
+
message: error.message,
|
|
1020
|
+
});
|
|
1021
|
+
}
|
|
1022
|
+
const actualRegistryOk = !!actual?.registryOk;
|
|
1023
|
+
const actualPluginRootOk = actualRegistryOk && actual.pluginRootOk;
|
|
1024
|
+
report.checks.actual_registry = { status: actualRegistryOk ? 'pass' : 'fail' };
|
|
1025
|
+
report.checks.actual_plugin_root = { status: actualPluginRootOk ? 'pass' : 'fail' };
|
|
1026
|
+
if (!actualRegistryOk) {
|
|
1027
|
+
addIssue(report, 'ACTUAL_REGISTRY_INVALID', {
|
|
1028
|
+
path: registryFile,
|
|
1029
|
+
expected_root: target,
|
|
1030
|
+
});
|
|
1031
|
+
}
|
|
1032
|
+
if (!actualPluginRootOk) {
|
|
1033
|
+
addIssue(report, 'ACTUAL_PLUGIN_ROOT_INVALID', { path: target });
|
|
1034
|
+
}
|
|
1035
|
+
if (!actualRegistryOk || !actualPluginRootOk) {
|
|
1036
|
+
report.checks.isolated_consumer_cli = { status: 'fail' };
|
|
1037
|
+
report.checks.isolated_consumer_plugin = { status: 'fail' };
|
|
1038
|
+
report.checks.isolated_consumer_mcp = { status: 'fail' };
|
|
1039
|
+
return applyNativeDoctor(report, path.join(target, 'runtime'));
|
|
1040
|
+
}
|
|
1041
|
+
try {
|
|
1042
|
+
const host = inspectKimiHost(ctx);
|
|
1043
|
+
report.checks.isolated_consumer_cli = { status: 'pass' };
|
|
1044
|
+
report.checks.isolated_consumer_plugin = { status: host.pluginOk ? 'pass' : 'fail' };
|
|
1045
|
+
report.checks.isolated_consumer_mcp = { status: host.mcpOk ? 'pass' : 'fail' };
|
|
1046
|
+
if (!host.pluginOk) {
|
|
1047
|
+
addIssue(report, 'HOST_PLUGIN_NOT_DISCOVERED', { plugin_id: PLUGIN_ID });
|
|
1048
|
+
}
|
|
1049
|
+
if (!host.mcpOk) {
|
|
1050
|
+
addIssue(report, 'HOST_MCP_NOT_DISCOVERED', {
|
|
1051
|
+
plugin_id: PLUGIN_ID,
|
|
1052
|
+
server: MCP_SERVER_NAME,
|
|
1053
|
+
});
|
|
1054
|
+
}
|
|
1055
|
+
} catch (error) {
|
|
1056
|
+
report.checks.isolated_consumer_cli = { status: 'fail' };
|
|
1057
|
+
report.checks.isolated_consumer_plugin = { status: 'fail' };
|
|
1058
|
+
report.checks.isolated_consumer_mcp = { status: 'fail' };
|
|
1059
|
+
addIssue(
|
|
1060
|
+
report,
|
|
1061
|
+
error.code === 'HOST_CLI_TIMEOUT' ? 'HOST_CLI_TIMEOUT' : 'HOST_CLI_DOCTOR_FAILED',
|
|
1062
|
+
{ message: error.message },
|
|
1063
|
+
);
|
|
1064
|
+
}
|
|
1065
|
+
}
|
|
450
1066
|
return applyNativeDoctor(report, path.join(target, 'runtime'));
|
|
451
1067
|
}
|
|
452
1068
|
|
|
453
1069
|
function uninstall(ctx = {}) {
|
|
454
1070
|
const target = resolvePluginRoot(ctx);
|
|
455
1071
|
const registryFile = resolveRegistryFile(ctx);
|
|
1072
|
+
const registryExisted = fs.existsSync(registryFile);
|
|
1073
|
+
const registryBytes = registryExisted ? fs.readFileSync(registryFile) : null;
|
|
456
1074
|
const registry = loadRegistry(registryFile);
|
|
457
1075
|
const record = registry.plugins.find((entry) => entry && entry.id === PLUGIN_ID);
|
|
458
1076
|
if (record && path.resolve(record.root || '') !== path.resolve(target)) {
|
|
@@ -463,18 +1081,68 @@ function uninstall(ctx = {}) {
|
|
|
463
1081
|
}
|
|
464
1082
|
|
|
465
1083
|
const report = { target, registry: registryFile, removed: {} };
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
1084
|
+
const backup = path.join(
|
|
1085
|
+
path.dirname(target),
|
|
1086
|
+
`.${PLUGIN_ID}.uninstall-backup-${crypto.randomUUID()}`,
|
|
1087
|
+
);
|
|
1088
|
+
let pluginMoved = false;
|
|
1089
|
+
let registryPublicationAttempted = false;
|
|
1090
|
+
try {
|
|
1091
|
+
if (fs.existsSync(target)) {
|
|
1092
|
+
fs.renameSync(target, backup);
|
|
1093
|
+
pluginMoved = true;
|
|
1094
|
+
}
|
|
1095
|
+
if (record) {
|
|
1096
|
+
const expectedBytes = Buffer.from(`${JSON.stringify({
|
|
1097
|
+
version: 1,
|
|
1098
|
+
plugins: registry.plugins.filter((entry) => !entry || entry.id !== PLUGIN_ID),
|
|
1099
|
+
}, null, 2)}\n`);
|
|
1100
|
+
registryPublicationAttempted = true;
|
|
1101
|
+
writeAtomic(registryFile, expectedBytes);
|
|
1102
|
+
const readback = fs.readFileSync(registryFile);
|
|
1103
|
+
if (!readback.equals(expectedBytes)) {
|
|
1104
|
+
throw new Error(`Kimi registry readback mismatch after uninstall: ${registryFile}`);
|
|
1105
|
+
}
|
|
1106
|
+
const verified = loadRegistry(registryFile);
|
|
1107
|
+
if (verified.plugins.some((entry) => entry && entry.id === PLUGIN_ID)) {
|
|
1108
|
+
throw new Error(`Kimi registry still contains ${PLUGIN_ID} after uninstall`);
|
|
1109
|
+
}
|
|
1110
|
+
report.removed.registration = true;
|
|
1111
|
+
}
|
|
1112
|
+
if (pluginMoved) {
|
|
1113
|
+
removeTree(backup);
|
|
1114
|
+
report.removed.plugin = true;
|
|
1115
|
+
}
|
|
1116
|
+
return report;
|
|
1117
|
+
} catch (error) {
|
|
1118
|
+
const rollbackErrors = [];
|
|
1119
|
+
if (registryPublicationAttempted) {
|
|
1120
|
+
try {
|
|
1121
|
+
if (registryExisted) {
|
|
1122
|
+
writeAtomic(registryFile, registryBytes);
|
|
1123
|
+
} else if (fs.existsSync(registryFile)) {
|
|
1124
|
+
fs.unlinkSync(registryFile);
|
|
1125
|
+
}
|
|
1126
|
+
} catch (rollbackError) {
|
|
1127
|
+
rollbackErrors.push(rollbackError);
|
|
1128
|
+
}
|
|
1129
|
+
}
|
|
1130
|
+
if (pluginMoved && fs.existsSync(backup)) {
|
|
1131
|
+
try {
|
|
1132
|
+
if (fs.existsSync(target)) removeTree(target);
|
|
1133
|
+
fs.renameSync(backup, target);
|
|
1134
|
+
} catch (rollbackError) {
|
|
1135
|
+
rollbackErrors.push(rollbackError);
|
|
1136
|
+
}
|
|
1137
|
+
}
|
|
1138
|
+
if (rollbackErrors.length > 0) {
|
|
1139
|
+
throw new AggregateError(
|
|
1140
|
+
[error, ...rollbackErrors],
|
|
1141
|
+
'Kimi uninstall and rollback both failed',
|
|
1142
|
+
);
|
|
1143
|
+
}
|
|
1144
|
+
throw error;
|
|
476
1145
|
}
|
|
477
|
-
return report;
|
|
478
1146
|
}
|
|
479
1147
|
|
|
480
1148
|
module.exports = {
|
|
@@ -482,6 +1150,7 @@ module.exports = {
|
|
|
482
1150
|
PLUGIN_ID,
|
|
483
1151
|
MCP_SERVER_NAME,
|
|
484
1152
|
buildHooksManifest,
|
|
1153
|
+
inspectKimiHost,
|
|
485
1154
|
kimiTextTransform,
|
|
486
1155
|
resolveTarget,
|
|
487
1156
|
resolvePluginRoot,
|