kibi-mcp 0.3.1 → 0.3.2

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.
@@ -101,6 +101,7 @@ export async function initiateGracefulShutdown(exitCode = 0) {
101
101
  // Exit
102
102
  process.exit(exitCode);
103
103
  }
104
+ // implements REQ-008
104
105
  async function ensurePrologUnsafe() {
105
106
  const workspaceRoot = resolveWorkspaceRoot();
106
107
  // Determine target branch: respect KIBI_BRANCH override or resolve from git
@@ -131,7 +132,11 @@ async function ensurePrologUnsafe() {
131
132
  }
132
133
  // Branch changed - need to detach and re-attach
133
134
  debugLog(`[KIBI-MCP] Branch changed: ${activeBranchName} -> ${targetBranch}`);
134
- // Detach from old KB
135
+ // Persist and detach from old KB
136
+ const saveResult = await prologProcess.query("kb_save");
137
+ if (!saveResult.success) {
138
+ throw new Error(`Failed to save old KB before detach: ${saveResult.error || "Unknown error"}`);
139
+ }
135
140
  const detachResult = await prologProcess.query("kb_detach");
136
141
  if (!detachResult.success) {
137
142
  debugLog(`[KIBI-MCP] Warning: failed to detach from old KB: ${detachResult.error || "Unknown error"}`);
@@ -14,11 +14,11 @@
14
14
 
15
15
  You should have received a copy of the GNU Affero General Public License
16
16
  along with this program. If not, see <https://www.gnu.org/licenses/>.
17
- */
18
- import { existsSync } from "node:fs";
17
+ */
18
+ import { existsSync, readFileSync } from "node:fs";
19
19
  import { createRequire } from "node:module";
20
20
  import * as path from "node:path";
21
- import { parsePairList } from "./prolog-list.js";
21
+ import { resolveWorkspaceRoot } from "../workspace.js";
22
22
  const require = createRequire(import.meta.url);
23
23
  function resolveChecksPlPath() {
24
24
  const overrideChecksPath = process.env.KIBI_CHECKS_PL_PATH;
@@ -38,6 +38,23 @@ function resolveChecksPlPath() {
38
38
  }
39
39
  throw new Error("Unable to resolve checks.pl path");
40
40
  }
41
+ const ALL_RULES = [
42
+ "must-priority-coverage",
43
+ "no-dangling-refs",
44
+ "no-cycles",
45
+ "required-fields",
46
+ "symbol-coverage",
47
+ "symbol-traceability",
48
+ "deprecated-adr-no-successor",
49
+ "domain-contradictions",
50
+ ];
51
+ const RULE_NAMES = new Set(ALL_RULES);
52
+ const DEFAULT_CHECKS_CONFIG = {
53
+ rules: Object.fromEntries(ALL_RULES.map((rule) => [rule, true])),
54
+ symbolTraceability: {
55
+ requireAdr: false,
56
+ },
57
+ };
41
58
  function formatDiagnosticsForMcp(diagnostics) {
42
59
  return diagnostics.map((d) => ({
43
60
  category: d.category,
@@ -47,85 +64,96 @@ function formatDiagnosticsForMcp(diagnostics) {
47
64
  suggestion: d.suggestion,
48
65
  }));
49
66
  }
67
+ // implements REQ-002
68
+ function loadChecksConfig(workspaceRoot) {
69
+ const configPath = path.join(workspaceRoot, ".kb", "config.json");
70
+ if (!existsSync(configPath)) {
71
+ return DEFAULT_CHECKS_CONFIG;
72
+ }
73
+ try {
74
+ const content = readFileSync(configPath, "utf8");
75
+ const parsed = JSON.parse(content);
76
+ const parsedRules = parsed.checks?.rules;
77
+ const normalizedRules = {
78
+ ...DEFAULT_CHECKS_CONFIG.rules,
79
+ };
80
+ if (parsedRules && typeof parsedRules === "object") {
81
+ for (const [key, value] of Object.entries(parsedRules)) {
82
+ if (typeof value === "boolean") {
83
+ normalizedRules[key] = value;
84
+ }
85
+ // Ignore non-boolean values (they are not added to normalizedRules, preserving defaults)
86
+ }
87
+ }
88
+ const parsedSt = parsed.checks?.symbolTraceability;
89
+ const normalizedSt = { ...DEFAULT_CHECKS_CONFIG.symbolTraceability };
90
+ if (parsedSt && typeof parsedSt === "object") {
91
+ if (typeof parsedSt.requireAdr === "boolean") {
92
+ normalizedSt.requireAdr = parsedSt.requireAdr;
93
+ }
94
+ }
95
+ return {
96
+ rules: normalizedRules,
97
+ symbolTraceability: normalizedSt,
98
+ };
99
+ }
100
+ catch {
101
+ return DEFAULT_CHECKS_CONFIG;
102
+ }
103
+ }
104
+ // implements REQ-002
105
+ function getEffectiveRules(configRules, requestedRules) {
106
+ const effective = new Set();
107
+ for (const rule of ALL_RULES) {
108
+ const enabled = configRules[rule] ?? true;
109
+ if (enabled) {
110
+ effective.add(rule);
111
+ }
112
+ }
113
+ if (requestedRules && requestedRules.length > 0) {
114
+ const allowed = new Set(requestedRules.filter((rule) => RULE_NAMES.has(rule)));
115
+ for (const rule of Array.from(effective)) {
116
+ if (!allowed.has(rule)) {
117
+ effective.delete(rule);
118
+ }
119
+ }
120
+ }
121
+ return effective;
122
+ }
50
123
  /**
51
124
  * Handle kb_check tool calls - run validation rules on the KB
52
125
  * Reuses validation logic from CLI check command
53
126
  */
127
+ // implements REQ-002
54
128
  export async function handleKbCheck(prolog, args) {
55
129
  const { rules } = args;
56
130
  try {
57
- const violations = [];
58
- let allEntityIds = null;
59
- // Run all validation rules (or specific rules if provided)
60
- const allRules = [
61
- "must-priority-coverage",
62
- "no-dangling-refs",
63
- "no-cycles",
64
- "required-fields",
65
- "symbol-coverage",
66
- "symbol-traceability",
67
- "deprecated-adr-no-successor",
68
- "domain-contradictions",
69
- ];
70
- const rulesToRun = rules && rules.length > 0 ? rules : allRules;
71
- const rulesAllowlist = new Set(rulesToRun);
72
- const aggregatedViolations = await runAggregatedChecks(prolog, rulesAllowlist);
73
- if (aggregatedViolations) {
74
- const diagnostics = aggregatedViolations.map((v) => ({
75
- category: "SYNC_ERROR",
76
- severity: "error",
77
- message: v.description,
78
- file: v.source,
79
- suggestion: v.suggestion,
80
- }));
81
- const summary = aggregatedViolations.length === 0
82
- ? "No violations found"
83
- : `${aggregatedViolations.length} violations found`;
131
+ const workspaceRoot = resolveWorkspaceRoot();
132
+ const checksConfig = loadChecksConfig(workspaceRoot);
133
+ const rulesAllowlist = getEffectiveRules(checksConfig.rules, rules);
134
+ if (rulesAllowlist.size === 0) {
84
135
  return {
85
- content: [
86
- {
87
- type: "text",
88
- text: summary,
89
- },
90
- ],
136
+ content: [{ type: "text", text: "No violations found" }],
91
137
  structuredContent: {
92
- violations: aggregatedViolations,
93
- count: aggregatedViolations.length,
94
- diagnostics: formatDiagnosticsForMcp(diagnostics),
138
+ violations: [],
139
+ count: 0,
140
+ diagnostics: [],
95
141
  },
96
142
  };
97
143
  }
98
- if (rulesToRun.includes("must-priority-coverage")) {
99
- violations.push(...(await checkMustPriorityCoverage(prolog)));
100
- }
101
- if (rulesToRun.includes("no-dangling-refs")) {
102
- violations.push(...(await checkNoDanglingRefs(prolog)));
103
- }
104
- if (rulesToRun.includes("no-cycles")) {
105
- violations.push(...(await checkNoCycles(prolog)));
106
- }
107
- if (rulesToRun.includes("required-fields")) {
108
- if (!allEntityIds) {
109
- allEntityIds = await getAllEntityIds(prolog);
110
- }
111
- violations.push(...(await checkRequiredFields(prolog, allEntityIds)));
112
- }
113
- if (rulesToRun.includes("symbol-coverage")) {
114
- violations.push(...(await checkSymbolCoverage(prolog)));
115
- }
116
- if (rulesToRun.includes("symbol-traceability")) {
117
- violations.push(...(await checkSymbolTraceability(prolog, false)));
118
- }
119
- const diagnostics = violations.map((v) => ({
144
+ // Run aggregated checks using same approach as CLI
145
+ // This now runs ALL rules including symbol-traceability
146
+ const aggregatedViolations = await runAggregatedChecks(prolog, rulesAllowlist, checksConfig.symbolTraceability.requireAdr);
147
+ const diagnostics = aggregatedViolations.map((v) => ({
120
148
  category: "SYNC_ERROR",
121
149
  severity: "error",
122
150
  message: v.description,
123
151
  file: v.source,
124
152
  suggestion: v.suggestion,
125
153
  }));
126
- const summary = violations.length === 0
154
+ const summary = aggregatedViolations.length === 0
127
155
  ? "No violations found"
128
- : `${violations.length} violations found`;
156
+ : `${aggregatedViolations.length} violations found`;
129
157
  return {
130
158
  content: [
131
159
  {
@@ -134,8 +162,8 @@ export async function handleKbCheck(prolog, args) {
134
162
  },
135
163
  ],
136
164
  structuredContent: {
137
- violations,
138
- count: violations.length,
165
+ violations: aggregatedViolations,
166
+ count: aggregatedViolations.length,
139
167
  diagnostics: formatDiagnosticsForMcp(diagnostics),
140
168
  },
141
169
  };
@@ -145,341 +173,49 @@ export async function handleKbCheck(prolog, args) {
145
173
  throw new Error(`Check execution failed: ${message}`);
146
174
  }
147
175
  }
148
- async function runAggregatedChecks(prolog, rulesAllowlist) {
176
+ // implements REQ-002
177
+ async function runAggregatedChecks(prolog, rulesAllowlist, requireAdr) {
178
+ const violations = [];
149
179
  const checksPlPath = resolveChecksPlPath();
150
180
  const normalizedChecksPlPath = checksPlPath.replace(/\\/g, "/");
151
181
  const checksPlPathEscaped = normalizedChecksPlPath.replace(/'/g, "''");
152
- const violations = [];
153
- const ruleToPredicate = {
154
- "must-priority-coverage": "check_must_priority_coverage",
155
- "no-dangling-refs": "check_no_dangling_refs",
156
- "no-cycles": "check_no_cycles",
157
- "required-fields": "check_required_fields",
158
- "symbol-coverage": "check_symbol_coverage",
159
- };
160
- for (const rule of rulesAllowlist) {
161
- const predicate = ruleToPredicate[rule];
162
- if (!predicate) {
163
- continue;
164
- }
165
- const query = `(use_module('${checksPlPathEscaped}'), call(checks:${predicate}(Violations)), findall(_{rule:Rule,entityId:EntityId,description:Description,suggestion:Suggestion,source:Source}, member(violation(Rule, EntityId, Description, Suggestion, Source), Violations), Rows), call(checks:atom_json_dict(JsonString, Rows, [])))`;
166
- const result = await prolog.query(query);
167
- if (!result.success || !result.bindings.JsonString) {
168
- return null;
169
- }
170
- let parsedRows;
171
- try {
172
- parsedRows = JSON.parse(result.bindings.JsonString);
173
- if (typeof parsedRows === "string") {
174
- parsedRows = JSON.parse(parsedRows);
175
- }
176
- }
177
- catch {
178
- return null;
179
- }
180
- if (!Array.isArray(parsedRows)) {
181
- return null;
182
- }
183
- for (const row of parsedRows) {
184
- if (!row || typeof row !== "object") {
185
- continue;
186
- }
187
- const raw = row;
188
- const rule = typeof raw.rule === "string" ? raw.rule : "";
189
- if (!rulesAllowlist.has(rule)) {
190
- continue;
191
- }
192
- const entityId = typeof raw.entityId === "string"
193
- ? raw.entityId
194
- : typeof raw.entity_id === "string"
195
- ? raw.entity_id
196
- : "";
197
- const description = typeof raw.description === "string" ? raw.description : "";
198
- const suggestion = typeof raw.suggestion === "string" ? raw.suggestion : undefined;
199
- const source = typeof raw.source === "string" ? raw.source : undefined;
200
- if (!rule || !entityId || !description) {
201
- continue;
202
- }
203
- violations.push({
204
- rule,
205
- entityId,
206
- description,
207
- suggestion,
208
- source,
209
- });
210
- }
211
- }
212
- return violations;
213
- }
214
- async function checkSymbolTraceability(prolog, requireAdr) {
215
- const violations = [];
182
+ // Use check_all_json_with_options if available, otherwise fall back to check_all_json
216
183
  const requireAdrStr = requireAdr ? "true" : "false";
217
- const result = await prolog.query(`findall(violation(Rule, EntityId, Desc, Sugg, Src), checks:symbol_traceability_violation(${requireAdrStr}, violation(Rule, EntityId, Desc, Sugg, Src)), Violations)`);
218
- if (!result.success || !result.bindings.Violations) {
219
- return violations;
220
- }
221
- const violationsStr = result.bindings.Violations;
222
- if (violationsStr && violationsStr !== "[]") {
223
- const violationRegex = /violation\(([^,]+),'?([^',]+)'?,([^,]+),([^,]+),'?([^']*)'?\)/g;
224
- let match;
225
- do {
226
- match = violationRegex.exec(violationsStr);
227
- if (match) {
228
- violations.push({
229
- rule: match[1].trim().replace(/^'|'$/g, ""),
230
- entityId: match[2].trim(),
231
- description: match[3].trim().replace(/^"|"$/g, ""),
232
- suggestion: match[4].trim().replace(/^"|"$/g, ""),
233
- source: match[5].trim() || undefined,
234
- });
235
- }
236
- } while (match);
237
- }
238
- return violations;
239
- }
240
- async function checkMustPriorityCoverage(prolog) {
241
- const violations = [];
242
- const gapsResult = await prolog.query("setof([Req,Reason], coverage_gap(Req, Reason), Rows)");
243
- if (!gapsResult.success || !gapsResult.bindings.Rows) {
244
- return violations;
245
- }
246
- const gaps = parsePairList(gapsResult.bindings.Rows);
247
- for (const [reqId, reason] of gaps) {
248
- const entityResult = await prolog.query(`kb_entity('${reqId}', req, Props)`);
249
- let source = "";
250
- if (entityResult.success && entityResult.bindings.Props) {
251
- const propsStr = entityResult.bindings.Props;
252
- const sourceMatch = propsStr.match(/source\s*=\s*\^\^?\("([^"]+)"/);
253
- if (sourceMatch) {
254
- source = sourceMatch[1];
255
- }
256
- }
257
- const missing = [];
258
- if (reason.includes("scenario")) {
259
- missing.push("scenario");
260
- }
261
- if (reason.includes("test")) {
262
- missing.push("test");
263
- }
264
- violations.push({
265
- rule: "must-priority-coverage",
266
- entityId: reqId,
267
- description: `Must-priority requirement lacks ${missing.join(" and ")} coverage`,
268
- source,
269
- suggestion: missing
270
- .map((m) => `Create ${m} that covers this requirement`)
271
- .join("; "),
272
- });
273
- }
274
- return violations;
275
- }
276
- async function getAllEntityIds(prolog, type) {
277
- const typeFilter = type ? `, Type = ${type}` : "";
278
- const query = `findall(Id, (kb_entity(Id, Type, _)${typeFilter}), Ids)`;
184
+ const query = `(use_module('${checksPlPathEscaped}'),
185
+ ( predicate_property(checks:check_all_json_with_options(_, _), _)
186
+ -> call(checks:check_all_json_with_options(JsonString, ${requireAdrStr}))
187
+ ; call(checks:check_all_json(JsonString))
188
+ ))`;
279
189
  const result = await prolog.query(query);
280
- if (!result.success || !result.bindings.Ids) {
281
- return [];
282
- }
283
- const idsStr = result.bindings.Ids;
284
- const match = idsStr.match(/\[(.*)\]/);
285
- if (!match) {
286
- return [];
287
- }
288
- const content = match[1].trim();
289
- if (!content) {
290
- return [];
190
+ if (!result.success) {
191
+ throw new Error(`Aggregated checks query failed: ${result.error || "Unknown error"}`);
291
192
  }
292
- return content.split(",").map((id) => id.trim().replace(/^'|'$/g, ""));
293
- }
294
- async function checkNoDanglingRefs(prolog) {
295
- const violations = [];
296
- // Get all entity IDs once
297
- const allEntityIds = new Set(await getAllEntityIds(prolog));
298
- // Get all relationships by querying all known relationship types
299
- const relTypes = [
300
- "depends_on",
301
- "verified_by",
302
- "validates",
303
- "specified_by",
304
- "relates_to",
305
- ];
306
- const allRels = [];
307
- for (const relType of relTypes) {
308
- const relsResult = await prolog.query(`findall([From,To], kb_relationship(${relType}, From, To), Rels)`);
309
- if (relsResult.success && relsResult.bindings.Rels) {
310
- const relsStr = relsResult.bindings.Rels;
311
- const match = relsStr.match(/\[(.*)\]/);
312
- if (match) {
313
- const content = match[1].trim();
314
- if (content) {
315
- const relMatches = content.matchAll(/\[([^,]+),([^\]]+)\]/g);
316
- for (const relMatch of relMatches) {
317
- const fromId = relMatch[1].trim().replace(/^'|'$/g, "");
318
- const toId = relMatch[2].trim().replace(/^'|'$/g, "");
319
- allRels.push({ from: fromId, to: toId });
320
- }
321
- }
322
- }
323
- }
324
- }
325
- // Check all collected relationships for dangling refs
326
- for (const rel of allRels) {
327
- if (!allEntityIds.has(rel.from)) {
328
- violations.push({
329
- rule: "no-dangling-refs",
330
- entityId: rel.from,
331
- description: `Relationship references non-existent entity: ${rel.from}`,
332
- suggestion: "Remove relationship or create missing entity",
333
- });
334
- }
335
- if (!allEntityIds.has(rel.to)) {
336
- violations.push({
337
- rule: "no-dangling-refs",
338
- entityId: rel.to,
339
- description: `Relationship references non-existent entity: ${rel.to}`,
340
- suggestion: "Remove relationship or create missing entity",
341
- });
342
- }
343
- }
344
- return violations;
345
- }
346
- async function checkNoCycles(prolog) {
347
- const violations = [];
348
- // Get all depends_on relationships
349
- const depsResult = await prolog.query("findall([From,To], kb_relationship(depends_on, From, To), Deps)");
350
- if (!depsResult.success || !depsResult.bindings.Deps) {
351
- return violations;
352
- }
353
- const depsStr = depsResult.bindings.Deps;
354
- const match = depsStr.match(/\[(.*)\]/);
355
- if (!match) {
356
- return violations;
357
- }
358
- const content = match[1].trim();
359
- if (!content) {
360
- return violations;
361
- }
362
- // Build adjacency map
363
- const graph = new Map();
364
- const depMatches = content.matchAll(/\[([^,]+),([^\]]+)\]/g);
365
- for (const depMatch of depMatches) {
366
- const from = depMatch[1].trim().replace(/^'|'$/g, "");
367
- const to = depMatch[2].trim().replace(/^'|'$/g, "");
368
- if (!graph.has(from)) {
369
- graph.set(from, []);
193
+ let violationsDict;
194
+ try {
195
+ const jsonString = result.bindings.JsonString;
196
+ if (!jsonString) {
197
+ throw new Error("No JSON string in binding");
370
198
  }
371
- const fromList = graph.get(from);
372
- if (fromList) {
373
- fromList.push(to);
199
+ let parsed = JSON.parse(jsonString);
200
+ if (typeof parsed === "string") {
201
+ parsed = JSON.parse(parsed);
374
202
  }
203
+ violationsDict = parsed;
375
204
  }
376
- // DFS to detect cycles
377
- const visited = new Set();
378
- const recStack = new Set();
379
- function hasCycleDFS(node, path) {
380
- visited.add(node);
381
- recStack.add(node);
382
- path.push(node);
383
- const neighbors = graph.get(node) || [];
384
- for (const neighbor of neighbors) {
385
- if (!visited.has(neighbor)) {
386
- const cyclePath = hasCycleDFS(neighbor, [...path]);
387
- if (cyclePath)
388
- return cyclePath;
389
- }
390
- else if (recStack.has(neighbor)) {
391
- // Cycle detected
392
- return [...path, neighbor];
393
- }
394
- }
395
- recStack.delete(node);
396
- return null;
205
+ catch (parseError) {
206
+ throw new Error(`Failed to parse violations JSON: ${parseError instanceof Error ? parseError.message : String(parseError)}`);
397
207
  }
398
- // Check each node for cycles
399
- for (const node of graph.keys()) {
400
- if (!visited.has(node)) {
401
- const cyclePath = hasCycleDFS(node, []);
402
- if (cyclePath) {
403
- const cycleWithSources = [];
404
- for (const entityId of cyclePath) {
405
- const entityResult = await prolog.query(`kb_entity('${entityId}', _, Props)`);
406
- let sourceName = entityId;
407
- if (entityResult.success && entityResult.bindings.Props) {
408
- const propsStr = entityResult.bindings.Props;
409
- const sourceMatch = propsStr.match(/source\s*=\s*\^\^?\("([^"]+)"/);
410
- if (sourceMatch) {
411
- sourceName = path.basename(sourceMatch[1], ".md");
412
- }
413
- }
414
- cycleWithSources.push(sourceName);
415
- }
208
+ for (const ruleViolations of Object.values(violationsDict)) {
209
+ for (const v of ruleViolations) {
210
+ const isAllowed = rulesAllowlist.has(v.rule);
211
+ if (isAllowed) {
416
212
  violations.push({
417
- rule: "no-cycles",
418
- entityId: cyclePath[0],
419
- description: `Circular dependency detected: ${cycleWithSources.join(" → ")}`,
420
- suggestion: "Break the cycle by removing one of the depends_on relationships",
213
+ rule: v.rule,
214
+ entityId: v.entityId,
215
+ description: v.description,
216
+ suggestion: v.suggestion || undefined,
217
+ source: v.source || undefined,
421
218
  });
422
- break; // Report only first cycle found
423
- }
424
- }
425
- }
426
- return violations;
427
- }
428
- async function checkRequiredFields(prolog, allEntityIds) {
429
- const violations = [];
430
- const required = [
431
- "id",
432
- "title",
433
- "status",
434
- "created_at",
435
- "updated_at",
436
- "source",
437
- ];
438
- for (const entityId of allEntityIds) {
439
- const result = await prolog.query(`kb_entity('${entityId}', Type, Props)`);
440
- if (result.success && result.bindings.Props) {
441
- // Parse properties list: [key1=value1, key2=value2, ...]
442
- const propsStr = result.bindings.Props;
443
- const propKeys = new Set();
444
- // Extract keys from Props
445
- const keyMatches = propsStr.matchAll(/(\w+)\s*=/g);
446
- for (const match of keyMatches) {
447
- propKeys.add(match[1]);
448
- }
449
- // Check for missing required fields
450
- for (const field of required) {
451
- if (!propKeys.has(field)) {
452
- violations.push({
453
- rule: "required-fields",
454
- entityId: entityId,
455
- description: `Missing required field: ${field}`,
456
- suggestion: `Add ${field} to entity definition`,
457
- });
458
- }
459
- }
460
- }
461
- }
462
- return violations;
463
- }
464
- async function checkSymbolCoverage(prolog) {
465
- const violations = [];
466
- const uncoveredResult = await prolog.query("setof(Symbol, (kb_entity(Symbol, symbol, _), \\+ transitively_implements(Symbol, _)), Symbols)");
467
- if (uncoveredResult.success && uncoveredResult.bindings.Symbols) {
468
- const symbolsStr = uncoveredResult.bindings.Symbols;
469
- const match = symbolsStr.match(/\[(.*)\]/);
470
- if (match) {
471
- const content = match[1].trim();
472
- if (content) {
473
- const symbolMatches = content.matchAll(/'([^']+)'/g);
474
- for (const symbolMatch of symbolMatches) {
475
- const symbolId = symbolMatch[1];
476
- violations.push({
477
- rule: "symbol-coverage",
478
- entityId: symbolId,
479
- description: "Code symbol is not traceable to any functional requirement.",
480
- suggestion: "Update symbols.yaml to link this symbol to a related requirement.",
481
- });
482
- }
483
219
  }
484
220
  }
485
221
  }
@@ -51,7 +51,10 @@ export async function handleKbDelete(prolog, args) {
51
51
  }
52
52
  }
53
53
  // Save KB to disk
54
- await prolog.query("kb_save");
54
+ const saveResult = await prolog.query("kb_save");
55
+ if (!saveResult.success) {
56
+ throw new Error(`Failed to save KB after delete: ${saveResult.error || "Unknown error"}`);
57
+ }
55
58
  prolog.invalidateCache();
56
59
  return {
57
60
  content: [
@@ -27,6 +27,7 @@ const validateRelationship = ajv.compile(relationshipSchema);
27
27
  * Handle kb.upsert tool calls
28
28
  * Accepts { type, id, properties } — the flat format matching the tool schema.
29
29
  * Validates the assembled entity against JSON Schema before Prolog writes.
30
+ * implements REQ-002, REQ-011
30
31
  */
31
32
  export async function handleKbUpsert(prolog, args) {
32
33
  const { type, id, properties, relationships = [] } = args;
@@ -80,49 +81,56 @@ export async function handleKbUpsert(prolog, args) {
80
81
  for (const entity of entities) {
81
82
  const id = entity.id;
82
83
  const type = entity.type;
83
- // Check if entity exists
84
+ // Check if entity exists before transaction (to determine created vs updated)
84
85
  const checkGoal = `once(kb_entity('${escapeAtom(id)}', _, _))`;
85
86
  const checkResult = await prolog.query(checkGoal);
86
87
  const isUpdate = checkResult.success;
87
88
  // Build property list for Prolog
88
89
  const props = buildPropertyList(entity);
89
- // Assert entity (upsert)
90
+ // Build relationship goals
91
+ const relationshipGoals = [];
92
+ for (const rel of relationships) {
93
+ const relType = rel.type;
94
+ const from = rel.from;
95
+ const to = rel.to;
96
+ const metadata = buildRelationshipMetadata(rel);
97
+ relationshipGoals.push(`kb_assert_relationship(${relType}, '${escapeAtom(from)}', '${escapeAtom(to)}', ${metadata})`);
98
+ }
99
+ // Build atomic transaction goal wrapping entity + all relationships
100
+ // implements REQ-002, REQ-011
101
+ let transactionGoal;
102
+ if (relationshipGoals.length === 0) {
103
+ // Simple case: just entity
104
+ transactionGoal = `rdf_transaction((kb_assert_entity(${type}, ${props})))`;
105
+ }
106
+ else {
107
+ // Entity + relationships in one transaction
108
+ const goals = [
109
+ `kb_assert_entity(${type}, ${props})`,
110
+ ...relationshipGoals,
111
+ ].join(", ");
112
+ transactionGoal = `rdf_transaction((${goals}))`;
113
+ }
114
+ const txResult = await prolog.query(transactionGoal);
115
+ if (!txResult.success) {
116
+ throw new Error(`Failed to upsert entity ${id}: ${txResult.error || "Unknown error"} (goal: ${transactionGoal})`);
117
+ }
118
+ // Update counters
90
119
  if (isUpdate) {
91
- // Update counter only. kb_assert_entity implements upsert semantics in Prolog.
92
120
  updated++;
93
121
  }
94
122
  else {
95
123
  created++;
96
124
  }
97
- const assertGoal = `kb_assert_entity(${type}, ${props})`;
98
- const assertResult = await prolog.query(assertGoal);
99
- if (!assertResult.success) {
100
- throw new Error(`Failed to assert entity ${id}: ${assertResult.error || "Unknown error"}`);
101
- }
125
+ relationshipsCreated += relationships.length;
102
126
  }
103
- // Process relationships
104
- for (const rel of relationships) {
105
- const relType = rel.type;
106
- const from = rel.from;
107
- const to = rel.to;
108
- // Build metadata
109
- const metadata = buildRelationshipMetadata(rel);
110
- const relGoal = `kb_assert_relationship(${relType}, '${escapeAtom(from)}', '${escapeAtom(to)}', ${metadata})`;
111
- const relResult = await prolog.query(relGoal);
112
- if (!relResult.success) {
113
- throw new Error(`Failed to assert relationship ${relType} from ${from} to ${to}: ${relResult.error || "Unknown error"}`);
114
- }
115
- relationshipsCreated++;
116
- }
117
- // Note: kb_save is intentionally NOT called here for performance.
118
- // Callers that need durability across restarts should explicitly call kb_save.
119
- // This allows batching multiple upserts before a single disk write.
120
- prolog.invalidateCache();
121
- // Save KB to disk to ensure durability across process restarts
122
- await prolog.query("kb_save");
123
- prolog.invalidateCache();
124
- // multiple upserts and save once at the end for better performance.
127
+ // Save KB to disk after all entities/relationships are written to ensure
128
+ // durability across process restarts.
125
129
  prolog.invalidateCache();
130
+ const saveResult = await prolog.query("kb_save");
131
+ if (!saveResult.success) {
132
+ throw new Error(`Failed to save KB after upsert: ${saveResult.error || "Unknown error"}`);
133
+ }
126
134
  let contradictionPairsDetected;
127
135
  if (type === "req" && !args._skipContradictionCheck) {
128
136
  contradictionPairsDetected = await detectContradictionPairs(prolog, id);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "kibi-mcp",
3
- "version": "0.3.1",
3
+ "version": "0.3.2",
4
4
  "dependencies": {
5
5
  "@modelcontextprotocol/sdk": "^1.26.0",
6
6
  "ajv": "^8.18.0",
@@ -9,7 +9,7 @@
9
9
  "fast-glob": "^3.2.12",
10
10
  "gray-matter": "^4.0.3",
11
11
  "js-yaml": "^4.1.0",
12
- "kibi-cli": "^0.2.6",
12
+ "kibi-cli": "^0.2.7",
13
13
  "kibi-core": "^0.1.10",
14
14
  "mcpcat": "^0.1.12",
15
15
  "ts-morph": "^23.0.0",