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.
@@ -7,14 +7,17 @@
7
7
  /**
8
8
  * Launch the kibi-mcp package installed by the consumer project.
9
9
  *
10
- * Cursor starts plugin MCP commands with the plugin directory as their cwd.
11
- * This adapter deliberately knows nothing about the Kibi source tree: it only
12
- * resolves a consumer workspace, finds that workspace's kibi-mcp package, and
13
- * runs its declared bin with the consumer as both cwd and KIBI_WORKSPACE.
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 { existsSync, readFileSync, realpathSync, statSync } from "node:fs";
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 packageJsonForResolvedFile(startPath) {
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 (true) {
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
- const parent = dirname(current);
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(argv = process.argv.slice(2), env = process.env) {
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 = spawn(process.execPath, [projectLocal.binPath, ...childArgs], {
336
- cwd: workspaceRoot,
337
- env: { ...env, KIBI_WORKSPACE: workspaceRoot },
338
- stdio: ["inherit", "inherit", "inherit"],
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
- const entrypoint = process.argv[1] ? resolve(process.argv[1]) : undefined;
375
- if (entrypoint === resolve(fileURLToPath(import.meta.url))) {
376
- const exitCode = await launchKibiMcp(process.argv.slice(2));
377
- process.exitCode = exitCode;
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();
@@ -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(typeof chunk === "string" ? Buffer.from(chunk) : chunk);
98
+ chunks.push(Buffer.from(chunk));
96
99
  }
97
100
  return Buffer.concat(chunks).toString("utf8");
98
101
  }
@@ -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;
@@ -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
- const invokedPath = process.argv[1]
205
- ? pathToFileURL(path.resolve(process.argv[1])).href
206
- : "";
207
- if (import.meta.url === invokedPath) {
208
- main().catch((error) => {
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
- if (error instanceof Error &&
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(JSON.parse(fs.readFileSync(statePath(stateDir), "utf8")));
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();
@@ -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: options.impactCheckRun === true
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
  }
@@ -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
@@ -2,7 +2,10 @@
2
2
  "mcpServers": {
3
3
  "kibi": {
4
4
  "command": "node",
5
- "args": ["bin/launch-kibi-mcp.mjs", "${workspaceFolder}"]
5
+ "args": [
6
+ "${CURSOR_PLUGIN_ROOT}/bin/launch-kibi-mcp.mjs",
7
+ "${workspaceFolder}"
8
+ ]
6
9
  }
7
10
  }
8
11
  }
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
  }
@@ -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;