kibi-mcp 0.4.0 → 0.5.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.
@@ -89,7 +89,8 @@ export function deriveDiagnosticFields(toolName, args, telemetry, result) {
89
89
  telemetry_status: telemetry ? "provided" : "missing",
90
90
  };
91
91
  const structuredContent = result && typeof result === "object" && "structuredContent" in result
92
- ? result.structuredContent
92
+ ? result
93
+ .structuredContent
93
94
  : undefined;
94
95
  if (toolName === "kb_query" || toolName === "kb_search") {
95
96
  const resultCount = Number(structuredContent?.count ?? 0);
@@ -103,9 +104,7 @@ export function deriveDiagnosticFields(toolName, args, telemetry, result) {
103
104
  fields.violation_count = violationCount;
104
105
  fields.requested_rules = Array.isArray(args.rules) ? args.rules : [];
105
106
  fields.result_summary =
106
- violationCount === 0
107
- ? "0 violations"
108
- : `${violationCount} violations`;
107
+ violationCount === 0 ? "0 violations" : `${violationCount} violations`;
109
108
  }
110
109
  if (!fields.result_summary) {
111
110
  fields.result_summary = `${toolName} completed`;
@@ -30,11 +30,13 @@ function renderToolsDoc() {
30
30
  const required = Array.isArray(tool.inputSchema?.required)
31
31
  ? tool.inputSchema.required.join(", ")
32
32
  : "none";
33
- lines.push(`| ${tool.name} | ${tool.description} | ${required} |`);
34
33
  }
34
+ lines.push("");
35
+ lines.push("Modeling note: Prefer query-first discovery; create `fact` entities before `req` entities and express semantics via `constrains` + `requires_property`.");
35
36
  return lines.join("\n");
36
37
  }
37
38
  export const PROMPTS = [
39
+ // implements REQ-002, REQ-013, REQ-mcp-search-discovery
38
40
  {
39
41
  name: "init-kibi",
40
42
  description: "Bootstrap Kibi on an existing repository with zero entities.",
@@ -123,6 +125,8 @@ export const PROMPTS = [
123
125
  "- Run `kb_check` after meaningful mutations to catch integrity issues early.",
124
126
  "- Prefer explicit IDs and enum values to avoid invalid parameters.",
125
127
  "- Assume every write can affect downstream traceability queries.",
128
+ "- Model requirements by first creating/reusing fact entities, then express req semantics with `constrains` + `requires_property` relationships (create-before-link).",
129
+ "- flag gates runtime/config behavior; use `fact` with `fact_kind: observation` or `meta` for bug and workaround notes.",
126
130
  ].join("\n"),
127
131
  },
128
132
  {
@@ -130,7 +134,6 @@ export const PROMPTS = [
130
134
  description: "Step-by-step call order for discovery, mutation, and verification.",
131
135
  text: [
132
136
  "# kibi-mcp Workflow",
133
- "",
134
137
  "Follow this sequence for reliable operation:",
135
138
  "",
136
139
  "1. **Discover first**: Call `kb_search` for exploratory discovery, then `kb_query` to confirm exact current state before mutation.",
@@ -196,7 +199,7 @@ function registerDocResources() {
196
199
  "## Discover before mutating",
197
200
  '1. `kb_search` with `{ "query": "login flow" }` to discover related requirements, tests, and ADRs',
198
201
  '2. `kb_query` with `{ "type": "req", "sourceFile": "src/auth/login.ts" }` for exact follow-up',
199
- '3. `kb_status` with `{}` when branch attachment or freshness confidence matters',
202
+ "3. `kb_status` with `{}` when branch attachment or freshness confidence matters",
200
203
  "",
201
204
  "## Model requirements as reusable facts",
202
205
  '1. `kb_query` with `{ "type": "fact" }` to find existing fact IDs before creating new ones',
@@ -205,6 +208,13 @@ function registerDocResources() {
205
208
  "4. Reuse the same constrained fact ID across related requirements; vary property facts only when semantics differ",
206
209
  '5. `kb_check` with `{ "rules": ["required-fields","no-dangling-refs"] }` for targeted validation',
207
210
  "",
211
+ "Note: Create or reuse `fact` entities first, then create `req` entities and link with `constrains` and `requires_property` (create-before-link). Use `flag` for runtime/config gates; use `fact` with `fact_kind: observation` or `meta` for bug and workaround notes.",
212
+ "",
213
+ "## Find missing coverage",
214
+ '1. `kb_find_gaps` with `{ "type": "req", "missingRelationships": ["specified_by", "verified_by"] }` to find under-linked requirements',
215
+ "",
216
+ "## Find missing coverage",
217
+ "",
208
218
  "## Find missing coverage",
209
219
  '1. `kb_find_gaps` with `{ "type": "req", "missingRelationships": ["specified_by", "verified_by"] }` to find under-linked requirements',
210
220
  '2. `kb_coverage` with `{ "by": "req", "includePassing": false }` to review evaluated coverage rows',
@@ -18,9 +18,9 @@
18
18
  import process from "node:process";
19
19
  import { z } from "zod";
20
20
  import { DIAGNOSTIC_MODE_ENABLED, appendUsageLogLine, deriveDiagnosticFields, extractToolCallPayload, } from "../diagnostics.js";
21
- import { handleKbCoverage } from "../tools/coverage.js";
22
21
  import { TOOLS } from "../tools-config.js";
23
22
  import { handleKbCheck } from "../tools/check.js";
23
+ import { handleKbCoverage } from "../tools/coverage.js";
24
24
  import { handleKbDelete } from "../tools/delete.js";
25
25
  import { handleKbFindGaps } from "../tools/find-gaps.js";
26
26
  import { handleKbGraph } from "../tools/graph.js";
@@ -16,45 +16,10 @@
16
16
  along with this program. If not, see <https://www.gnu.org/licenses/>.
17
17
  */
18
18
  import { existsSync, readFileSync } from "node:fs";
19
- import { createRequire } from "node:module";
20
19
  import * as path from "node:path";
20
+ import { DEFAULT_CHECKS_CONFIG, RULE_NAMES, } from "kibi-cli/public/check-types";
21
21
  import { resolveWorkspaceRoot } from "../workspace.js";
22
- const require = createRequire(import.meta.url);
23
- function resolveChecksPlPath() {
24
- const overrideChecksPath = process.env.KIBI_CHECKS_PL_PATH;
25
- if (overrideChecksPath && existsSync(overrideChecksPath)) {
26
- return overrideChecksPath;
27
- }
28
- try {
29
- const installedChecksPl = require.resolve("kibi-core/src/checks.pl");
30
- if (existsSync(installedChecksPl)) {
31
- return installedChecksPl;
32
- }
33
- }
34
- catch { }
35
- const localChecksPl = path.join(process.cwd(), "packages/core/src/checks.pl");
36
- if (existsSync(localChecksPl)) {
37
- return localChecksPl;
38
- }
39
- throw new Error("Unable to resolve checks.pl path");
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
- };
22
+ import { resolveCorePlPath } from "./core-module.js";
58
23
  function formatDiagnosticsForMcp(diagnostics) {
59
24
  return diagnostics.map((d) => ({
60
25
  category: d.category,
@@ -121,21 +86,16 @@ function loadChecksConfig(workspaceRoot) {
121
86
  }
122
87
  // implements REQ-002
123
88
  function getEffectiveRules(configRules, requestedRules) {
89
+ if (requestedRules && requestedRules.length > 0) {
90
+ return new Set(requestedRules.filter((rule) => RULE_NAMES.has(rule)));
91
+ }
124
92
  const effective = new Set();
125
- for (const rule of ALL_RULES) {
93
+ for (const rule of RULE_NAMES) {
126
94
  const enabled = configRules[rule] ?? true;
127
95
  if (enabled) {
128
96
  effective.add(rule);
129
97
  }
130
98
  }
131
- if (requestedRules && requestedRules.length > 0) {
132
- const allowed = new Set(requestedRules.filter((rule) => RULE_NAMES.has(rule)));
133
- for (const rule of Array.from(effective)) {
134
- if (!allowed.has(rule)) {
135
- effective.delete(rule);
136
- }
137
- }
138
- }
139
99
  return effective;
140
100
  }
141
101
  /**
@@ -192,7 +152,7 @@ export async function handleKbCheck(prolog, args) {
192
152
  // implements REQ-002
193
153
  async function runAggregatedChecks(prolog, rulesAllowlist, requireAdr) {
194
154
  const violations = [];
195
- const checksPlPath = resolveChecksPlPath();
155
+ const checksPlPath = resolveCorePlPath("checks.pl");
196
156
  const normalizedChecksPlPath = checksPlPath.replace(/\\/g, "/");
197
157
  const checksPlPathEscaped = normalizedChecksPlPath.replace(/'/g, "''");
198
158
  // Use check_all_json_with_options if available, otherwise fall back to check_all_json
@@ -1,9 +1,7 @@
1
1
  import { existsSync } from "node:fs";
2
- import { createRequire } from "node:module";
3
2
  import path from "node:path";
4
- import { PrologProcess } from "kibi-cli/prolog";
3
+ import { PrologProcess, resolveKbPlPath } from "kibi-cli/prolog";
5
4
  import { escapeAtomContent } from "kibi-cli/prolog/codec";
6
- const require = createRequire(import.meta.url);
7
5
  // implements REQ-002, REQ-013
8
6
  export function resolveCorePlPath(fileName) {
9
7
  const envKey = `KIBI_${fileName.replace(/\W/g, "_").toUpperCase()}_PATH`;
@@ -11,18 +9,12 @@ export function resolveCorePlPath(fileName) {
11
9
  if (override && existsSync(override)) {
12
10
  return override;
13
11
  }
14
- try {
15
- const installedPath = require.resolve(`kibi-core/src/${fileName}`);
16
- if (existsSync(installedPath)) {
17
- return installedPath;
18
- }
19
- }
20
- catch { }
21
- const localPath = path.join(process.cwd(), "packages/core/src", fileName);
22
- if (existsSync(localPath)) {
23
- return localPath;
12
+ const kbPlPath = resolveKbPlPath();
13
+ const sibling = path.join(path.dirname(kbPlPath), fileName);
14
+ if (existsSync(sibling)) {
15
+ return sibling;
24
16
  }
25
- throw new Error(`Unable to resolve core module path for ${fileName}`);
17
+ throw new Error(`Root-consistency error: resolveKbPlPath() resolved to '${kbPlPath}' but sibling '${fileName}' not found at '${sibling}'`);
26
18
  }
27
19
  // implements REQ-002, REQ-013
28
20
  export async function runJsonModuleQuery(prolog, fileName, goal, errorLabel) {
@@ -1,5 +1,6 @@
1
1
  import { escapeAtomContent, parseEntityFromBinding, parseEntityFromList, parseListOfLists, } from "kibi-cli/prolog/codec";
2
2
  export const VALID_ENTITY_TYPES = [
3
+ // implements REQ-002
3
4
  "req",
4
5
  "scenario",
5
6
  "test",
@@ -1,4 +1,4 @@
1
- import { runJsonModuleQuery, toPrologAtom, toPrologList } from "./core-module.js";
1
+ import { runJsonModuleQuery, toPrologAtom, toPrologList, } from "./core-module.js";
2
2
  import { validateEntityType } from "./entity-query.js";
3
3
  // implements REQ-002, REQ-013
4
4
  export async function handleKbFindGaps(prolog, args) {
@@ -15,6 +15,7 @@
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
17
  */
18
+ import { splitTopLevel } from "kibi-cli/prolog/codec";
18
19
  // implements REQ-002
19
20
  export function parseAtomList(raw) {
20
21
  const trimmed = raw.trim();
@@ -86,49 +87,6 @@ function unwrapList(value) {
86
87
  }
87
88
  return value;
88
89
  }
89
- function splitTopLevel(input, delimiter) {
90
- const parts = [];
91
- let current = "";
92
- let depth = 0;
93
- let inDoubleQuotes = false;
94
- let inSingleQuotes = false;
95
- for (let i = 0; i < input.length; i++) {
96
- const ch = input[i];
97
- const prev = i > 0 ? input[i - 1] : "";
98
- if (ch === '"' && !inSingleQuotes && prev !== "\\") {
99
- inDoubleQuotes = !inDoubleQuotes;
100
- current += ch;
101
- continue;
102
- }
103
- if (ch === "'" && !inDoubleQuotes && prev !== "\\") {
104
- inSingleQuotes = !inSingleQuotes;
105
- current += ch;
106
- continue;
107
- }
108
- if (!inSingleQuotes && !inDoubleQuotes && (ch === "[" || ch === "(")) {
109
- depth++;
110
- current += ch;
111
- continue;
112
- }
113
- if (!inSingleQuotes && !inDoubleQuotes && (ch === "]" || ch === ")")) {
114
- depth--;
115
- current += ch;
116
- continue;
117
- }
118
- if (!inSingleQuotes && !inDoubleQuotes && depth === 0 && ch === delimiter) {
119
- if (current.length > 0) {
120
- parts.push(current);
121
- }
122
- current = "";
123
- continue;
124
- }
125
- current += ch;
126
- }
127
- if (current.length > 0) {
128
- parts.push(current);
129
- }
130
- return parts;
131
- }
132
90
  function stripQuotes(value) {
133
91
  if (value.startsWith("'") && value.endsWith("'")) {
134
92
  return value.slice(1, -1);
@@ -1,6 +1,6 @@
1
1
  import { rankEntities } from "kibi-cli/search-ranking";
2
- import { loadEntities, paginateResults, validateEntityType, } from "./entity-query.js";
3
2
  import { resolveWorkspaceRoot } from "../workspace.js";
3
+ import { loadEntities, paginateResults, validateEntityType, } from "./entity-query.js";
4
4
  // implements REQ-mcp-search-discovery, REQ-002
5
5
  export async function handleKbSearch(prolog, args) {
6
6
  const { query, type, limit = 20, offset = 0 } = args;
@@ -45,6 +45,7 @@ const SOURCE_EXTENSIONS = new Set([
45
45
  ".cjs",
46
46
  ]);
47
47
  export async function handleKbSymbolsRefresh(args) {
48
+ // implements REQ-vscode-traceability
48
49
  const dryRun = args.dryRun === true;
49
50
  const workspaceRoot = resolveWorkspaceRoot();
50
51
  const manifestPath = resolveManifestPath(workspaceRoot);
@@ -112,6 +113,7 @@ export async function handleKbSymbolsRefresh(args) {
112
113
  };
113
114
  }
114
115
  export async function refreshCoordinatesForSymbolId(symbolId, workspaceRoot = resolveWorkspaceRoot()) {
116
+ // implements REQ-vscode-traceability
115
117
  const manifestPath = resolveManifestPath(workspaceRoot);
116
118
  const rawContent = readFileSync(manifestPath, "utf8");
117
119
  const parsed = parseYAML(rawContent);
@@ -151,6 +153,7 @@ export async function refreshCoordinatesForSymbolId(symbolId, workspaceRoot = re
151
153
  return { refreshed, found: true };
152
154
  }
153
155
  export function resolveManifestPath(workspaceRoot) {
156
+ // implements REQ-002, REQ-013
154
157
  const configPath = path.join(workspaceRoot, ".kb", "config.json");
155
158
  if (existsSync(configPath)) {
156
159
  try {
@@ -168,7 +171,9 @@ export function resolveManifestPath(workspaceRoot) {
168
171
  : path.resolve(workspaceRoot, config.symbolsManifest);
169
172
  }
170
173
  }
171
- catch { }
174
+ catch {
175
+ // config file missing or malformed; fall through to defaults
176
+ }
172
177
  }
173
178
  const candidates = [
174
179
  path.join(workspaceRoot, "symbols.yaml"),
@@ -16,10 +16,11 @@
16
16
  along with this program. If not, see <https://www.gnu.org/licenses/>.
17
17
  */
18
18
  import Ajv from "ajv";
19
- import { escapeAtom, toPrologAtom } from "kibi-cli/prolog/codec";
19
+ import { escapeAtom, toPrologAtom, toPrologString, } from "kibi-cli/prolog/codec";
20
20
  import entitySchema from "kibi-cli/schemas/entity";
21
21
  import relationshipSchema from "kibi-cli/schemas/relationship";
22
22
  import { refreshCoordinatesForSymbolId } from "./symbols.js";
23
+ let refreshCoordinatesForSymbolIdImpl = refreshCoordinatesForSymbolId;
23
24
  const ajv = new Ajv({ strict: false });
24
25
  const validateEntity = ajv.compile(entitySchema);
25
26
  const validateRelationship = ajv.compile(relationshipSchema);
@@ -73,6 +74,10 @@ export async function handleKbUpsert(prolog, args) {
73
74
  throw new Error(`Relationship validation failed at index ${i}: ${errorMessages}`);
74
75
  }
75
76
  }
77
+ validateRelationshipSources(id, relationships);
78
+ // Validate strict-lane fact_kind pairing for constrains/requires_property
79
+ // implements REQ-011
80
+ await validateStrictLanePairing(prolog, relationships);
76
81
  let created = 0;
77
82
  let updated = 0;
78
83
  let relationshipsCreated = 0;
@@ -94,26 +99,44 @@ export async function handleKbUpsert(prolog, args) {
94
99
  const from = rel.from;
95
100
  const to = rel.to;
96
101
  const metadata = buildRelationshipMetadata(rel);
97
- relationshipGoals.push(`kb_assert_relationship(${relType}, '${escapeAtom(from)}', '${escapeAtom(to)}', ${metadata})`);
102
+ relationshipGoals.push(`kb_assert_relationship_no_audit(${relType}, '${escapeAtom(from)}', '${escapeAtom(to)}', ${metadata})`);
98
103
  }
99
104
  // Build atomic transaction goal wrapping entity + all relationships
105
+ // For requirements, also include contradiction check within the transaction
100
106
  // implements REQ-002, REQ-011
101
107
  let transactionGoal;
108
+ const needsContradictionCheck = type === "req" && !args._skipContradictionCheck;
102
109
  if (relationshipGoals.length === 0) {
103
110
  // Simple case: just entity
104
- transactionGoal = `rdf_transaction((kb_assert_entity(${type}, ${props})))`;
111
+ if (needsContradictionCheck) {
112
+ transactionGoal = `rdf_transaction((kb_assert_entity_no_audit(${type}, ${props}), check_req_contradiction('${escapeAtom(id)}')))`;
113
+ }
114
+ else {
115
+ transactionGoal = `rdf_transaction((kb_assert_entity_no_audit(${type}, ${props})))`;
116
+ }
105
117
  }
106
118
  else {
107
119
  // Entity + relationships in one transaction
108
120
  const goals = [
109
- `kb_assert_entity(${type}, ${props})`,
121
+ `kb_assert_entity_no_audit(${type}, ${props})`,
110
122
  ...relationshipGoals,
111
123
  ].join(", ");
112
- transactionGoal = `rdf_transaction((${goals}))`;
124
+ if (needsContradictionCheck) {
125
+ transactionGoal = `rdf_transaction((${goals}, check_req_contradiction('${escapeAtom(id)}')))`;
126
+ }
127
+ else {
128
+ transactionGoal = `rdf_transaction((${goals}))`;
129
+ }
113
130
  }
114
131
  const txResult = await prolog.query(transactionGoal);
115
132
  if (!txResult.success) {
116
- throw new Error(`Failed to upsert entity ${id}: ${txResult.error || "Unknown error"} (goal: ${transactionGoal})`);
133
+ // Format error message without exposing raw transaction goal
134
+ const formattedError = formatUpsertError(id, txResult.error);
135
+ throw new Error(formattedError);
136
+ }
137
+ await recordEntityAudit(prolog, type, entity);
138
+ for (const rel of relationships) {
139
+ await recordRelationshipAudit(prolog, rel);
117
140
  }
118
141
  // Update counters
119
142
  if (isUpdate) {
@@ -131,13 +154,9 @@ export async function handleKbUpsert(prolog, args) {
131
154
  if (!saveResult.success) {
132
155
  throw new Error(`Failed to save KB after upsert: ${saveResult.error || "Unknown error"}`);
133
156
  }
134
- let contradictionPairsDetected;
135
- if (type === "req" && !args._skipContradictionCheck) {
136
- contradictionPairsDetected = await detectContradictionPairs(prolog, id);
137
- }
138
157
  if (type === "symbol") {
139
158
  try {
140
- await refreshCoordinatesForSymbolId(id);
159
+ await refreshCoordinatesForSymbolIdImpl(id);
141
160
  }
142
161
  catch (error) {
143
162
  const message = error instanceof Error ? error.message : String(error);
@@ -150,16 +169,13 @@ export async function handleKbUpsert(prolog, args) {
150
169
  content: [
151
170
  {
152
171
  type: "text",
153
- text: contradictionPairsDetected && contradictionPairsDetected > 0
154
- ? `Upserted ${id} (${created > 0 ? "created" : "updated"}) with ${relationshipsCreated} relationship(s). Contradiction probe detected ${contradictionPairsDetected} potential conflict pair(s).`
155
- : `Upserted ${id} (${created > 0 ? "created" : "updated"}) with ${relationshipsCreated} relationship(s).`,
172
+ text: `Upserted ${id} (${created > 0 ? "created" : "updated"}) with ${relationshipsCreated} relationship(s).`,
156
173
  },
157
174
  ],
158
175
  structuredContent: {
159
176
  created,
160
177
  updated,
161
178
  relationships_created: relationshipsCreated,
162
- contradiction_pairs_detected: contradictionPairsDetected,
163
179
  },
164
180
  };
165
181
  }
@@ -168,27 +184,34 @@ export async function handleKbUpsert(prolog, args) {
168
184
  throw new Error(`Upsert execution failed: ${message}`);
169
185
  }
170
186
  }
171
- async function detectContradictionPairs(prolog, reqId) {
172
- const escaped = escapeAtom(reqId);
173
- const goal = `aggregate_all(count, (contradicting_reqs(A, B, _), (A = '${escaped}' ; B = '${escaped}' ; A = 'file:///${escaped}' ; B = 'file:///${escaped}')), Count)`;
174
- const result = await prolog.query(goal);
175
- if (!result.success) {
176
- return 0;
177
- }
178
- const raw = result.bindings.Count;
179
- const count = Number(raw);
180
- return Number.isFinite(count) ? count : 0;
181
- }
187
+ export const __test__ = {
188
+ // implements REQ-vscode-traceability
189
+ setRefreshCoordinatesForSymbolIdForTests(fn) {
190
+ refreshCoordinatesForSymbolIdImpl = fn ?? refreshCoordinatesForSymbolId;
191
+ },
192
+ };
182
193
  /**
183
194
  * Build Prolog property list from entity object
184
195
  * Returns simple Key=Value format without typed literals
185
196
  * Example output: "[id='test-1', title=\"Test\", status=active]"
197
+ * implements REQ-002
186
198
  */
187
199
  function buildPropertyList(entity) {
188
200
  const pairs = [];
189
201
  // Defined internally to ensure thread safety and avoid initialization order issues.
190
202
  // Using simple arrays instead of Sets is performant enough for small lists and avoids Set allocation overhead.
191
- const ATOM_FIELDS = ["status", "owner", "priority", "severity"];
203
+ // implements REQ-002
204
+ const ATOM_FIELDS = [
205
+ "status",
206
+ "owner",
207
+ "priority",
208
+ "severity",
209
+ // Typed fact enum fields must be atoms for Prolog validation
210
+ "fact_kind",
211
+ "operator",
212
+ "value_type",
213
+ "polarity",
214
+ ];
192
215
  const STRING_FIELDS = [
193
216
  "id",
194
217
  "title",
@@ -200,6 +223,8 @@ function buildPropertyList(entity) {
200
223
  for (const [key, value] of Object.entries(entity)) {
201
224
  if (key === "type")
202
225
  continue;
226
+ if (value === undefined || value === null)
227
+ continue;
203
228
  let prologValue;
204
229
  if (key === "id" && typeof value === "string") {
205
230
  prologValue = `'${value.replace(/'/g, "''")}'`;
@@ -211,10 +236,10 @@ function buildPropertyList(entity) {
211
236
  prologValue = toPrologAtom(value);
212
237
  }
213
238
  else if (STRING_FIELDS.includes(key) && typeof value === "string") {
214
- prologValue = `"${escapeQuotes(value)}"`;
239
+ prologValue = `${toPrologString(value)}`;
215
240
  }
216
241
  else if (typeof value === "string") {
217
- prologValue = `"${escapeQuotes(value)}"`;
242
+ prologValue = `${toPrologString(value)}`;
218
243
  }
219
244
  else if (typeof value === "number") {
220
245
  prologValue = String(value);
@@ -223,7 +248,7 @@ function buildPropertyList(entity) {
223
248
  prologValue = value ? "true" : "false";
224
249
  }
225
250
  else {
226
- prologValue = `"${escapeQuotes(String(value))}"`;
251
+ prologValue = `${toPrologString(String(value))}`;
227
252
  }
228
253
  pairs.push(`${key}=${prologValue}`);
229
254
  }
@@ -240,21 +265,122 @@ function buildRelationshipMetadata(rel) {
240
265
  continue;
241
266
  let prologValue;
242
267
  if (typeof value === "string") {
243
- prologValue = `"${escapeQuotes(value)}"`;
268
+ prologValue = `${toPrologString(value)}`;
244
269
  }
245
270
  else if (typeof value === "number") {
246
271
  prologValue = String(value);
247
272
  }
248
273
  else {
249
- prologValue = `"${escapeQuotes(String(value))}"`;
274
+ prologValue = `${toPrologString(String(value))}`;
250
275
  }
251
276
  pairs.push(`${key}=${prologValue}`);
252
277
  }
253
278
  return `[${pairs.join(", ")}]`;
254
279
  }
255
280
  /**
256
- * Escape double quotes in strings for Prolog
281
+ * Ensure all relationship rows belong to the entity being upserted.
282
+ * Rejects foreign-source relationship writes in the same request.
283
+ * implements REQ-002, REQ-011
284
+ */
285
+ function validateRelationshipSources(entityId, relationships) {
286
+ for (const rel of relationships) {
287
+ if (rel.from !== entityId) {
288
+ throw new Error(`Relationship source must match the upserted entity ${entityId}; received from=${String(rel.from)}`);
289
+ }
290
+ }
291
+ }
292
+ /**
293
+ * Validate strict-lane fact_kind pairing for constrains/requires_property relationships.
294
+ * constrains targets must be subject, observation, or meta facts (or legacy without fact_kind).
295
+ * requires_property targets must be property_value, observation, or meta facts (or legacy without fact_kind).
296
+ * implements REQ-011
257
297
  */
258
- function escapeQuotes(str) {
259
- return str.replace(/"/g, '\\"');
298
+ async function validateStrictLanePairing(prolog, relationships) {
299
+ // implements REQ-011
300
+ for (const rel of relationships) {
301
+ if (rel.type === "constrains") {
302
+ const targetId = rel.to;
303
+ // Reject if target is a fact with fact_kind=property_value.
304
+ // Allow: legacy (no fact_kind), subject, observation, meta, or non-existent
305
+ // (non-existent targets are caught by relationship type validation elsewhere).
306
+ const rejectResult = await prolog.query(`once((kb_entity('${escapeAtom(targetId)}', fact, _SlpProps), memberchk(fact_kind=_SlpFK, _SlpProps), normalize_term_atom(_SlpFK, property_value)))`);
307
+ if (rejectResult.success) {
308
+ throw new Error(`Relationship 'constrains' requires target '${targetId}' to be a subject, observation, or meta fact. Property_value facts cannot be direct targets of constrains relationships.`);
309
+ }
310
+ }
311
+ else if (rel.type === "requires_property") {
312
+ const targetId = rel.to;
313
+ // Reject if target is a fact with fact_kind=subject.
314
+ // Allow: legacy (no fact_kind), property_value, observation, meta, or non-existent.
315
+ const rejectResult = await prolog.query(`once((kb_entity('${escapeAtom(targetId)}', fact, _SlpProps), memberchk(fact_kind=_SlpFK, _SlpProps), normalize_term_atom(_SlpFK, subject)))`);
316
+ if (rejectResult.success) {
317
+ throw new Error(`Relationship 'requires_property' requires target '${targetId}' to be a property_value, observation, or meta fact. Subject facts cannot be direct targets of requires_property relationships.`);
318
+ }
319
+ }
320
+ }
321
+ }
322
+ /**
323
+ * Record audit entry for a successfully committed entity mutation.
324
+ * Called only after the RDF transaction succeeds.
325
+ * implements REQ-011
326
+ */
327
+ async function recordEntityAudit(prolog, type, entity) {
328
+ const props = buildPropertyList(entity);
329
+ const result = await prolog.query(`kb_log_entity_upsert(${type}, ${props})`);
330
+ if (!result.success) {
331
+ throw new Error(`Failed to record audit entry for ${String(entity.id)}: ${result.error || "Unknown error"}`);
332
+ }
333
+ }
334
+ /**
335
+ * Record audit entry for a successfully committed relationship mutation.
336
+ * Called only after the RDF transaction succeeds.
337
+ * implements REQ-011
338
+ */
339
+ async function recordRelationshipAudit(prolog, rel) {
340
+ const relType = rel.type;
341
+ const from = rel.from;
342
+ const to = rel.to;
343
+ const metadata = buildRelationshipMetadata(rel);
344
+ const result = await prolog.query(`kb_log_relationship_upsert(${relType}, '${escapeAtom(from)}', '${escapeAtom(to)}', ${metadata})`);
345
+ if (!result.success) {
346
+ throw new Error(`Failed to record relationship audit entry ${from}->${to}: ${result.error || "Unknown error"}`);
347
+ }
348
+ }
349
+ /**
350
+ * Format upsert error message for user-facing display.
351
+ * Removes raw transaction goals and extracts meaningful contradiction details.
352
+ * implements REQ-011
353
+ */
354
+ function formatUpsertError(entityId, rawError) {
355
+ if (!rawError) {
356
+ return `Failed to upsert entity ${entityId}: Unknown error`;
357
+ }
358
+ // Check for contradiction error - Prolog returns kb_contradiction([...]) term
359
+ // Try to extract readable details from the term
360
+ const contradictionMatch = rawError.match(/kb_contradiction\(\s*\[([^\]]+)\]\s*\)/);
361
+ if (contradictionMatch) {
362
+ // Extract individual conflict details from the list
363
+ const details = contradictionMatch[1];
364
+ // Parse out readable parts - each entry is like 'Reason'-'ReqId'
365
+ const conflicts = [];
366
+ const conflictRegex = /'([^']+)'-'([^']+)'/g;
367
+ let execResult = conflictRegex.exec(details);
368
+ while (execResult !== null) {
369
+ const reason = execResult[1];
370
+ const otherReq = execResult[2];
371
+ conflicts.push(` - Conflicts with ${otherReq}: ${reason}`);
372
+ execResult = conflictRegex.exec(details);
373
+ }
374
+ if (conflicts.length > 0) {
375
+ const uniqueConflicts = [...new Set(conflicts)];
376
+ return `Contradiction detected for requirement ${entityId}:\n${uniqueConflicts.join("\n")}\n\nTo resolve:\n 1. Add a supersedes relationship from the new requirement to the conflicting one, OR\n 2. Deprecate the conflicting requirement before creating the new one.`;
377
+ }
378
+ return `Contradiction detected for entity ${entityId}: This requirement conflicts with existing requirements. Add a supersedes relationship to the conflicting requirement, or deprecate the old requirement before creating the new one.`;
379
+ }
380
+ // Check for RDF transaction error
381
+ if (rawError.includes("rdf_transaction")) {
382
+ return `Failed to upsert entity ${entityId}: Transaction failed`;
383
+ }
384
+ // Default: return cleaned error without raw goal
385
+ return `Failed to upsert entity ${entityId}: ${rawError}`;
260
386
  }
@@ -232,7 +232,7 @@ const BASE_TOOLS = [
232
232
  },
233
233
  {
234
234
  name: "kb_upsert",
235
- description: "Create or update one entity and optional relationships. Use for KB mutations after validating intent. Use the `relationships` array for batch creation of multiple links in a single call (e.g., linking a requirement to multiple tests or facts). Prefer modeling requirements as reusable fact links (`constrains`, `requires_property`) so consistency and contradiction checks remain queryable. Relationship endpoints must already exist in KB. Do not use for read-only inspection. Side effects: writes KB, may refresh symbol coordinates.",
235
+ description: "Create or update one entity and optional relationships. Use for KB mutations after validating intent. Use the `relationships` array for batch creation of multiple links in a single call (e.g., linking a requirement to multiple tests or facts). Prefer modeling requirements as reusable fact links (`constrains`, `requires_property`) so consistency and contradiction checks remain queryable. Relationship endpoints must already exist in KB. For requirements, the write will be rejected if it contradicts existing current requirements that constrain the same subject with incompatible properties. To replace a conflicting requirement, include a `supersedes` relationship from the new requirement to the old one in the same request. Do not use for read-only inspection. Side effects: writes KB, may refresh symbol coordinates.",
236
236
  inputSchema: {
237
237
  type: "object",
238
238
  required: ["type", "id", "properties"],
@@ -375,9 +375,10 @@ const BASE_TOOLS = [
375
375
  "required-fields",
376
376
  "deprecated-adr-no-successor",
377
377
  "domain-contradictions",
378
+ "strict-fact-shape",
378
379
  ],
379
380
  },
380
- description: "Optional rule subset. Allowed: must-priority-coverage, symbol-coverage, symbol-traceability, no-dangling-refs, no-cycles, required-fields, deprecated-adr-no-successor, domain-contradictions. If omitted, server runs all.",
381
+ description: "Optional rule subset. Allowed: must-priority-coverage, symbol-coverage, symbol-traceability, no-dangling-refs, no-cycles, required-fields, deprecated-adr-no-successor, domain-contradictions, strict-fact-shape. If omitted, server runs all.",
381
382
  },
382
383
  },
383
384
  },
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "kibi-mcp",
3
- "version": "0.4.0",
3
+ "version": "0.5.2",
4
4
  "dependencies": {
5
5
  "@modelcontextprotocol/sdk": "^1.26.0",
6
6
  "ajv": "^8.18.0",
@@ -9,8 +9,8 @@
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.3.0",
13
- "kibi-core": "^0.2.0",
12
+ "kibi-cli": "^0.4.3",
13
+ "kibi-core": "^0.3.0",
14
14
  "mcpcat": "^0.1.12",
15
15
  "ts-morph": "^23.0.0",
16
16
  "zod": "^4.3.6"