kibi-mcp 0.17.5 → 0.19.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -70,12 +70,32 @@ export function classifyDiagnosticError(error) {
70
70
  return buildErrorFields(err, "relationship_validation_failed", "validation", "Relationship payload failed schema validation.");
71
71
  if (err.message.startsWith("Relationship source must match the upserted entity"))
72
72
  return buildErrorFields(err, "relationship_source_mismatch", "validation", "Relationship source did not match the entity being upserted.");
73
+ if (lower.includes("contradiction detected for requirement")) {
74
+ const fields = buildErrorFields(err, "semantic_contradiction", "validation", "Requirement prose/facts contradict existing contradiction-ready requirements.");
75
+ return {
76
+ ...fields,
77
+ semantic_outcome: "conflict-blocked",
78
+ ...semanticContradictionIds(err.message),
79
+ };
80
+ }
73
81
  if (lower.includes("module load failed"))
74
82
  return buildErrorFields(err, "prolog_module_load_failed", "prolog_runtime", "Prolog failed to load an execution module.");
75
83
  if (lower.includes("query failed"))
76
84
  return buildErrorFields(err, "prolog_query_failed", "prolog_runtime", "Prolog query execution failed.");
77
85
  return buildErrorFields(err, "handler_error", "handler", "Unhandled MCP handler error.");
78
86
  }
87
+ function semanticContradictionIds(message) {
88
+ const checked = message.match(/Contradiction detected for requirement\s+([^:\s]+)/i);
89
+ const conflicts = [
90
+ ...message.matchAll(/Conflicts with\s+([^:\s]+)/gi),
91
+ ].flatMap((match) => (match[1] ? [match[1]] : []));
92
+ return {
93
+ ...(checked?.[1] ? { semantic_checked_req_id: checked[1] } : {}),
94
+ ...(conflicts.length > 0
95
+ ? { semantic_conflicting_req_ids: conflicts }
96
+ : {}),
97
+ };
98
+ }
79
99
  export function deriveDiagnosticHints(input) {
80
100
  const error = input.error instanceof Error ? input.error : new Error(String(input.error));
81
101
  const message = error.message.toLowerCase();
@@ -89,6 +109,8 @@ export function deriveDiagnosticHints(input) {
89
109
  return `tool_timeout ${mappedStage}: retry the same request after reducing payload size or scope.`;
90
110
  if (error_category === "relationship_source_mismatch")
91
111
  return `relationship_source_mismatch ${mappedStage}: align the relationship source with the upserted entity id.`;
112
+ if (error_category === "semantic_contradiction")
113
+ return `semantic_contradiction ${mappedStage}: add a supersedes relationship to the conflicting requirement, deprecate the older requirement, or align the modeled facts.`;
92
114
  if (error_category === "prolog_process_not_started")
93
115
  return `prolog_process_not_started ${mappedStage}: restart the MCP server before retrying the tool call.`;
94
116
  if (error_category === "prolog_query_failed")
@@ -58,6 +58,88 @@ export const DIAGNOSTIC_TELEMETRY_SCHEMA = {
58
58
  },
59
59
  },
60
60
  };
