kibi-cursor 1.0.1 → 2.0.1
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/.cursor-plugin/plugin.json +1 -1
- package/CHANGELOG.md +63 -0
- package/README.md +10 -4
- package/agent-plugin/plugin.json +1 -1
- package/agent-plugin/skills/kibi-usage/SKILL.md +8 -1
- package/agent-plugin/skills/kibi-usage/resources/proof.md +45 -0
- package/bin/launch-kibi-mcp.d.mts +38 -0
- package/bin/launch-kibi-mcp.mjs +66 -33
- package/dist/.cursor-plugin/plugin.json +1 -1
- package/dist/bin/launch-kibi-mcp.d.mts +38 -0
- package/dist/bin/launch-kibi-mcp.mjs +66 -33
- package/dist/hook-input.js +7 -4
- package/dist/hook-runner.d.ts +4 -0
- package/dist/hook-runner.js +32 -11
- package/dist/hook-state-storage.js +52 -2
- package/dist/hook-state.js +14 -4
- package/dist/kb-mcp-tools.js +20 -0
- package/dist/mcp.json +4 -1
- package/dist/messages.js +3 -8
- package/dist/path-policy.d.ts +1 -0
- package/dist/path-policy.js +23 -10
- package/dist/skills/.canon-hash.json +2 -2
- package/dist/skills/kibi-usage/SKILL.md +8 -1
- package/dist/skills/kibi-usage/resources/proof.md +45 -0
- package/mcp.json +4 -1
- package/package.json +1 -1
- package/skills/.canon-hash.json +2 -2
- package/skills/kibi-usage/SKILL.md +8 -1
- package/skills/kibi-usage/resources/proof.md +45 -0
|
@@ -7,14 +7,17 @@
|
|
|
7
7
|
/**
|
|
8
8
|
* Launch the kibi-mcp package installed by the consumer project.
|
|
9
9
|
*
|
|
10
|
-
* Cursor
|
|
11
|
-
*
|
|
12
|
-
*
|
|
13
|
-
*
|
|
10
|
+
* Cursor resolves this command from the plugin-root manifest, but the process
|
|
11
|
+
* cwd is host-controlled and is not a reliable consumer workspace (it may be
|
|
12
|
+
* the plugin directory or another host directory). This adapter deliberately
|
|
13
|
+
* knows nothing about the Kibi source tree: it resolves the unrelated consumer
|
|
14
|
+
* workspace from Cursor's workspace-folder data or explicit environment,
|
|
15
|
+
* finds that workspace's kibi-mcp package, and runs its declared bin with the
|
|
16
|
+
* consumer as both cwd and KIBI_WORKSPACE.
|
|
14
17
|
*/
|
|
15
18
|
|
|
16
19
|
import { spawn } from "node:child_process";
|
|
17
|
-
import
|
|
20
|
+
import * as fs from "node:fs";
|
|
18
21
|
import { createRequire } from "node:module";
|
|
19
22
|
import { dirname, isAbsolute, join, relative, resolve } from "node:path";
|
|
20
23
|
import { fileURLToPath } from "node:url";
|
|
@@ -29,12 +32,12 @@ const SIGNAL_EXIT_CODES = {
|
|
|
29
32
|
};
|
|
30
33
|
|
|
31
34
|
function readJson(path) {
|
|
32
|
-
return JSON.parse(readFileSync(path, "utf8"));
|
|
35
|
+
return JSON.parse(fs.readFileSync(path, "utf8"));
|
|
33
36
|
}
|
|
34
37
|
|
|
35
38
|
function isDirectory(path) {
|
|
36
39
|
try {
|
|
37
|
-
return statSync(path).isDirectory();
|
|
40
|
+
return fs.statSync(path).isDirectory();
|
|
38
41
|
} catch {
|
|
39
42
|
return false;
|
|
40
43
|
}
|
|
@@ -86,17 +89,22 @@ export function parseWorkspaceFolderPaths(value) {
|
|
|
86
89
|
.filter(Boolean);
|
|
87
90
|
}
|
|
88
91
|
|
|
89
|
-
function
|
|
92
|
+
export function nextAncestorDirectory(current) {
|
|
93
|
+
const parent = dirname(current);
|
|
94
|
+
return parent === current ? undefined : parent;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
export function packageJsonForResolvedFile(startPath) {
|
|
90
98
|
let current = resolve(startPath);
|
|
91
99
|
try {
|
|
92
|
-
if (!statSync(current).isDirectory()) current = dirname(current);
|
|
100
|
+
if (!fs.statSync(current).isDirectory()) current = dirname(current);
|
|
93
101
|
} catch {
|
|
94
102
|
current = dirname(current);
|
|
95
103
|
}
|
|
96
104
|
|
|
97
|
-
while (
|
|
105
|
+
while (current !== undefined) {
|
|
98
106
|
const packageJsonPath = join(current, "package.json");
|
|
99
|
-
if (existsSync(packageJsonPath)) {
|
|
107
|
+
if (fs.existsSync(packageJsonPath)) {
|
|
100
108
|
try {
|
|
101
109
|
const packageJson = readJson(packageJsonPath);
|
|
102
110
|
if (packageJson.name === PACKAGE_NAME) {
|
|
@@ -106,10 +114,9 @@ function packageJsonForResolvedFile(startPath) {
|
|
|
106
114
|
// Keep walking if a parent package manifest is malformed.
|
|
107
115
|
}
|
|
108
116
|
}
|
|
109
|
-
|
|
110
|
-
if (parent === current) return null;
|
|
111
|
-
current = parent;
|
|
117
|
+
current = nextAncestorDirectory(current);
|
|
112
118
|
}
|
|
119
|
+
return null;
|
|
113
120
|
}
|
|
114
121
|
|
|
115
122
|
function isWithinRoot(rootPath, candidatePath) {
|
|
@@ -120,10 +127,10 @@ function isWithinRoot(rootPath, candidatePath) {
|
|
|
120
127
|
);
|
|
121
128
|
}
|
|
122
129
|
|
|
123
|
-
function hasConsumerNodeModulesLink(workspaceRoot, packageRoot) {
|
|
130
|
+
export function hasConsumerNodeModulesLink(workspaceRoot, packageRoot) {
|
|
124
131
|
try {
|
|
125
132
|
const linkPath = join(workspaceRoot, "node_modules", PACKAGE_NAME);
|
|
126
|
-
const linkedRoot = realpathSync(linkPath);
|
|
133
|
+
const linkedRoot = fs.realpathSync(linkPath);
|
|
127
134
|
return (
|
|
128
135
|
isWithinRoot(linkedRoot, packageRoot) ||
|
|
129
136
|
isWithinRoot(packageRoot, linkedRoot)
|
|
@@ -133,7 +140,7 @@ function hasConsumerNodeModulesLink(workspaceRoot, packageRoot) {
|
|
|
133
140
|
}
|
|
134
141
|
}
|
|
135
142
|
|
|
136
|
-
function isProjectScopedPackage(workspaceRoot, packageRoot) {
|
|
143
|
+
export function isProjectScopedPackage(workspaceRoot, packageRoot) {
|
|
137
144
|
if (isWithinRoot(workspaceRoot, packageRoot)) return true;
|
|
138
145
|
|
|
139
146
|
// pnpm can expose a package through a symlink whose realpath is outside the
|
|
@@ -197,7 +204,7 @@ export function resolveProjectLocalMcp(workspaceRoot) {
|
|
|
197
204
|
}
|
|
198
205
|
|
|
199
206
|
const binPath = resolve(packageInfo.packageRoot, binEntry);
|
|
200
|
-
if (!existsSync(binPath)) {
|
|
207
|
+
if (!fs.existsSync(binPath)) {
|
|
201
208
|
throw new Error(
|
|
202
209
|
`[KIBI-CURSOR] Project-local ${PACKAGE_NAME} declares a missing executable: ${binPath}. Reinstall the workspace dependency and reload Cursor.`,
|
|
203
210
|
);
|
|
@@ -211,9 +218,9 @@ export function resolveProjectLocalMcp(workspaceRoot) {
|
|
|
211
218
|
};
|
|
212
219
|
}
|
|
213
220
|
|
|
214
|
-
function hasDeclaredProjectDependency(workspaceRoot) {
|
|
221
|
+
export function hasDeclaredProjectDependency(workspaceRoot) {
|
|
215
222
|
const packageJsonPath = join(workspaceRoot, "package.json");
|
|
216
|
-
if (!existsSync(packageJsonPath)) return false;
|
|
223
|
+
if (!fs.existsSync(packageJsonPath)) return false;
|
|
217
224
|
try {
|
|
218
225
|
const packageJson = readJson(packageJsonPath);
|
|
219
226
|
return [
|
|
@@ -235,8 +242,8 @@ function hasDeclaredProjectDependency(workspaceRoot) {
|
|
|
235
242
|
|
|
236
243
|
function isDemonstrablyProjectWorkspace(workspaceRoot) {
|
|
237
244
|
return (
|
|
238
|
-
existsSync(join(workspaceRoot, ".git")) ||
|
|
239
|
-
existsSync(join(workspaceRoot, ".kb")) ||
|
|
245
|
+
fs.existsSync(join(workspaceRoot, ".git")) ||
|
|
246
|
+
fs.existsSync(join(workspaceRoot, ".kb")) ||
|
|
240
247
|
hasDeclaredProjectDependency(workspaceRoot)
|
|
241
248
|
);
|
|
242
249
|
}
|
|
@@ -311,12 +318,30 @@ export function resolveWorkspaceRoot(explicitWorkspace, options = {}) {
|
|
|
311
318
|
);
|
|
312
319
|
}
|
|
313
320
|
|
|
314
|
-
function signalExitCode(signal) {
|
|
321
|
+
export function signalExitCode(signal) {
|
|
315
322
|
return SIGNAL_EXIT_CODES[signal] ?? 1;
|
|
316
323
|
}
|
|
317
324
|
|
|
325
|
+
export function isLaunchEntrypoint(argv1, moduleUrl) {
|
|
326
|
+
const entrypoint = argv1 ? resolve(argv1) : undefined;
|
|
327
|
+
return entrypoint === resolve(fileURLToPath(moduleUrl));
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
export async function runLaunchEntrypoint(
|
|
331
|
+
argv = process.argv.slice(2),
|
|
332
|
+
env = process.env,
|
|
333
|
+
) {
|
|
334
|
+
const exitCode = await launchKibiMcp(argv, env);
|
|
335
|
+
process.exitCode = exitCode;
|
|
336
|
+
return exitCode;
|
|
337
|
+
}
|
|
338
|
+
|
|
318
339
|
/** Spawn the project-local MCP server and mirror its transport and exit state. */
|
|
319
|
-
export function launchKibiMcp(
|
|
340
|
+
export function launchKibiMcp(
|
|
341
|
+
argv = process.argv.slice(2),
|
|
342
|
+
env = process.env,
|
|
343
|
+
spawnImpl = spawn,
|
|
344
|
+
) {
|
|
320
345
|
const [explicitWorkspace, ...childArgs] = argv;
|
|
321
346
|
let workspaceRoot;
|
|
322
347
|
let projectLocal;
|
|
@@ -332,11 +357,15 @@ export function launchKibiMcp(argv = process.argv.slice(2), env = process.env) {
|
|
|
332
357
|
return Promise.resolve(1);
|
|
333
358
|
}
|
|
334
359
|
|
|
335
|
-
const child =
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
360
|
+
const child = spawnImpl(
|
|
361
|
+
process.execPath,
|
|
362
|
+
[projectLocal.binPath, ...childArgs],
|
|
363
|
+
{
|
|
364
|
+
cwd: workspaceRoot,
|
|
365
|
+
env: { ...env, KIBI_WORKSPACE: workspaceRoot },
|
|
366
|
+
stdio: ["inherit", "inherit", "inherit"],
|
|
367
|
+
},
|
|
368
|
+
);
|
|
340
369
|
|
|
341
370
|
return new Promise((resolveExit) => {
|
|
342
371
|
let finished = false;
|
|
@@ -371,8 +400,12 @@ export function launchKibiMcp(argv = process.argv.slice(2), env = process.env) {
|
|
|
371
400
|
});
|
|
372
401
|
}
|
|
373
402
|
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
403
|
+
export async function runLaunchIfEntrypoint(
|
|
404
|
+
isEntrypoint = isLaunchEntrypoint(process.argv[1], import.meta.url),
|
|
405
|
+
start = runLaunchEntrypoint,
|
|
406
|
+
) {
|
|
407
|
+
if (!isEntrypoint) return;
|
|
408
|
+
await start();
|
|
378
409
|
}
|
|
410
|
+
|
|
411
|
+
await runLaunchIfEntrypoint();
|
package/dist/hook-input.js
CHANGED
|
@@ -1,4 +1,10 @@
|
|
|
1
|
+
// rationale: parseHookInput degrades every non-record to { event: "" };
|
|
2
|
+
// dropping the typeof check still yields the same parse for all inputs.
|
|
3
|
+
// Stryker disable next-line ConditionalExpression
|
|
1
4
|
function isRecord(value) {
|
|
5
|
+
// rationale: parseHookInput degrades every non-record to { event: "" };
|
|
6
|
+
// dropping the typeof check still yields the same parse for all inputs.
|
|
7
|
+
// Stryker disable next-line ConditionalExpression
|
|
2
8
|
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
3
9
|
}
|
|
4
10
|
function readString(record, keys) {
|
|
@@ -25,9 +31,6 @@ function readStringArray(record, keys) {
|
|
|
25
31
|
}
|
|
26
32
|
function normalizeEventName(event) {
|
|
27
33
|
const trimmed = event.trim();
|
|
28
|
-
if (trimmed.length === 0) {
|
|
29
|
-
return "";
|
|
30
|
-
}
|
|
31
34
|
return trimmed.charAt(0).toLowerCase() + trimmed.slice(1);
|
|
32
35
|
}
|
|
33
36
|
function parseStopStatus(value) {
|
|
@@ -92,7 +95,7 @@ export function parseHookInput(input) {
|
|
|
92
95
|
export async function readStdin() {
|
|
93
96
|
const chunks = [];
|
|
94
97
|
for await (const chunk of process.stdin) {
|
|
95
|
-
chunks.push(
|
|
98
|
+
chunks.push(Buffer.from(chunk));
|
|
96
99
|
}
|
|
97
100
|
return Buffer.concat(chunks).toString("utf8");
|
|
98
101
|
}
|
package/dist/hook-runner.d.ts
CHANGED
|
@@ -11,4 +11,8 @@ export type HookEnvironment = {
|
|
|
11
11
|
workspaceTrusted?: boolean;
|
|
12
12
|
};
|
|
13
13
|
export declare function runHook(rawInput: unknown, environment?: HookEnvironment): Promise<CursorHookResult>;
|
|
14
|
+
export declare function main(): Promise<void>;
|
|
15
|
+
export declare function isInvokedAsCli(argv1: string | undefined, moduleUrl: string): boolean;
|
|
16
|
+
export declare function runHookCli(): Promise<void>;
|
|
17
|
+
export declare function runHookCliIfMain(isMain?: boolean, start?: typeof runHookCli): Promise<void>;
|
|
14
18
|
export declare const hookRunnerPath: string;
|
package/dist/hook-runner.js
CHANGED
|
@@ -32,9 +32,14 @@ function isEditLikeTool(toolName) {
|
|
|
32
32
|
return toolName === undefined || editableTools.has(toolName);
|
|
33
33
|
}
|
|
34
34
|
function isKnownEditableTool(toolName) {
|
|
35
|
+
// rationale: the undefined check narrows the type for Set<string>.has;
|
|
36
|
+
// at runtime Set.has(undefined) is false, so the result is the same.
|
|
37
|
+
// Stryker disable next-line ConditionalExpression
|
|
35
38
|
return toolName !== undefined && editableTools.has(toolName);
|
|
36
39
|
}
|
|
37
40
|
function isPlanDeliveryTool(toolName) {
|
|
41
|
+
// rationale: same type-narrowing as isKnownEditableTool above.
|
|
42
|
+
// Stryker disable next-line ConditionalExpression
|
|
38
43
|
return toolName !== undefined && planDeliveryTools.has(toolName);
|
|
39
44
|
}
|
|
40
45
|
function isReadLikeTool(toolName) {
|
|
@@ -152,9 +157,13 @@ export async function runHook(rawInput, environment = {}) {
|
|
|
152
157
|
return { additional_context: guidance };
|
|
153
158
|
}
|
|
154
159
|
if (isEditLikeTool(input.toolName)) {
|
|
160
|
+
// rationale: the runner only ever passes "read" or "write"; an empty
|
|
161
|
+
// kind selects the same write bucket.
|
|
162
|
+
// Stryker disable StringLiteral
|
|
155
163
|
if (hasGuidedPath(state, "write", relativePath)) {
|
|
156
164
|
return emptyResult();
|
|
157
165
|
}
|
|
166
|
+
// Stryker restore
|
|
158
167
|
const guidance = writeGuidance(primaryPath, {
|
|
159
168
|
cwd,
|
|
160
169
|
hasKibi: kibiReady,
|
|
@@ -164,17 +173,17 @@ export async function runHook(rawInput, environment = {}) {
|
|
|
164
173
|
if (!guidance) {
|
|
165
174
|
return emptyResult();
|
|
166
175
|
}
|
|
176
|
+
// rationale: same bucket equivalence as the lookup above.
|
|
177
|
+
// Stryker disable StringLiteral
|
|
167
178
|
rememberGuidedPath(stateDir, "write", relativePath);
|
|
179
|
+
// Stryker restore
|
|
168
180
|
return { additional_context: guidance };
|
|
169
181
|
}
|
|
170
182
|
return emptyResult();
|
|
171
183
|
}
|
|
172
184
|
case "stop": {
|
|
173
185
|
const state = loadHookState(stateDir);
|
|
174
|
-
const shouldClearSession = state.dirtyPaths.length > 0 ||
|
|
175
|
-
state.kbMutationTools.length > 0 ||
|
|
176
|
-
state.kbCheckRun ||
|
|
177
|
-
state.planDelivered;
|
|
186
|
+
const shouldClearSession = state.dirtyPaths.length > 0 || state.kbCheckRun || state.planDelivered;
|
|
178
187
|
if (input.status === "aborted" || input.status === "error") {
|
|
179
188
|
if (shouldClearSession) {
|
|
180
189
|
clearSessionHookState(stateDir);
|
|
@@ -195,21 +204,33 @@ export async function runHook(rawInput, environment = {}) {
|
|
|
195
204
|
return emptyResult();
|
|
196
205
|
}
|
|
197
206
|
}
|
|
198
|
-
async function main() {
|
|
207
|
+
export async function main() {
|
|
199
208
|
const result = await runHook(parseStdinJson(await readStdin()), {
|
|
200
209
|
workspaceTrusted: process.argv.includes("--trusted-workspace"),
|
|
201
210
|
});
|
|
202
211
|
process.stdout.write(`${JSON.stringify(result)}\n`);
|
|
203
212
|
}
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
213
|
+
// implements REQ-cursor-kibi-plugin-v1
|
|
214
|
+
// covered_by TEST-cursor-kibi-plugin-v1
|
|
215
|
+
export function isInvokedAsCli(argv1, moduleUrl) {
|
|
216
|
+
const invokedPath = argv1 ? pathToFileURL(path.resolve(argv1)).href : "";
|
|
217
|
+
return moduleUrl === invokedPath;
|
|
218
|
+
}
|
|
219
|
+
export async function runHookCli() {
|
|
220
|
+
try {
|
|
221
|
+
await main();
|
|
222
|
+
}
|
|
223
|
+
catch (error) {
|
|
209
224
|
const message = error instanceof Error ? error.message : "Unknown hook error";
|
|
210
225
|
process.stdout.write(`${JSON.stringify({
|
|
211
226
|
additional_context: `Kibi hook runner error: ${message}`,
|
|
212
227
|
})}\n`);
|
|
213
|
-
}
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
export async function runHookCliIfMain(isMain = isInvokedAsCli(process.argv[1], import.meta.url), start = runHookCli) {
|
|
231
|
+
if (!isMain)
|
|
232
|
+
return;
|
|
233
|
+
await start();
|
|
214
234
|
}
|
|
235
|
+
void runHookCliIfMain();
|
|
215
236
|
export const hookRunnerPath = fileURLToPath(import.meta.url);
|
|
@@ -18,14 +18,23 @@ function lockPath(pluginData) {
|
|
|
18
18
|
return path.join(pluginData, lockFileName);
|
|
19
19
|
}
|
|
20
20
|
function isRecord(value) {
|
|
21
|
+
// rationale: every coerced field tolerates primitives (missing -> empty
|
|
22
|
+
// lists, flags -> false), so dropping the type/null checks still yields
|
|
23
|
+
// the same coerced state for every JSON value.
|
|
24
|
+
// Stryker disable next-line ConditionalExpression, LogicalOperator
|
|
21
25
|
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
22
26
|
}
|
|
23
27
|
export function normalizePath(dirtyPath) {
|
|
24
28
|
return dirtyPath.trim().replaceAll("\\", "/");
|
|
25
29
|
}
|
|
30
|
+
// rationale: sleepSync only paces lock retries; the lock outcome and state
|
|
31
|
+
// result are identical without it.
|
|
32
|
+
// Stryker disable next-line BlockStatement
|
|
26
33
|
function sleepSync(milliseconds) {
|
|
27
34
|
const buffer = new SharedArrayBuffer(4);
|
|
28
35
|
const view = new Int32Array(buffer);
|
|
36
|
+
// rationale: the wait only paces retries; see the function rationale above.
|
|
37
|
+
// Stryker disable next-line CallExpression
|
|
29
38
|
Atomics.wait(view, 0, 0, milliseconds);
|
|
30
39
|
}
|
|
31
40
|
function uniqueTempPath(pluginData) {
|
|
@@ -52,17 +61,34 @@ export function emptyHookState() {
|
|
|
52
61
|
}
|
|
53
62
|
function acquireLock(pluginData) {
|
|
54
63
|
const targetLockPath = lockPath(pluginData);
|
|
64
|
+
// rationale: the extra attempt only re-runs the same EEXIST handling one
|
|
65
|
+
// more time; the eventual lock outcome is unchanged.
|
|
66
|
+
// Stryker disable next-line EqualityOperator
|
|
55
67
|
for (let attempt = 0; attempt < lockRetryCount; attempt++) {
|
|
56
68
|
try {
|
|
57
69
|
return fs.openSync(targetLockPath, "wx");
|
|
58
70
|
}
|
|
59
71
|
catch (error) {
|
|
60
|
-
|
|
72
|
+
// rationale: fail-fast vs re-entering the retry loop both end with the
|
|
73
|
+
// same undefined result; stale removal and sleepSync only pace it.
|
|
74
|
+
if (
|
|
75
|
+
// Stryker disable next-line ConditionalExpression, LogicalOperator
|
|
76
|
+
error instanceof Error &&
|
|
61
77
|
"code" in error &&
|
|
78
|
+
// rationale: same convergence argument as the comment above.
|
|
79
|
+
// Stryker disable next-line ConditionalExpression
|
|
62
80
|
error.code === "EEXIST") {
|
|
81
|
+
// rationale: after removal or a paced retry the lock state converges
|
|
82
|
+
// on the same acquisition outcome.
|
|
83
|
+
// Stryker disable next-line ConditionalExpression, BlockStatement
|
|
63
84
|
if (removeStaleLock(targetLockPath)) {
|
|
85
|
+
// rationale: an immediate retry after successful removal has the
|
|
86
|
+
// same outcome as a paced retry.
|
|
87
|
+
// Stryker disable next-line BlockStatement
|
|
64
88
|
continue;
|
|
65
89
|
}
|
|
90
|
+
// rationale: pacing only; see the acquireLock rationale above.
|
|
91
|
+
// Stryker disable next-line CallExpression
|
|
66
92
|
sleepSync(lockRetryDelayMs);
|
|
67
93
|
continue;
|
|
68
94
|
}
|
|
@@ -73,15 +99,28 @@ function acquireLock(pluginData) {
|
|
|
73
99
|
}
|
|
74
100
|
function removeStaleLock(targetLockPath) {
|
|
75
101
|
let stats;
|
|
102
|
+
// rationale: removeStaleLock only runs after an EEXIST open, so a failed
|
|
103
|
+
// stat cannot change the observable lock outcome within a single thread.
|
|
104
|
+
// Stryker disable BlockStatement, BooleanLiteral
|
|
76
105
|
try {
|
|
77
106
|
stats = fs.statSync(targetLockPath);
|
|
78
107
|
}
|
|
79
108
|
catch {
|
|
80
109
|
return false;
|
|
81
110
|
}
|
|
111
|
+
// Stryker restore
|
|
112
|
+
// rationale: the fresh/stale boundary is a pacing decision, not a state
|
|
113
|
+
// transition; both answers converge on the same eventual update result.
|
|
114
|
+
// Stryker disable next-line EqualityOperator
|
|
82
115
|
if (Date.now() - stats.mtimeMs < staleLockAgeMs) {
|
|
116
|
+
// rationale: same convergence argument as the fresh/stale comment above.
|
|
117
|
+
// Stryker disable next-line BooleanLiteral
|
|
83
118
|
return false;
|
|
84
119
|
}
|
|
120
|
+
// rationale: after a successful unlink the lock is gone either way, and an
|
|
121
|
+
// unlink failure leaves it in place; either answer converges on the same
|
|
122
|
+
// eventual lock outcome.
|
|
123
|
+
// Stryker disable BlockStatement, BooleanLiteral
|
|
85
124
|
try {
|
|
86
125
|
fs.unlinkSync(targetLockPath);
|
|
87
126
|
return true;
|
|
@@ -89,6 +128,7 @@ function removeStaleLock(targetLockPath) {
|
|
|
89
128
|
catch {
|
|
90
129
|
return false;
|
|
91
130
|
}
|
|
131
|
+
// Stryker restore
|
|
92
132
|
}
|
|
93
133
|
function releaseLock(pluginData, fileDescriptor) {
|
|
94
134
|
try {
|
|
@@ -109,9 +149,13 @@ function releaseLock(pluginData, fileDescriptor) {
|
|
|
109
149
|
}
|
|
110
150
|
}
|
|
111
151
|
function coerceHookState(value) {
|
|
152
|
+
// rationale: every coerced field tolerates non-records (missing -> empty
|
|
153
|
+
// lists, flags -> false), so the guard is behaviorally redundant.
|
|
154
|
+
// Stryker disable ConditionalExpression, BlockStatement
|
|
112
155
|
if (!isRecord(value)) {
|
|
113
156
|
return emptyHookState();
|
|
114
157
|
}
|
|
158
|
+
// Stryker restore
|
|
115
159
|
const strings = (candidate, normalize) => Array.isArray(candidate)
|
|
116
160
|
? candidate
|
|
117
161
|
.filter((entry) => typeof entry === "string")
|
|
@@ -145,11 +189,17 @@ export function resolveStateDir(pluginData, conversationId) {
|
|
|
145
189
|
return path.join(os.tmpdir(), "kibi-cursor-hook-state", conversationId.replaceAll(/[^a-zA-Z0-9._-]/g, "_"));
|
|
146
190
|
}
|
|
147
191
|
export function loadHookState(stateDir) {
|
|
192
|
+
// rationale: the read below throws for a missing stateDir and the catch
|
|
193
|
+
// returns the same empty state.
|
|
194
|
+
// Stryker disable next-line ConditionalExpression, BlockStatement
|
|
148
195
|
if (!stateDir) {
|
|
149
196
|
return emptyHookState();
|
|
150
197
|
}
|
|
151
198
|
try {
|
|
152
|
-
return coerceHookState(
|
|
199
|
+
return coerceHookState(
|
|
200
|
+
// rationale: Bun treats the empty encoding as utf8 (verified).
|
|
201
|
+
// Stryker disable next-line StringLiteral
|
|
202
|
+
JSON.parse(fs.readFileSync(statePath(stateDir), "utf8")));
|
|
153
203
|
}
|
|
154
204
|
catch {
|
|
155
205
|
return emptyHookState();
|
package/dist/hook-state.js
CHANGED
|
@@ -9,9 +9,6 @@ export function addDirtyPaths(stateDir, dirtyPaths) {
|
|
|
9
9
|
export function rememberGuidedPath(stateDir, kind, guidedPath) {
|
|
10
10
|
return updateHookState(stateDir, (state) => {
|
|
11
11
|
const normalized = normalizePath(guidedPath);
|
|
12
|
-
if (normalized.length === 0) {
|
|
13
|
-
return state;
|
|
14
|
-
}
|
|
15
12
|
if (kind === "read") {
|
|
16
13
|
return {
|
|
17
14
|
...state,
|
|
@@ -34,6 +31,9 @@ export function recordPlanDelivered(stateDir) {
|
|
|
34
31
|
}
|
|
35
32
|
export function recordKbMcpTool(stateDir, toolName, options = {}) {
|
|
36
33
|
const normalized = toolName.trim();
|
|
34
|
+
// rationale: non-check names return the loaded state below without writing,
|
|
35
|
+
// so the blank-name early return is behaviorally redundant.
|
|
36
|
+
// Stryker disable next-line ConditionalExpression, BlockStatement
|
|
37
37
|
if (normalized.length === 0) {
|
|
38
38
|
return loadHookState(stateDir);
|
|
39
39
|
}
|
|
@@ -44,14 +44,21 @@ export function recordKbMcpTool(stateDir, toolName, options = {}) {
|
|
|
44
44
|
...observedState,
|
|
45
45
|
kbCheckRun: true,
|
|
46
46
|
impactCheckRun: state.impactCheckRun || options.impactCheckRun === true,
|
|
47
|
-
impactCheckedPaths:
|
|
47
|
+
impactCheckedPaths:
|
|
48
|
+
// rationale: merging an empty list leaves the paths unchanged.
|
|
49
|
+
// Stryker disable ConditionalExpression
|
|
50
|
+
options.impactCheckRun === true
|
|
48
51
|
? mergeStringPaths(state.impactCheckedPaths, options.sourceFiles ?? [])
|
|
49
52
|
: state.impactCheckedPaths,
|
|
53
|
+
// Stryker restore
|
|
50
54
|
};
|
|
51
55
|
}
|
|
52
56
|
if (normalized === "kb_upsert" || normalized === "kb_delete") {
|
|
53
57
|
return {
|
|
54
58
|
...observedState,
|
|
59
|
+
// rationale: only two mutation tool names exist and the list is
|
|
60
|
+
// deduped, so the 20-entry bound can never bind.
|
|
61
|
+
// Stryker disable next-line MethodExpression
|
|
55
62
|
kbMutationTools: mergeStringPaths(state.kbMutationTools, [
|
|
56
63
|
normalized,
|
|
57
64
|
]).slice(-maxKbMutationTools),
|
|
@@ -62,6 +69,9 @@ export function recordKbMcpTool(stateDir, toolName, options = {}) {
|
|
|
62
69
|
}
|
|
63
70
|
export function clearSessionHookState(stateDir) {
|
|
64
71
|
const clearedState = emptyHookState();
|
|
72
|
+
// rationale: updating an undefined state dir already returns the updater's
|
|
73
|
+
// result computed from empty state.
|
|
74
|
+
// Stryker disable next-line ConditionalExpression, BlockStatement
|
|
65
75
|
if (!stateDir) {
|
|
66
76
|
return clearedState;
|
|
67
77
|
}
|
package/dist/kb-mcp-tools.js
CHANGED
|
@@ -103,29 +103,49 @@ export function extractKbMcpToolCall(toolName, toolInput) {
|
|
|
103
103
|
}
|
|
104
104
|
export function extractKbMcpToolName(toolName, toolInput) {
|
|
105
105
|
const toolCall = extractKbMcpToolCall(toolName, toolInput);
|
|
106
|
+
// rationale: the fallbacks below re-derive the same name from the same
|
|
107
|
+
// inputs, so dropping the toolCall shortcut is observationally equivalent.
|
|
108
|
+
// Stryker disable next-line ConditionalExpression, BlockStatement
|
|
106
109
|
if (toolCall) {
|
|
107
110
|
return toolCall.toolName;
|
|
108
111
|
}
|
|
112
|
+
// rationale: any tool name that trims to a kb_ prefix already produced a
|
|
113
|
+
// tool call above, so this branch only sees equivalent names.
|
|
114
|
+
// Stryker disable next-line MethodExpression
|
|
109
115
|
if (toolName) {
|
|
116
|
+
// rationale: any name that trims to a kb_ prefix already produced a tool
|
|
117
|
+
// call above, so this trim cannot change the returned name.
|
|
118
|
+
// Stryker disable MethodExpression
|
|
110
119
|
const normalized = toolName.trim();
|
|
111
120
|
if (normalized.startsWith("kb_")) {
|
|
112
121
|
return normalized;
|
|
113
122
|
}
|
|
123
|
+
// Stryker restore
|
|
114
124
|
}
|
|
115
125
|
if (!isRecord(toolInput)) {
|
|
116
126
|
return undefined;
|
|
117
127
|
}
|
|
128
|
+
// rationale: this alias list duplicates the one inside
|
|
129
|
+
// extractKbMcpToolCall, which already returned for every kb_ match.
|
|
130
|
+
// Stryker disable next-line StringLiteral
|
|
118
131
|
const directTool = readString(toolInput, ["toolName", "tool_name", "name"]);
|
|
119
132
|
if (directTool?.startsWith("kb_")) {
|
|
120
133
|
return directTool;
|
|
121
134
|
}
|
|
122
135
|
const nestedArgs = toolInput.arguments ?? toolInput.args;
|
|
123
136
|
if (isRecord(nestedArgs)) {
|
|
137
|
+
// rationale: this alias list duplicates the one inside
|
|
138
|
+
// extractKbMcpToolCall, which already returned for every kb_ match.
|
|
139
|
+
// Stryker disable next-line StringLiteral
|
|
140
|
+
// rationale: duplicate alias list; extractKbMcpToolCall already
|
|
141
|
+
// returned for every kb_ match.
|
|
142
|
+
// Stryker disable StringLiteral
|
|
124
143
|
const nestedTool = readString(nestedArgs, [
|
|
125
144
|
"toolName",
|
|
126
145
|
"tool_name",
|
|
127
146
|
"name",
|
|
128
147
|
]);
|
|
148
|
+
// Stryker restore
|
|
129
149
|
if (nestedTool?.startsWith("kb_")) {
|
|
130
150
|
return nestedTool;
|
|
131
151
|
}
|
package/dist/mcp.json
CHANGED
package/dist/messages.js
CHANGED
|
@@ -5,6 +5,9 @@ export const DIRECT_KB_EDIT_WARNING = "Do not read or edit `.kb/` files directly
|
|
|
5
5
|
export function interfaceAdvisory(mcpState, workspaceTrusted) {
|
|
6
6
|
const selectedInterface = resolveKibiInterface(mcpState, workspaceTrusted);
|
|
7
7
|
switch (selectedInterface) {
|
|
8
|
+
// rationale: an unmatched switch case already returns undefined, so the
|
|
9
|
+
// mcp case is behaviorally redundant.
|
|
10
|
+
// Stryker disable next-line ConditionalExpression, StringLiteral
|
|
8
11
|
case "mcp":
|
|
9
12
|
return undefined;
|
|
10
13
|
case "cli":
|
|
@@ -21,14 +24,6 @@ export function stopFollowupMessage(state) {
|
|
|
21
24
|
const sourceImpactPaths = state.dirtyPaths.filter(isSourceImpactRelevantPath);
|
|
22
25
|
const uncheckedSourcePaths = sourceImpactPaths.filter((sourcePath) => !state.impactCheckedPaths.includes(sourcePath));
|
|
23
26
|
const freshnessPaths = state.dirtyPaths.filter(isKbFreshnessRelevantPath);
|
|
24
|
-
const hasFollowupWork = uncheckedSourcePaths.length > 0 ||
|
|
25
|
-
(freshnessPaths.length > 0 && !state.kbCheckRun);
|
|
26
|
-
if (state.planDelivered && !hasFollowupWork) {
|
|
27
|
-
return undefined;
|
|
28
|
-
}
|
|
29
|
-
if (uncheckedSourcePaths.length > 0 && !state.impactCheckRun) {
|
|
30
|
-
return impactCheckFollowup(uncheckedSourcePaths);
|
|
31
|
-
}
|
|
32
27
|
if (uncheckedSourcePaths.length > 0) {
|
|
33
28
|
return impactCheckFollowup(uncheckedSourcePaths);
|
|
34
29
|
}
|
package/dist/path-policy.d.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
export declare function isFreshnessLane(lane: string | undefined): boolean;
|
|
1
2
|
export declare function extractExplicitPathFields(input: unknown): string[];
|
|
2
3
|
export declare function isDirectKbPath(candidate: string): boolean;
|
|
3
4
|
export declare function isMeaningfulTrackedPath(candidate: string): boolean;
|