61
+ function isRecord(value) {
62
+ return Boolean(value) && typeof value === "object" && !Array.isArray(value);
63
+ }
64
+ function structuredContentFrom(result) {
65
+ if (!isRecord(result))
66
+ return undefined;
67
+ const structuredContent = result.structuredContent;
68
+ return isRecord(structuredContent) ? structuredContent : undefined;
69
+ }
70
+ function stringArray(value) {
71
+ if (!Array.isArray(value))
72
+ return [];
73
+ return value.filter((item) => typeof item === "string");
74
+ }
75
+ function suggestionKindsFrom(value) {
76
+ if (!Array.isArray(value))
77
+ return [];
78
+ return value.flatMap((item) => {
79
+ if (!isRecord(item) || typeof item.kind !== "string")
80
+ return [];
81
+ return [item.kind];
82
+ });
83
+ }
84
+ function appendSemanticAdvisorFields(fields, receipt) {
85
+ if (!isRecord(receipt))
86
+ return;
87
+ const readiness = receipt.logic_readiness;
88
+ const lane = receipt.candidate_lane;
89
+ const suggestionKinds = suggestionKindsFrom(receipt.suggestions);
90
+ if (typeof readiness === "string") {
91
+ fields.semantic_logic_readiness = readiness;
92
+ }
93
+ if (typeof lane === "string") {
94
+ fields.semantic_candidate_lane = lane;
95
+ }
96
+ fields.semantic_suggestion_kinds = suggestionKinds;
97
+ fields.semantic_suggestion_count = suggestionKinds.length;
98
+ fields.semantic_next_tools = stringArray(receipt.suggested_next_tools);
99
+ }
100
+ function appendPredicateSuggestionFields(fields, structuredContent) {
101
+ const candidates = Array.isArray(structuredContent.candidates)
102
+ ? structuredContent.candidates
103
+ : [];
104
+ const topCandidate = candidates.find(isRecord);
105
+ fields.predicate_candidate_count = candidates.length;
106
+ if (topCandidate) {
107
+ if (typeof topCandidate.predicate_name === "string") {
108
+ fields.predicate_top_name = topCandidate.predicate_name;
109
+ }
110
+ if (typeof topCandidate.score === "number") {
111
+ fields.predicate_top_score = topCandidate.score;
112
+ }
113
+ }
114
+ if (typeof structuredContent.recommendedAction === "string") {
115
+ fields.predicate_recommended_action = structuredContent.recommendedAction;
116
+ }
117
+ fields.predicate_relationship_plan = isRecord(structuredContent.relationshipPlan);
118
+ }
119
+ function appendContradictionCheckFields(fields, contradictionCheck) {
120
+ if (!isRecord(contradictionCheck))
121
+ return;
122
+ const outcome = contradictionCheck.outcome;
123
+ if (typeof outcome === "string") {
124
+ fields.semantic_contradiction_outcome = outcome;
125
+ }
126
+ const checkedReqId = contradictionCheck.checked_req_id;
127
+ if (typeof checkedReqId === "string") {
128
+ fields.semantic_checked_req_id = checkedReqId;
129
+ }
130
+ const strictReadiness = contradictionCheck.strict_readiness;
131
+ if (typeof strictReadiness === "string") {
132
+ fields.semantic_strict_readiness = strictReadiness;
133
+ }
134
+ const subjectKey = contradictionCheck.subject_key;
135
+ if (typeof subjectKey === "string") {
136
+ fields.semantic_subject_key = subjectKey;
137
+ }
138
+ const propertyKey = contradictionCheck.property_key;
139
+ if (typeof propertyKey === "string") {
140
+ fields.semantic_property_key = propertyKey;
141
+ }
142
+ }
61
143
  export function deriveDiagnosticFields(toolName, args, telemetry, result) {
62
144
  const fields = {
63
145
  telemetry_status: telemetry ? "provided" : "missing",
@@ -67,10 +149,7 @@ export function deriveDiagnosticFields(toolName, args, telemetry, result) {
67
149
  fields.telemetry_confidence_score = telemetry.confidence_score ?? null;
68
150
  fields.telemetry_attempt_number = telemetry.attempt_number ?? null;
69
151
  }
70
- const structuredContent = result && typeof result === "object" && "structuredContent" in result
71
- ? result
72
- .structuredContent
73
- : undefined;
152
+ const structuredContent = structuredContentFrom(result);
74
153
  if (toolName === "kb_query" || toolName === "kb_search") {
75
154
  const resultCount = Number(structuredContent?.count ?? 0);
76
155
  fields.result_count = resultCount;
@@ -85,6 +164,36 @@ export function deriveDiagnosticFields(toolName, args, telemetry, result) {
85
164
  fields.result_summary =
86
165
  violationCount === 0 ? "0 violations" : `${violationCount} violations`;
87
166
  }
167
+ if (toolName === "kb_semantic_advisor" && structuredContent) {
168
+ appendSemanticAdvisorFields(fields, structuredContent.receipt);
169
+ const readiness = fields.semantic_logic_readiness;
170
+ const lane = fields.semantic_candidate_lane;
171
+ if (typeof readiness === "string" && typeof lane === "string") {
172
+ fields.result_summary = `semantic advisor ${readiness} via ${lane}`;
173
+ }
174
+ }
175
+ if (toolName === "kb_suggest_predicates" && structuredContent) {
176
+ appendPredicateSuggestionFields(fields, structuredContent);
177
+ const count = Number(fields.predicate_candidate_count ?? 0);
178
+ const topName = fields.predicate_top_name;
179
+ fields.result_summary =
180
+ typeof topName === "string"
181
+ ? `${count} predicate candidates; top=${topName}`
182
+ : `${count} predicate candidates`;
183
+ }
184
+ if (toolName === "kb_upsert" && structuredContent) {
185
+ const created = Number(structuredContent.created ?? 0);
186
+ const updated = Number(structuredContent.updated ?? 0);
187
+ fields.upsert_created = created;
188
+ fields.upsert_updated = updated;
189
+ fields.upsert_relationships_created = Number(structuredContent.relationships_created ?? 0);
190
+ appendContradictionCheckFields(fields, structuredContent.contradictionCheck);
191
+ appendSemanticAdvisorFields(fields, structuredContent.semanticAdvisor);
192
+ const readiness = fields.semantic_logic_readiness;
193
+ if (typeof readiness === "string") {
194
+ fields.result_summary = `upsert ${created > 0 ? "created" : "updated"}; semantic ${readiness}`;
195
+ }
196
+ }
88
197
  if (!fields.result_summary) {
89
198
  fields.result_summary = `${toolName} completed`;
90
199
  }
@@ -385,6 +385,16 @@ function detectStrictPropertySuggestion(payload, statement) {
385
385
  value_int: Number(comparative.groups.value),
386
386
  }, "Comparative numeric prose is a strict property constraint.", 0.86);
387
387
  }
388
+ const decisecondSlot = statement.match(/^.+?\s+(?:must|shall|should)\s+normalize\s+into\s+canonical\s+integer\s+decisecond\s+(?<subject>[a-z][a-z0-9_-]*)\s+values?\.?$/i);
389
+ if (decisecondSlot?.groups?.subject) {
390
+ return strictSuggestion(payload, decisecondSlot[0], {
391
+ subject_key: normalizeSubjectKey(decisecondSlot.groups.subject),
392
+ property_key: "slot_precision",
393
+ operator: "eq",
394
+ value_type: "string",
395
+ value_string: "decisecond",
396
+ }, "Canonical decisecond slot prose is a strict precision property.", 0.9);
397
+ }
388
398
  const threshold = statement.match(/^(?<subject>.+?)\s+(?:must|shall|should)\s+return\s+within\s+(?<value>\d+)\s+(?<unit>ms|milliseconds?|s|sec|secs|seconds?)\.?$/i);
389
399
  if (threshold?.groups?.subject &&
390
400
  threshold.groups.value &&
@@ -598,6 +608,21 @@ function detectPredicateSuggestion(payload, statement) {
598
608
  };
599
609
  return predicateSuggestion(payload, ownership[0], predicate, "Ownership prose assigns responsibility for a resource or behavior and should be queryable as a predicate.");
600
610
  }
611
+ const mergePolicy = statement.match(/^(?<inputs>.+?)\s+saved\s+in\s+the\s+same\s+(?<slot>.+?)\s+must\s+merge\s+into\s+(?<target>.+?)\s+instead\s+of\s+creating\s+.+?\.?$/i);
612
+ if (mergePolicy?.groups?.inputs &&
613
+ mergePolicy.groups.slot &&
614
+ mergePolicy.groups.target) {
615
+ const inputs = normalizePredicateToken(mergePolicy.groups.inputs);
616
+ const slot = normalizePredicateToken(mergePolicy.groups.slot);
617
+ const target = normalizePredicateToken(mergePolicy.groups.target);
618
+ const predicate = {
619
+ predicate_name: "merge_policy",
620
+ predicate_args: [inputs, slot, target],
621
+ canonical_key: `merge_policy(${inputs},${slot},${target})`,
622
+ polarity: "assert",
623
+ };
624
+ return predicateSuggestion(payload, mergePolicy[0], predicate, "Same-slot merge prose defines ontology-lite merge behavior and should be queryable as a predicate.");
625
+ }
601
626
  const retryPolicy = statement.match(/^(?<subject>.+?)\s+(?:must|shall|should)\s+retry\s+up\s+to\s+(?<count>\d+)\s+(?<unit>times|attempts?)\.?$/i);
602
627
  if (retryPolicy?.groups?.subject &&
603
628
  retryPolicy.groups.count &&
@@ -107,7 +107,7 @@ export const PROMPTS = [
107
107
  "- `kb_graph`: Bounded graph traversal from seed IDs",
108
108
  "- `kb_upsert`: Create or update entities and their relationships",
109
109
  "- `kb_delete`: Remove entities by ID (with dependency safety checks)",
110
- "- `kb_check`: Validate KB integrity against configurable rules",
110
+ "- `kb_check`: Validate KB integrity against configurable rules; omit `rules` for final full validation plus full-KB `qualityDiagnostics[]` audit review",
111
111
  "",
112
112
  "Core modeling principles:",
113
113
  "- Kibi has eight entity types: common authoring (req, scenario, test, fact) and supporting/system (adr, flag, event, symbol).",
@@ -118,7 +118,7 @@ export const PROMPTS = [
118
118
  "- v1 contradictions are limited to exact-value, boolean/enum, numeric range, and polarity conflicts.",
119
119
  "- Use `kb_search` first for discovery, then `kb_query` for exact follow-up before any mutation.",
120
120
  "- Use `kb_upsert` and `kb_delete` only for intentional, traceable KB changes.",
121
- "- Run `kb_check` after meaningful mutations to catch integrity issues early.",
121
+ "- Run `kb_check` with explicit `rules` during iteration for scoped feedback; run an unfiltered `kb_check` before completion to include the full-KB `qualityDiagnostics[]` audit scan.",
122
122
  "- Prefer explicit IDs and enum values to avoid invalid parameters.",
123
123
  "- Model requirements by first creating/reusing fact entities (create-before-link).",
124
124
  "- flag gates runtime/config behavior; use `fact` with `fact_kind: observation` or `meta` for bug and workaround notes.",
@@ -138,7 +138,8 @@ export const PROMPTS = [
138
138
  "5. **Model requirements as facts**: For new/updated reqs, create/reuse fact entities first, then express req semantics with `constrains` + `requires_property` (automated via `kb_model_requirement`).",
139
139
  "6. **Suggest predicates before prose**: For ontology-lane requirements, spell out the prose claim and call `kb_suggest_predicates` before writing `fact_kind: observation`. Apply the selected `fact_kind: predicate` applyPlan, then attach the returned `relationshipPlan` as `requires_predicate` while preserving existing req metadata; use the returned `review:ontology-gap` observation when no predicate fits.",
140
140
  "7. **Mutate**: Call `kb_upsert` for create/update, or `kb_delete` for explicit removals.",
141
- "8. **Targeted checks**: Run `kb_check` after meaningful mutations; specify only the rules you need.",
141
+ "8. **Targeted checks**: Run `kb_check` after meaningful mutations; specify only the rules you need so scoped validation stays fast and skips the full-KB advisory scan.",
142
+ "9. **Final check**: Run `kb_check` without `rules` before completion so hard `violations[]` and advisory full-KB `qualityDiagnostics[]` are both reviewed.",
142
143
  "",
143
144
  "If a tool returns empty results, do not assume failure. Re-check filters (type, id, tags, sourceFile, limit, or offset).",
144
145
  ].join("\n"),
@@ -203,7 +204,7 @@ function registerDocResources() {
203
204
  "2. `kb_upsert` for the fact entity first (create-before-link)",
204
205
  "3. `kb_upsert` for the req entity and include `relationships` with `constrains` and `requires_property`",
205
206
  "4. Reuse the same constrained fact ID across related requirements; vary property facts only when semantics differ",
206
- '5. `kb_check` with `{ "rules": ["required-fields","no-dangling-refs"] }` for targeted validation',
207
+ '5. `kb_check` with `{ "rules": ["required-fields","no-dangling-refs"] }` for targeted validation; supplying `rules` skips the full-KB advisory scan',
207
208
  "",
208
209
  "## Model requirements as ontology predicates",
209
210
  '1. Spell out the requirement prose and call `kb_suggest_predicates` with `{ "text": "...", "requirementId": "REQ-..." }`',
@@ -222,9 +223,9 @@ function registerDocResources() {
222
223
  '1. `kb_query` with `{ "type": "test" }` to check for existing test IDs',
223
224
  '2. `kb_query` with `{ "id": "REQ-XXX" }` to verify the requirement exists',
224
225
  "3. `kb_upsert` with entity payload and `relationships` containing `verified_by`",
225
- '4. `kb_check` with `{ "rules": ["required-fields","no-dangling-refs"] }` for targeted validation',
226
+ '4. `kb_check` with `{ "rules": ["required-fields","no-dangling-refs"] }` for targeted validation; use an unfiltered `kb_check` before completion for full-KB `qualityDiagnostics[]`',
226
227
  "",
227
- "Note: Always use query-first pattern. Specify only needed rules in kb_check for faster validation.",
228
+ "Note: Always use query-first pattern. Specify only needed rules in kb_check for faster iteration; omit rules for the final full validation and full-KB quality diagnostics review.",
228
229
  ].join("\n");
229
230
  return [
230
231
  {
@@ -88,7 +88,10 @@ export function registerConfiguredTools(server, runtime, registerTool) {
88
88
  });
89
89
  register({
90
90
  name: "kb_validate_upsert",
91
- handler: async (args) => runtime.handleKbValidateUpsert(args),
91
+ handler: async (args) => {
92
+ const prolog = await runtime.ensureProlog();
93
+ return runtime.handleKbValidateUpsert(prolog, args);
94
+ },
92
95
  });
93
96
  register({
94
97
  name: "kb_delete",
@@ -0,0 +1,67 @@
1
+ /*
2
+ Kibi — repo-local, per-branch, queryable long-term memory for software projects
3
+ Copyright (C) 2026 Piotr Franczyk
4
+
5
+ This program is free software: you can redistribute it and/or modify
6
+ it under the terms of the GNU Affero General Public License as published by
7
+ the Free Software Foundation, either version 3 of the License, or
8
+ (at your option) any later version.
9
+
10
+ This program is distributed in the hope that it will be useful,
11
+ but WITHOUT ANY WARRANTY; without even the implied warranty of
12
+ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
13
+ GNU Affero General Public License for more details.
14
+
15
+ You should have received a copy of the GNU Affero General Public License
16
+ along with this program. If not, see <https://www.gnu.org/licenses/>.
17
+ */
18
+ import { readFile } from "node:fs/promises";
19
+ import * as path from "node:path";
20
+ import { DEFAULT_CHECKS_CONFIG, RULE_NAMES, } from "kibi-cli/public/check-types";
21
+ // implements REQ-002
22
+ export async function loadChecksConfig(workspaceRoot) {
23
+ const configPath = path.join(workspaceRoot, ".kb", "config.json");
24
+ try {
25
+ const content = await readFile(configPath, "utf8");
26
+ const parsed = JSON.parse(content);
27
+ const parsedRules = parsed.checks?.rules;
28
+ const normalizedRules = {
29
+ ...DEFAULT_CHECKS_CONFIG.rules,
30
+ };
31
+ if (parsedRules && typeof parsedRules === "object") {
32
+ for (const [key, value] of Object.entries(parsedRules)) {
33
+ if (typeof value === "boolean") {
34
+ normalizedRules[key] = value;
35
+ }
36
+ }
37
+ }
38
+ const parsedSt = parsed.checks?.symbolTraceability;
39
+ const normalizedSt = { ...DEFAULT_CHECKS_CONFIG.symbolTraceability };
40
+ if (parsedSt &&
41
+ typeof parsedSt === "object" &&
42
+ typeof parsedSt.requireAdr === "boolean") {
43
+ normalizedSt.requireAdr = parsedSt.requireAdr;
44
+ }
45
+ return {
46
+ rules: normalizedRules,
47
+ symbolTraceability: normalizedSt,
48
+ };
49
+ }
50
+ catch {
51
+ return DEFAULT_CHECKS_CONFIG;
52
+ }
53
+ }
54
+ // implements REQ-002
55
+ export function getEffectiveRules(configRules, requestedRules) {
56
+ if (requestedRules && requestedRules.length > 0) {
57
+ return new Set(requestedRules.filter((rule) => RULE_NAMES.has(rule)));
58
+ }
59
+ const effective = new Set();
60
+ for (const rule of RULE_NAMES) {
61
+ const enabled = configRules[rule] ?? true;
62
+ if (enabled) {
63
+ effective.add(rule);
64
+ }
65
+ }
66
+ return effective;
67
+ }
@@ -0,0 +1,92 @@
1
+ function formatDiagnosticsForMcp(diagnostics) {
2
+ return diagnostics.map((d) => ({
3
+ category: d.category,
4
+ severity: d.severity,
5
+ message: d.message,
6
+ ...(d.file !== undefined ? { file: d.file } : {}),
7
+ ...(d.suggestion !== undefined ? { suggestion: d.suggestion } : {}),
8
+ }));
9
+ }
10
+ export function formatImpactText(impactResult) {
11
+ if (impactResult.impactDiagnostics.length === 0) {
12
+ return "No impact diagnostics found";
13
+ }
14
+ const details = impactResult.impactDiagnostics.map((diagnostic) => {
15
+ const files = diagnostic.files.length > 0
16
+ ? diagnostic.files.join(", ")
17
+ : "unknown-source";
18
+ return `- ${diagnostic.id} | ${diagnostic.severity} | ${files} | ${diagnostic.message} Suggestion: ${diagnostic.suggestion}`;
19
+ });
20
+ return `${impactResult.impactDiagnostics.length} impact diagnostics found\n${details.join("\n")}`;
21
+ }
22
+ function formatOptionalList(label, values) {
23
+ if (values === undefined || values.length === 0) {
24
+ return [];
25
+ }
26
+ return [`${label}: ${values.join(", ")}`];
27
+ }
28
+ export function formatQualityDiagnosticsText(diagnostics) {
29
+ if (diagnostics.length === 0) {
30
+ return "No quality diagnostics found";
31
+ }
32
+ const details = diagnostics.map((diagnostic) => {
33
+ const parts = [
34
+ diagnostic.id,
35
+ diagnostic.severity,
36
+ diagnostic.category,
37
+ diagnostic.message,
38
+ ];
39
+ const metadata = [
40
+ `Blocking: ${diagnostic.blocking ? "yes" : "no"}`,
41
+ ...formatOptionalList("Files", diagnostic.files),
42
+ ...formatOptionalList("Docs", diagnostic.docs),
43
+ ...(diagnostic.entityId !== undefined
44
+ ? [`Entity: ${diagnostic.entityId}`]
45
+ : []),
46
+ ...(diagnostic.source !== undefined
47
+ ? [`Source: ${diagnostic.source}`]
48
+ : []),
49
+ `Suggestion: ${diagnostic.suggestion}`,
50
+ ];
51
+ return `- ${parts.join(" | ")}\n ${metadata.join("\n ")}`;
52
+ });
53
+ return `${diagnostics.length} quality diagnostic${diagnostics.length === 1 ? "" : "s"} found\n${details.join("\n")}`;
54
+ }
55
+ export function buildStructuredContent(input) {
56
+ const qualityDiagnostics = input.qualityDiagnostics ?? [];
57
+ return {
58
+ violations: [...input.violations],
59
+ count: input.violations.length,
60
+ diagnostics: formatDiagnosticsForMcp(input.diagnostics),
61
+ ...(qualityDiagnostics.length > 0
62
+ ? { qualityDiagnostics: [...qualityDiagnostics] }
63
+ : {}),
64
+ ...(input.impactResult
65
+ ? {
66
+ impactDiagnostics: input.impactResult.impactDiagnostics,
67
+ sourceFiles: input.impactResult.sourceFiles,
68
+ extractedSymbols: input.impactResult.extractedSymbols,
69
+ linkedEntities: input.impactResult.linkedEntities,
70
+ nextActions: input.impactResult.nextActions,
71
+ }
72
+ : {}),
73
+ };
74
+ }
75
+ export function formatViolationText(violations) {
76
+ if (violations.length === 0) {
77
+ return "No violations found";
78
+ }
79
+ const details = violations.map((violation) => {
80
+ const parts = [
81
+ violation.rule,
82
+ violation.entityId,
83
+ violation.source ?? "unknown-source",
84
+ violation.description,
85
+ ];
86
+ if (violation.suggestion) {
87
+ parts.push(`Suggestion: ${violation.suggestion}`);
88
+ }
89
+ return `- ${parts.join(" | ")}`;
90
+ });
91
+ return `${violations.length} violations found\n${details.join("\n")}`;
92
+ }
@@ -0,0 +1,45 @@
1
+ /*
2
+ Kibi — repo-local, per-branch, queryable long-term memory for software projects
3
+ Copyright (C) 2026 Piotr Franczyk
4
+
5
+ This program is free software: you can redistribute it and/or modify
6
+ it under the terms of the GNU Affero General Public License as published by
7
+ the Free Software Foundation, either version 3 of the License, or
8
+ (at your option) any later version.
9
+
10
+ This program is distributed in the hope that it will be useful,
11
+ but WITHOUT ANY WARRANTY; without even the implied warranty of
12
+ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
13
+ GNU Affero General Public License for more details.
14
+
15
+ You should have received a copy of the GNU Affero General Public License
16
+ along with this program. If not, see <https://www.gnu.org/licenses/>.
17
+ */
18
+ import { analyzeChangedFileImpact, } from "kibi-cli/public/impact-diagnostics";
19
+ export function hasImpactOptions(args) {
20
+ return Boolean(args.includeImpactDiagnostics ||
21
+ args.staged ||
22
+ args.includeWorkingTreeDiff ||
23
+ (args.sourceFiles && args.sourceFiles.length > 0));
24
+ }
25
+ export function analyzeKbCheckImpact(workspaceRoot, args) {
26
+ if (!hasImpactOptions(args)) {
27
+ return undefined;
28
+ }
29
+ return analyzeChangedFileImpact({
30
+ workspaceRoot,
31
+ ...(args.sourceFiles !== undefined
32
+ ? { sourceFiles: args.sourceFiles }
33
+ : {}),
34
+ ...(args.staged !== undefined ? { staged: args.staged } : {}),
35
+ ...(args.includeWorkingTreeDiff !== undefined
36
+ ? { includeWorkingTreeDiff: args.includeWorkingTreeDiff }
37
+ : {}),
38
+ ...(args.includeImpactDiagnostics !== undefined
39
+ ? { includeImpactDiagnostics: args.includeImpactDiagnostics }
40
+ : {}),
41
+ ...(args.maxDiagnostics !== undefined
42
+ ? { maxDiagnostics: args.maxDiagnostics }
43
+ : {}),
44
+ });
45
+ }
@@ -0,0 +1,51 @@
1
+ import { resolveCorePlPath } from "./core-module.js";
2
+ import { collectQueryPlanSafetyViolations } from "./query-plan-safety.js";
3
+ // implements REQ-002
4
+ export async function runAggregatedChecks(prolog, rulesAllowlist, requireAdr) {
5
+ const violations = [];
6
+ const checksPlPath = resolveCorePlPath("checks.pl");
7
+ if (rulesAllowlist.has("query-plan-safety")) {
8
+ violations.push(...collectQueryPlanSafetyViolations(checksPlPath));
9
+ }
10
+ const normalizedChecksPlPath = checksPlPath.replace(/\\/g, "/");
11
+ const checksPlPathEscaped = normalizedChecksPlPath.replace(/'/g, "''");
12
+ const requireAdrStr = requireAdr ? "true" : "false";
13
+ const query = `(load_files('${checksPlPathEscaped}', [if(changed)]),
14
+ ( predicate_property(checks:check_all_json_with_options(_, _), _)
15
+ -> call(checks:check_all_json_with_options(JsonString, ${requireAdrStr}))
16
+ ; call(checks:check_all_json(JsonString))
17
+ ))`;
18
+ const result = await prolog.query(query);
19
+ if (!result.success) {
20
+ throw new Error(`Aggregated checks query failed: ${result.error || "Unknown error"}`);
21
+ }
22
+ const violationsDict = parseViolations(result.bindings.JsonString);
23
+ for (const ruleViolations of Object.values(violationsDict)) {
24
+ for (const v of ruleViolations) {
25
+ const isAllowed = rulesAllowlist.has(v.rule);
26
+ if (isAllowed) {
27
+ violations.push({
28
+ rule: v.rule,
29
+ entityId: v.entityId,
30
+ description: v.description,
31
+ ...(v.suggestion ? { suggestion: v.suggestion } : {}),
32
+ ...(v.source ? { source: v.source } : {}),
33
+ });
34
+ }
35
+ }
36
+ }
37
+ return violations;
38
+ }
39
+ function parseViolations(jsonString) {
40
+ try {
41
+ if (!jsonString || typeof jsonString !== "string") {
42
+ throw new Error("No JSON string in binding");
43
+ }
44
+ const parsed = JSON.parse(jsonString);
45
+ const violations = typeof parsed === "string" ? JSON.parse(parsed) : parsed;
46
+ return violations;
47
+ }
48
+ catch (parseError) {
49
+ throw new Error(`Failed to parse violations JSON: ${parseError instanceof Error ? parseError.message : String(parseError)}`);
50
+ }
51
+ }
@@ -0,0 +1 @@
1
+ export {};
@@ -1,99 +1,24 @@
1
- /*
2
- Kibi — repo-local, per-branch, queryable long-term memory for software projects
3
- Copyright (C) 2026 Piotr Franczyk
4
-
5
- This program is free software: you can redistribute it and/or modify
6
- it under the terms of the GNU Affero General Public License as published by
7
- the Free Software Foundation, either version 3 of the License, or
8
- (at your option) any later version.
9
-
10
- This program is distributed in the hope that it will be useful,
11
- but WITHOUT ANY WARRANTY; without even the implied warranty of
12
- MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
13
- GNU Affero General Public License for more details.
14
-
15
- You should have received a copy of the GNU Affero General Public License
16
- along with this program. If not, see <https://www.gnu.org/licenses/>.
17
- */
18
- import { readFile } from "node:fs/promises";
19
- import * as path from "node:path";
20
- import { DEFAULT_CHECKS_CONFIG, RULE_NAMES, } from "kibi-cli/public/check-types";
1
+ import { collectFullKbQualityDiagnostics, } from "kibi-cli/public/impact-diagnostics";
21
2
  import { resolveWorkspaceRoot } from "../workspace.js";
22
- import { resolveCorePlPath } from "./core-module.js";
23
- function formatDiagnosticsForMcp(diagnostics) {
24
- return diagnostics.map((d) => ({
25
- category: d.category,
26
- severity: d.severity,
27
- message: d.message,
28
- ...(d.file !== undefined ? { file: d.file } : {}),
29
- ...(d.suggestion !== undefined ? { suggestion: d.suggestion } : {}),
30
- }));
31
- }
32
- function formatViolationText(violations) {
33
- if (violations.length === 0) {
34
- return "No violations found";
35
- }
36
- const details = violations.map((violation) => {
37
- const parts = [
38
- violation.rule,
39
- violation.entityId,
40
- violation.source ?? "unknown-source",
41
- violation.description,
42
- ];
43
- if (violation.suggestion) {
44
- parts.push(`Suggestion: ${violation.suggestion}`);
45
- }
46
- return `- ${parts.join(" | ")}`;
47
- });
48
- return `${violations.length} violations found\n${details.join("\n")}`;
49
- }
50
- // implements REQ-002
51
- async function loadChecksConfig(workspaceRoot) {
52
- const configPath = path.join(workspaceRoot, ".kb", "config.json");
53
- try {
54
- const content = await readFile(configPath, "utf8");
55
- const parsed = JSON.parse(content);
56
- const parsedRules = parsed.checks?.rules;
57
- const normalizedRules = {
58
- ...DEFAULT_CHECKS_CONFIG.rules,
59
- };
60
- if (parsedRules && typeof parsedRules === "object") {
61
- for (const [key, value] of Object.entries(parsedRules)) {
62
- if (typeof value === "boolean") {
63
- normalizedRules[key] = value;
64
- }
65
- // Ignore non-boolean values (they are not added to normalizedRules, preserving defaults)
66
- }
67
- }
68
- const parsedSt = parsed.checks?.symbolTraceability;
69
- const normalizedSt = { ...DEFAULT_CHECKS_CONFIG.symbolTraceability };
70
- if (parsedSt && typeof parsedSt === "object") {
71
- if (typeof parsedSt.requireAdr === "boolean") {
72
- normalizedSt.requireAdr = parsedSt.requireAdr;
73
- }
74
- }
75
- return {
76
- rules: normalizedRules,
77
- symbolTraceability: normalizedSt,
78
- };
79
- }
80
- catch {
81
- return DEFAULT_CHECKS_CONFIG;
3
+ import { getEffectiveRules, loadChecksConfig } from "./check-config.js";
4
+ import { buildStructuredContent, formatImpactText, formatQualityDiagnosticsText, formatViolationText, } from "./check-format.js";
5
+ import { analyzeKbCheckImpact } from "./check-impact.js";
6
+ import { runAggregatedChecks } from "./check-prolog.js";
7
+ function qualityDiagnosticsFromImpact(impactResult) {
8
+ if (impactResult === undefined) {
9
+ return [];
82
10
  }
11
+ return impactResult.impactDiagnostics.filter((diagnostic) => !diagnostic.blocking && diagnostic.severity !== "error");
83
12
  }
84
- // implements REQ-002
85
- function getEffectiveRules(configRules, requestedRules) {
86
- if (requestedRules && requestedRules.length > 0) {
87
- return new Set(requestedRules.filter((rule) => RULE_NAMES.has(rule)));
13
+ function buildSummary(input) {
14
+ const sections = [formatViolationText(input.violations)];
15
+ if (input.impactResult !== undefined) {
16
+ sections.push(formatImpactText(input.impactResult));
88
17
  }
89
- const effective = new Set();
90
- for (const rule of RULE_NAMES) {
91
- const enabled = configRules[rule] ?? true;
92
- if (enabled) {
93
- effective.add(rule);
94
- }
18
+ if (input.qualityDiagnostics.length > 0) {
19
+ sections.push(formatQualityDiagnosticsText(input.qualityDiagnostics));
95
20
  }
96
- return effective;
21
+ return sections.join("\n");
97
22
  }
98
23
  /**
99
24
  * Handle kb_check tool calls - run validation rules on the KB
@@ -102,18 +27,41 @@ function getEffectiveRules(configRules, requestedRules) {
102
27
  // implements REQ-002
103
28
  export async function handleKbCheck(prolog, args) {
104
29
  const { rules, workspaceRoot: workspaceOverride } = args;
30
+ const hasExplicitRules = rules !== undefined;
105
31
  try {
106
32
  const workspaceRoot = workspaceOverride ?? resolveWorkspaceRoot();
107
33
  const checksConfig = await loadChecksConfig(workspaceRoot);
108
34
  const rulesAllowlist = getEffectiveRules(checksConfig.rules, rules);
35
+ const impactResult = analyzeKbCheckImpact(workspaceRoot, args);
36
+ const impactQualityDiagnostics = qualityDiagnosticsFromImpact(impactResult);
109
37
  if (rulesAllowlist.size === 0) {
38
+ const qualityDiagnostics = hasExplicitRules
39
+ ? []
40
+ : impactResult
41
+ ? impactQualityDiagnostics
42
+ : await collectFullKbQualityDiagnostics({
43
+ prolog,
44
+ ...(args.maxDiagnostics !== undefined
45
+ ? { maxDiagnostics: args.maxDiagnostics }
46
+ : {}),
47
+ });
110
48
  return {
111
- content: [{ type: "text", text: "No violations found" }],
112
- structuredContent: {
49
+ content: [
50
+ {
51
+ type: "text",
52
+ text: buildSummary({
53
+ violations: [],
54
+ impactResult,
55
+ qualityDiagnostics,
56
+ }),
57
+ },
58
+ ],
59
+ structuredContent: buildStructuredContent({
113
60
  violations: [],
114
- count: 0,
115
61
  diagnostics: [],
116
- },
62
+ qualityDiagnostics,
63
+ impactResult,
64
+ }),
117
65
  };
118
66
  }
119
67
  // Ensure we read the latest KB state, not a cached snapshot.
@@ -128,7 +76,22 @@ export async function handleKbCheck(prolog, args) {
128
76
  ...(v.source !== undefined ? { file: v.source } : {}),
129
77
  ...(v.suggestion !== undefined ? { suggestion: v.suggestion } : {}),
130
78
  }));
131
- const summary = formatViolationText(aggregatedViolations);
79
+ const qualityDiagnostics = hasExplicitRules
80
+ ? impactQualityDiagnostics
81
+ : impactResult
82
+ ? impactQualityDiagnostics
83
+ : await collectFullKbQualityDiagnostics({
84
+ prolog,
85
+ hardViolationEntityIds: new Set(aggregatedViolations.map((violation) => violation.entityId)),
86
+ ...(args.maxDiagnostics !== undefined
87
+ ? { maxDiagnostics: args.maxDiagnostics }
88
+ : {}),
89
+ });
90
+ const summary = buildSummary({
91
+ violations: aggregatedViolations,
92
+ impactResult,
93
+ qualityDiagnostics,
94
+ });
132
95
  return {
133
96
  content: [
134
97
  {
@@ -136,11 +99,12 @@ export async function handleKbCheck(prolog, args) {
136
99
  text: summary,
137
100
  },
138
101
  ],
139
- structuredContent: {
102
+ structuredContent: buildStructuredContent({
140
103
  violations: aggregatedViolations,
141
- count: aggregatedViolations.length,
142
- diagnostics: formatDiagnosticsForMcp(diagnostics),
143
- },
104
+ diagnostics,
105
+ qualityDiagnostics,
106
+ impactResult,
107
+ }),
144
108
  };
145
109
  }
146
110
  catch (error) {
@@ -148,51 +112,3 @@ export async function handleKbCheck(prolog, args) {
148
112
  throw new Error(`Check execution failed: ${message}`);
149
113
  }
150
114
  }
151
- // implements REQ-002
152
- async function runAggregatedChecks(prolog, rulesAllowlist, requireAdr) {
153
- const violations = [];
154
- const checksPlPath = resolveCorePlPath("checks.pl");
155
- const normalizedChecksPlPath = checksPlPath.replace(/\\/g, "/");
156
- const checksPlPathEscaped = normalizedChecksPlPath.replace(/'/g, "''");
157
- // Use check_all_json_with_options if available, otherwise fall back to check_all_json
158
- const requireAdrStr = requireAdr ? "true" : "false";
159
- const query = `(use_module('${checksPlPathEscaped}'),
160
- ( predicate_property(checks:check_all_json_with_options(_, _), _)
161
- -> call(checks:check_all_json_with_options(JsonString, ${requireAdrStr}))
162
- ; call(checks:check_all_json(JsonString))
163
- ))`;
164
- const result = await prolog.query(query);
165
- if (!result.success) {
166
- throw new Error(`Aggregated checks query failed: ${result.error || "Unknown error"}`);
167
- }
168
- let violationsDict;
169
- try {
170
- const jsonString = result.bindings.JsonString;
171
- if (!jsonString) {
172
- throw new Error("No JSON string in binding");
173
- }
174
- let parsed = JSON.parse(jsonString);
175
- if (typeof parsed === "string") {
176
- parsed = JSON.parse(parsed);
177
- }
178
- violationsDict = parsed;
179
- }
180
- catch (parseError) {
181
- throw new Error(`Failed to parse violations JSON: ${parseError instanceof Error ? parseError.message : String(parseError)}`);
182
- }
183
- for (const ruleViolations of Object.values(violationsDict)) {
184
- for (const v of ruleViolations) {
185
- const isAllowed = rulesAllowlist.has(v.rule);
186
- if (isAllowed) {
187
- violations.push({
188
- rule: v.rule,
189
- entityId: v.entityId,
190
- description: v.description,
191
- ...(v.suggestion ? { suggestion: v.suggestion } : {}),
192
- ...(v.source ? { source: v.source } : {}),
193
- });
194
- }
195
- }
196
- }
197
- return violations;
198
- }
@@ -0,0 +1,58 @@
1
+ import fs from "node:fs";
2
+ const GENERATOR_PATTERN = /\b(?:kb_entity|kb_relationship|member|memberchk|findall|setof|bagof)\s*\(/;
3
+ const NEGATION_PATTERN = /\\\+\s*/;
4
+ export function collectQueryPlanSafetyViolations(checksPlPath) {
5
+ const source = fs.readFileSync(checksPlPath, "utf8");
6
+ return analyzeSource(source).map((violation) => ({
7
+ rule: "query-plan-safety",
8
+ entityId: violation.predicate,
9
+ description: violation.description,
10
+ suggestion: violation.suggestion,
11
+ source: `${checksPlPath}:${violation.line}`,
12
+ }));
13
+ }
14
+ function analyzeSource(source) {
15
+ const lines = source.split(/\r?\n/);
16
+ const violations = [];
17
+ let clauseStart = 0;
18
+ let predicate = "unknown";
19
+ let clauseLines = [];
20
+ for (let index = 0; index < lines.length; index += 1) {
21
+ const line = lines[index] ?? "";
22
+ if (clauseLines.length === 0 && line.trim().length === 0) {
23
+ continue;
24
+ }
25
+ if (clauseLines.length === 0) {
26
+ clauseStart = index + 1;
27
+ predicate = predicateNameFrom(line) ?? "unknown";
28
+ }
29
+ clauseLines.push(line);
30
+ if (line.trim().endsWith(".")) {
31
+ const violation = analyzeClause(predicate, clauseStart, clauseLines);
32
+ if (violation)
33
+ violations.push(violation);
34
+ clauseLines = [];
35
+ }
36
+ }
37
+ return violations;
38
+ }
39
+ function predicateNameFrom(line) {
40
+ const match = line.match(/^\s*([a-z][a-zA-Z0-9_]*)\s*\(/);
41
+ return match?.[1] ?? null;
42
+ }
43
+ function analyzeClause(predicate, startLine, lines) {
44
+ const negationIndex = lines.findIndex((line) => NEGATION_PATTERN.test(line));
45
+ if (negationIndex < 0)
46
+ return null;
47
+ const hasLaterGenerator = lines
48
+ .slice(negationIndex + 1)
49
+ .some((line) => GENERATOR_PATTERN.test(line));
50
+ if (!hasLaterGenerator)
51
+ return null;
52
+ return {
53
+ predicate,
54
+ line: startLine + negationIndex,
55
+ description: "Negation appears before later generator calls in the same clause.",
56
+ suggestion: "Move kb_entity/kb_relationship/member/findall generators before \\+/1 so variables are bound before negation.",
57
+ };
58
+ }
@@ -0,0 +1,118 @@
1
+ import { escapeAtom, toPrologAtom } from "kibi-cli/prolog/codec";
2
+ function getStringField(record, field) {
3
+ const value = record[field];
4
+ if (typeof value !== "string" || value.trim() === "") {
5
+ throw new Error(`Relationship ${field} must be a non-empty string`);
6
+ }
7
+ return value;
8
+ }
9
+ function normalizePrologAtom(value) {
10
+ return value.replace(/^['"]|['"]$/g, "");
11
+ }
12
+ async function resolveEndpointType(input) {
13
+ if (input.endpointId === input.entity.id) {
14
+ const type = input.entity.type;
15
+ if (typeof type === "string" && type.trim() !== "") {
16
+ return type;
17
+ }
18
+ }
19
+ let result;
20
+ try {
21
+ result = await input.prolog.query(`kb_entity('${escapeAtom(input.endpointId)}', Type, _)`);
22
+ }
23
+ catch (error) {
24
+ if (error instanceof Error) {
25
+ return null;
26
+ }
27
+ throw error;
28
+ }
29
+ if (!result.success) {
30
+ return null;
31
+ }
32
+ const rawType = result.bindings?.Type;
33
+ if (typeof rawType !== "string" || rawType.trim() === "") {
34
+ return null;
35
+ }
36
+ return normalizePrologAtom(rawType);
37
+ }
38
+ function relationshipRecipe(tuple) {
39
+ if (tuple.relType === "verified_by" &&
40
+ tuple.fromType === "fact" &&
41
+ tuple.toType === "test") {
42
+ return [
43
+ "Facts are not directly verified by tests.",
44
+ "Create or update a requirement and link REQ -> TEST with verified_by.",
45
+ "Link the requirement to the fact with constrains or requires_property.",
46
+ ].join(" ");
47
+ }
48
+ if (tuple.relType === "validates" &&
49
+ tuple.fromType === "test" &&
50
+ tuple.toType === "fact") {
51
+ return [
52
+ "Tests validate requirements or scenarios, not facts directly.",
53
+ "Create or update a requirement and link TEST -> REQ with validates.",
54
+ "Link the requirement to the fact with constrains or requires_property.",
55
+ ].join(" ");
56
+ }
57
+ if (tuple.relType === "verified_by") {
58
+ return "verified_by is only valid as req/scenario -> test.";
59
+ }
60
+ if (tuple.relType === "validates") {
61
+ return "validates is only valid as test -> req/scenario.";
62
+ }
63
+ return "Use a typed relationship from docs/entity-schema.md, or relates_to only as a reviewed escape hatch.";
64
+ }
65
+ export function formatInvalidRelationshipTuple(tuple) {
66
+ return `Invalid relationship: ${tuple.relType} from ${tuple.fromType} to ${tuple.toType}. ${relationshipRecipe(tuple)}`;
67
+ }
68
+ export function formatInvalidRelationshipError(rawError) {
69
+ const placeholderMatch = rawError.match(/Invalid relationship:\s*~w from ~w to ~w-\[([^,\]]+),([^,\]]+),([^\]]+)\]/);
70
+ if (placeholderMatch) {
71
+ const [, relType, fromType, toType] = placeholderMatch;
72
+ if (relType && fromType && toType) {
73
+ return formatInvalidRelationshipTuple({ relType, fromType, toType });
74
+ }
75
+ }
76
+ const readableMatch = rawError.match(/Invalid relationship:\s*([^\s]+) from ([^\s]+) to ([^\s.]+)/);
77
+ if (readableMatch) {
78
+ const [, relType, fromType, toType] = readableMatch;
79
+ if (relType && fromType && toType) {
80
+ return formatInvalidRelationshipTuple({ relType, fromType, toType });
81
+ }
82
+ }
83
+ return null;
84
+ }
85
+ export function formatRelationshipSourceMismatch(entityId, relationship) {
86
+ const from = getStringField(relationship, "from");
87
+ const to = getStringField(relationship, "to");
88
+ return [
89
+ `Relationship source must match the upserted entity ${entityId}; received from=${from}.`,
90
+ `To add ${from} -> ${to}, upsert ${from} instead and include the relationship in that call.`,
91
+ ].join(" ");
92
+ }
93
+ export async function validateLiveRelationshipTargets(prolog, entity, relationships) {
94
+ for (const relationship of relationships) {
95
+ const fromType = await resolveEndpointType({
96
+ prolog,
97
+ entity,
98
+ endpointId: getStringField(relationship, "from"),
99
+ });
100
+ const toType = await resolveEndpointType({
101
+ prolog,
102
+ entity,
103
+ endpointId: getStringField(relationship, "to"),
104
+ });
105
+ if (fromType === null || toType === null) {
106
+ continue;
107
+ }
108
+ const tuple = {
109
+ relType: getStringField(relationship, "type"),
110
+ fromType,
111
+ toType,
112
+ };
113
+ const result = await prolog.query(`once(kb:validate_relationship(${toPrologAtom(tuple.relType)}, ${toPrologAtom(tuple.fromType)}, ${toPrologAtom(tuple.toType)}))`);
114
+ if (!result.success) {
115
+ throw new Error(formatInvalidRelationshipTuple(tuple));
116
+ }
117
+ }
118
+ }
@@ -36,7 +36,7 @@ export async function handleKbSkillsLoad(args) {
36
36
  content: [
37
37
  {
38
38
  type: "text",
39
- text: `Loaded bundled skill ${bundle.manifest.id} with ${resources.length} resources`,
39
+ text: `Loaded bundled skill ${bundle.manifest.id} with ${resources.length} resources: ${formatResourceList(resources)}`,
40
40
  },
41
41
  ],
42
42
  structuredContent: payload,
@@ -65,7 +65,25 @@ export async function handleKbSkillsRead(args) {
65
65
  }
66
66
  catch (error) {
67
67
  const message = error instanceof Error ? error.message : String(error);
68
- throw new Error(`Skills read failed: ${message}`);
68
+ throw new Error(`Skills read failed: ${message}${resourceListHint(args.id)}`);
69
+ }
70
+ }
71
+ function formatResourceList(resources) {
72
+ return resources.length === 0 ? "none" : resources.join(", ");
73
+ }
74
+ function resourceListHint(id) {
75
+ if (typeof id !== "string" || id.trim() === "") {
76
+ return "";
77
+ }
78
+ try {
79
+ const bundle = loadBundledSkill(id);
80
+ return `. Declared resources: ${formatResourceList(bundle.manifest.resources ?? [])}`;
81
+ }
82
+ catch (error) {
83
+ if (error instanceof Error) {
84
+ return "";
85
+ }
86
+ throw error;
69
87
  }
70
88
  }
71
89
  function assertNonEmptyString(value, field) {
@@ -27,6 +27,7 @@ import { isMcpDebugEnabled } from "../env.js";
27
27
  import { analyzeSemanticAdvisorInput } from "../semantic-advisor/analyze-prose.js";
28
28
  import { readBranchKbStamp } from "../server/kb-freshness.js";
29
29
  import { attachedBranchKbPath, updateAttachedBranchStamp, } from "../server/session.js";
30
+ import { formatInvalidRelationshipError, formatRelationshipSourceMismatch, validateLiveRelationshipTargets, } from "./relationship-validation.js";
30
31
  import { refreshCoordinatesForSymbolId } from "./symbols.js";
31
32
  let refreshCoordinatesForSymbolIdImpl = refreshCoordinatesForSymbolId;
32
33
  const ajv = new Ajv({ strict: false });
@@ -209,6 +210,7 @@ export async function handleKbUpsert(prolog, args) {
209
210
  // Validate strict-lane fact_kind pairing for constrains/requires_property
210
211
  // implements REQ-011
211
212
  await validateStrictLanePairing(prolog, effectiveRelationships);
213
+ await validateLiveRelationshipTargets(prolog, entity, effectiveRelationships);
212
214
  // Process entities
213
215
  for (const entity of entities) {
214
216
  const id = entity.id;
@@ -318,6 +320,19 @@ export async function handleKbUpsert(prolog, args) {
318
320
  relationships_created: relationshipsCreated,
319
321
  warnings: [...semanticAdvisor.warnings, ...coverageWarnings],
320
322
  semanticAdvisor: semanticAdvisor.receipt,
323
+ ...(type === "req"
324
+ ? {
325
+ contradictionCheck: {
326
+ outcome: args._skipContradictionCheck
327
+ ? "skipped"
328
+ : "no-conflict",
329
+ checked_req_id: entity.id,
330
+ strict_readiness: semanticAdvisor.receipt.logic_readiness === "modeled"
331
+ ? "modeled"
332
+ : semanticAdvisor.receipt.candidate_lane,
333
+ },
334
+ }
335
+ : {}),
321
336
  },
322
337
  };
323
338
  }
@@ -508,6 +523,9 @@ function buildPropertyList(entity) {
508
523
  "priority",
509
524
  "severity",
510
525
  "symbol_role",
526
+ "granularity_reason",
527
+ "verification_scope",
528
+ "verification_perspective",
511
529
  // Typed fact enum fields must be atoms for Prolog validation
512
530
  "fact_kind",
513
531
  "operator",
@@ -587,7 +605,7 @@ function buildRelationshipMetadata(rel) {
587
605
  function validateRelationshipSources(entityId, relationships) {
588
606
  for (const rel of relationships) {
589
607
  if (rel.from !== entityId) {
590
- throw new Error(`Relationship source must match the upserted entity ${entityId}; received from=${String(rel.from)}`);
608
+ throw new Error(formatRelationshipSourceMismatch(entityId, rel));
591
609
  }
592
610
  }
593
611
  }
@@ -766,6 +784,10 @@ function formatUpsertError(entityId, rawError) {
766
784
  if (!rawError) {
767
785
  return `Failed to upsert entity ${entityId}: Unknown error`;
768
786
  }
787
+ const invalidRelationshipError = formatInvalidRelationshipError(rawError);
788
+ if (invalidRelationshipError !== null) {
789
+ return `Failed to upsert entity ${entityId}: ${invalidRelationshipError}`;
790
+ }
769
791
  // Check for contradiction error - Prolog returns kb_contradiction([...]) term
770
792
  // Try to extract readable details from the term
771
793
  const contradictionMatch = rawError.match(/kb_contradiction\(\s*\[([^\]]+)\]\s*\)/);
@@ -1,8 +1,20 @@
1
1
  import { analyzeSemanticAdvisorInput } from "../semantic-advisor/analyze-prose.js";
2
+ import { validateLiveRelationshipTargets } from "./relationship-validation.js";
2
3
  import { validateKbUpsertArgs } from "./upsert.js";
3
- export async function handleKbValidateUpsert(args) {
4
+ function isUpsertArgs(value) {
5
+ return "type" in value && "id" in value && "properties" in value;
6
+ }
7
+ export async function handleKbValidateUpsert(prologOrArgs, maybeArgs) {
4
8
  try {
9
+ const args = maybeArgs ?? (isUpsertArgs(prologOrArgs) ? prologOrArgs : null);
10
+ if (args === null) {
11
+ throw new Error("kb_validate_upsert requires an upsert payload");
12
+ }
13
+ const prolog = maybeArgs === undefined ? null : prologOrArgs;
5
14
  const { entity } = validateKbUpsertArgs(args);
15
+ if (prolog !== null && !isUpsertArgs(prolog)) {
16
+ await validateLiveRelationshipTargets(prolog, entity, args.relationships ?? []);
17
+ }
6
18
  const semanticAdvisor = analyzeSemanticAdvisorInput({
7
19
  payload: { ...args },
8
20
  });
@@ -421,6 +421,16 @@ const BASE_TOOLS = [
421
421
  ],
422
422
  description: "Optional role classification for symbol entities. Example: 'behavioral'.",
423
423
  },
424
+ verification_scope: {
425
+ type: "string",
426
+ enum: ["unit", "integration", "end_to_end"],
427
+ description: "Optional typed verification scope for test entities. Example: 'end_to_end'.",
428
+ },
429
+ verification_perspective: {
430
+ type: "string",
431
+ enum: ["internal", "consumer"],
432
+ description: "Optional typed verification perspective for test entities. Example: 'consumer'.",
433
+ },
424
434
  fact_kind: {
425
435
  type: "string",
426
436
  enum: [
@@ -592,7 +602,7 @@ const BASE_TOOLS = [
592
602
  },
593
603
  {
594
604
  name: "kb_check",
595
- description: "Run KB validation rules and return violations. Use before or after mutations. Do not use for point lookups. No write side effects. Prefer explicit rules for faster iteration.",
605
+ description: "Run KB validation rules and return violations. Use before or after mutations, and after meaningful source edits with impact options to surface symbol granularity and semantic-review diagnostics. Do not use for point lookups. No write side effects. Prefer explicit rules for faster iteration; omit rules for final full validation plus full-KB qualityDiagnostics review.",
596
606
  inputSchema: {
597
607
  type: "object",
598
608
  properties: {
@@ -611,9 +621,37 @@ const BASE_TOOLS = [
611
621
  "domain-contradictions",
612
622
  "strict-fact-shape",
613
623
  "strict-req-fact-pairing",
624
+ "predicate-verifiability",
625
+ "query-plan-safety",
614
626
  ],
615
627
  },
616
- 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, strict-req-fact-pairing. If omitted, server runs all.",
628
+ 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, strict-req-fact-pairing, predicate-verifiability, query-plan-safety. If omitted, server runs all rules plus the full-KB qualityDiagnostics audit scan; if supplied, server preserves scoped validation and skips the full-KB advisory scan.",
629
+ },
630
+ sourceFiles: {
631
+ type: "array",
632
+ items: { type: "string" },
633
+ description: "Optional repo-relative source files to inspect for early impact diagnostics. Use with includeImpactDiagnostics after meaningful source edits.",
634
+ },
635
+ staged: {
636
+ type: "boolean",
637
+ description: "When true, inspect staged source changes for impact diagnostics using the shared CLI impact analyzer without shelling out to kibi check.",
638
+ },
639
+ includeWorkingTreeDiff: {
640
+ type: "boolean",
641
+ description: "When true, inspect current unstaged working-tree diffs for impact diagnostics. Pair with sourceFiles to scope the analysis.",
642
+ },
643
+ includeImpactDiagnostics: {
644
+ type: "boolean",
645
+ description: "When true, include changed-file impact diagnostics such as symbol_granularity_violation and symbol_semantic_review_needed in structured output.",
646
+ },
647
+ maxDiagnostics: {
648
+ type: "integer",
649
+ minimum: 0,
650
+ description: "Optional maximum number of impact diagnostics to return. Graph validation violations are not capped by this value.",
651
+ },
652
+ workspaceRoot: {
653
+ type: "string",
654
+ description: "Optional workspace root for impact diagnostics and .kb/config.json lookup. Defaults to the MCP server workspace.",
617
655
  },
618
656
  },
619
657
  },
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "kibi-mcp",
3
- "version": "0.17.5",
3
+ "version": "0.19.0",
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.12.8",
13
- "kibi-core": "^0.6.4",
12
+ "kibi-cli": "^0.14.0",
13
+ "kibi-core": "^0.7.0",
14
14
  "mcpcat": "^0.1.12",
15
15
  "ts-morph": "^23.0.0",
16
16
  "zod": "^4.3.6"