kibi-mcp 0.15.3 → 0.16.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.
@@ -37,7 +37,7 @@ let diagnosticUsageLogPath = null;
37
37
  export function initializeDiagnosticMode(enabled = DIAGNOSTIC_MODE_ENABLED) {
38
38
  diagnosticUsageLogPath = null;
39
39
  if (!enabled) {
40
- Reflect.deleteProperty(process.env, "KIBI_MCP_DIAGNOSTIC_MODE");
40
+ process.env.KIBI_MCP_DIAGNOSTIC_MODE = "0";
41
41
  return;
42
42
  }
43
43
  const workspaceRoot = resolveWorkspaceRoot();
@@ -38,6 +38,7 @@ function renderToolsDoc() {
38
38
  lines.push("");
39
39
  lines.push("Modeling note: Kibi has eight core entity types grouped into common authoring (req, scenario, test, fact) and supporting/system (adr, flag, event, symbol).");
40
40
  lines.push("Only strict domain facts (`fact_kind: subject` + `property_value`) participate in contradiction inference; use `flag` for runtime/config gates and `fact_kind: observation` or `meta` for bug/workaround notes.");
41
+ lines.push("Predicate flow: before writing ontology prose, call `kb_suggest_predicates`; apply a suggested `fact_kind: predicate` via `requires_predicate`, or record the returned `review:ontology-gap` observation when no predicate fits.");
41
42
  return lines.join("\n");
42
43
  }
43
44
  export const PROMPTS = [
@@ -135,8 +136,9 @@ export const PROMPTS = [
135
136
  "3. **Create-before-link**: Create endpoint entities with `kb_upsert` before linking them.",
136
137
  "4. **Validate intent**: If creating links, call `kb_query` for both endpoint IDs first to ensure they exist.",
137
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`).",
138
- "5. **Mutate**: Call `kb_upsert` for create/update, or `kb_delete` for explicit removals.",
139
- "6. **Targeted checks**: Run `kb_check` after meaningful mutations; specify only the rules you need.",
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
+ "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.",
140
142
  "",
141
143
  "If a tool returns empty results, do not assume failure. Re-check filters (type, id, tags, sourceFile, limit, or offset).",
142
144
  ].join("\n"),
@@ -203,16 +205,16 @@ function registerDocResources() {
203
205
  "4. Reuse the same constrained fact ID across related requirements; vary property facts only when semantics differ",
204
206
  '5. `kb_check` with `{ "rules": ["required-fields","no-dangling-refs"] }` for targeted validation',
205
207
  "",
208
+ "## Model requirements as ontology predicates",
209
+ '1. Spell out the requirement prose and call `kb_suggest_predicates` with `{ "text": "...", "requirementId": "REQ-..." }`',
210
+ "2. If candidates are returned, apply the top or user-selected `structuredContent.applyPlan` to create `fact_kind: predicate`, then attach `structuredContent.relationshipPlan` with `requires_predicate` while preserving existing req metadata",
211
+ "3. If no candidate fits, apply or review the returned `review:ontology-gap` observation instead of silently writing prose",
212
+ "",
206
213
  "Note: Kibi has eight core entity types. Create or reuse `fact` entities first, then create `req` entities and link with `constrains` and `requires_property` (create-before-link).",
207
214
  "Only strict domain facts are contradiction-safe. Use `flag` for runtime/config gates; use `fact` with `fact_kind: observation` or `meta` for bug/workaround notes.",
208
215
  "",
209
216
  "## Find missing coverage",
210
217
  '1. `kb_find_gaps` with `{ "type": "req", "missingRelationships": ["specified_by", "verified_by"] }` to find under-linked requirements',
211
- "",
212
- "## Find missing coverage",
213
- "",
214
- "## Find missing coverage",
215
- '1. `kb_find_gaps` with `{ "type": "req", "missingRelationships": ["specified_by", "verified_by"] }` to find under-linked requirements',
216
218
  '2. `kb_coverage` with `{ "by": "req", "includePassing": false }` to review evaluated coverage rows',
217
219
  '3. `kb_graph` with `{ "seedIds": ["REQ-001"], "direction": "both", "depth": 2 }` to inspect neighboring entities',
218
220
  "",
@@ -31,6 +31,7 @@ import { handleKbQuery } from "../tools/query.js";
31
31
  import { handleKbSearch } from "../tools/search.js";
32
32
  import { handleKbSkillsList, handleKbSkillsLoad, handleKbSkillsRead, } from "../tools/skills.js";
33
33
  import { handleKbStatus } from "../tools/status.js";
34
+ import { handleKbSuggestPredicates, } from "../tools/suggest-predicates.js";
34
35
  import { handleKbUpsert } from "../tools/upsert.js";
35
36
  const DEFAULT_TOOL_TIMEOUT_MS = 90_000;
36
37
  const TOOL_TIMEOUT_ENV = "KIBI_MCP_TOOL_TIMEOUT_MS";
@@ -86,6 +87,7 @@ const DEFAULT_TOOLS_RUNTIME = {
86
87
  handleKbSkillsRead,
87
88
  handleKbUpsert,
88
89
  handleKbModelRequirement,
90
+ handleKbSuggestPredicates,
89
91
  handleKbAutopilotGenerate,
90
92
  };
91
93
  // implements REQ-008
@@ -407,6 +409,10 @@ runtime = DEFAULT_TOOLS_RUNTIME) {
407
409
  const prolog = await runtime.ensureProlog();
408
410
  return runtime.handleKbModelRequirement(prolog, args);
409
411
  }, runtime);
412
+ addTool(server, "kb_suggest_predicates", toolDef("kb_suggest_predicates").description, toolDef("kb_suggest_predicates").inputSchema, async (args) => {
413
+ const prolog = await runtime.ensureProlog();
414
+ return runtime.handleKbSuggestPredicates(prolog, args);
415
+ }, runtime);
410
416
  addTool(server, "kb_autopilot_generate", toolDef("kb_autopilot_generate").description, toolDef("kb_autopilot_generate").inputSchema, async (args) => {
411
417
  const prolog = await runtime.ensureProlog();
412
418
  return runtime.handleKbAutopilotGenerate(prolog, args);
@@ -847,7 +847,7 @@ export async function classifyActivationState(workspaceRoot, prolog) {
847
847
  }
848
848
  }
849
849
  // Recursively collect markdown files under `dir`, excluding known ignore dirs.
850
- function collectMarkdownFiles(dir, workspaceRoot, vendoredRoots) {
850
+ export function collectMarkdownFiles(dir, workspaceRoot, vendoredRoots) {
851
851
  const results = [];
852
852
  if (!fs.existsSync(dir))
853
853
  return results;
@@ -6,6 +6,25 @@ import { buildGenericMarkdownCandidates, buildNormativeRequirementCandidates, bu
6
6
  import { discoverProviderEvidence, resolveActivationPolicy, } from "./autopilot-discovery.js";
7
7
  import { loadEntities } from "./entity-query.js";
8
8
  import { getWorkspaceMigrationWarning } from "./model-requirement.js";
9
+ const defaultAutopilotGenerateDeps = {
10
+ buildGenericMarkdownCandidates,
11
+ buildNormativeRequirementCandidates,
12
+ buildProviderEvidenceCandidates,
13
+ buildSymbolManifestCandidates,
14
+ buildTypedMarkdownCandidates,
15
+ collectSourceOnlyAuthoringSignals,
16
+ discoverProviderEvidence,
17
+ getWorkspaceMigrationWarning,
18
+ loadEntities,
19
+ resolveActivationPolicy,
20
+ };
21
+ let autopilotGenerateDeps = defaultAutopilotGenerateDeps;
22
+ export function _setAutopilotGenerateDepsForTests(deps) {
23
+ autopilotGenerateDeps = { ...defaultAutopilotGenerateDeps, ...deps };
24
+ }
25
+ export function _resetAutopilotGenerateDepsForTests() {
26
+ autopilotGenerateDeps = defaultAutopilotGenerateDeps;
27
+ }
9
28
  function clamp(value, min, max) {
10
29
  return Math.max(min, Math.min(max, value));
11
30
  }
@@ -377,7 +396,7 @@ _prolog, args) {
377
396
  // Gather existing entity ids to suppress duplicates
378
397
  let existingIds = new Set();
379
398
  try {
380
- const entities = await loadEntities(prolog, {});
399
+ const entities = await autopilotGenerateDeps.loadEntities(prolog, {});
381
400
  for (const e of entities) {
382
401
  const id = String(e.id ?? "");
383
402
  if (id)
@@ -389,10 +408,10 @@ _prolog, args) {
389
408
  existingIds = new Set();
390
409
  }
391
410
  const workspaceRoot = resolveWorkspaceRoot();
392
- const activation = await resolveActivationPolicy(workspaceRoot, prolog);
411
+ const activation = await autopilotGenerateDeps.resolveActivationPolicy(workspaceRoot, prolog);
393
412
  const activationState = activation.activationState;
394
- const activationDiscovery = discoverProviderEvidence(workspaceRoot, activation);
395
- const migrationWarning = await getWorkspaceMigrationWarning(workspaceRoot);
413
+ const activationDiscovery = autopilotGenerateDeps.discoverProviderEvidence(workspaceRoot, activation);
414
+ const migrationWarning = await autopilotGenerateDeps.getWorkspaceMigrationWarning(workspaceRoot);
396
415
  const declaredContext = normalizeBootstrapContext(bootstrapContext);
397
416
  const discoveredCandidatePaths = activationDiscovery.evidence.reduce((acc, item) => {
398
417
  const relativePath = item.relativePath;
@@ -414,7 +433,7 @@ _prolog, args) {
414
433
  markdownFiles: [],
415
434
  evidence: candidateDiscovery.evidence.filter((item) => item.kind !== "generic_markdown"),
416
435
  };
417
- let sourceOnlySignals = collectSourceOnlyAuthoringSignals(guidanceDiscovery, {
436
+ let sourceOnlySignals = autopilotGenerateDeps.collectSourceOnlyAuthoringSignals(guidanceDiscovery, {
418
437
  ids: existingIds,
419
438
  workspaceRoot,
420
439
  }, normalizedMinConfidence);
@@ -435,28 +454,31 @@ _prolog, args) {
435
454
  return `${entityType}::${String(title).trim().toLowerCase().replace(/\s+/g, " ")}`;
436
455
  }
437
456
  if (activation.allowCandidateGeneration) {
438
- typedMarkdownCandidates = buildTypedMarkdownCandidates(candidateDiscovery, {
439
- ids: existingIds,
440
- workspaceRoot,
441
- });
442
- manifestCandidates = buildSymbolManifestCandidates(candidateDiscovery, {
457
+ typedMarkdownCandidates =
458
+ autopilotGenerateDeps.buildTypedMarkdownCandidates(candidateDiscovery, {
459
+ ids: existingIds,
460
+ workspaceRoot,
461
+ });
462
+ manifestCandidates = autopilotGenerateDeps.buildSymbolManifestCandidates(candidateDiscovery, {
443
463
  ids: existingIds,
444
464
  workspaceRoot,
445
465
  });
446
466
  if (includeGenericMarkdown) {
447
- genericCandidates = buildGenericMarkdownCandidates(candidateDiscovery, {
467
+ genericCandidates = autopilotGenerateDeps.buildGenericMarkdownCandidates(candidateDiscovery, {
448
468
  ids: existingIds,
449
469
  workspaceRoot,
450
470
  }, normalizedMinConfidence);
451
- normativeRequirementCandidates = buildNormativeRequirementCandidates(candidateDiscovery, {
471
+ normativeRequirementCandidates =
472
+ autopilotGenerateDeps.buildNormativeRequirementCandidates(candidateDiscovery, {
473
+ ids: existingIds,
474
+ workspaceRoot,
475
+ }, normalizedMinConfidence);
476
+ }
477
+ providerEvidenceCandidates =
478
+ autopilotGenerateDeps.buildProviderEvidenceCandidates(candidateDiscovery, {
452
479
  ids: existingIds,
453
480
  workspaceRoot,
454
481
  }, normalizedMinConfidence);
455
- }
456
- providerEvidenceCandidates = buildProviderEvidenceCandidates(candidateDiscovery, {
457
- ids: existingIds,
458
- workspaceRoot,
459
- }, normalizedMinConfidence);
460
482
  allCandidates = [
461
483
  ...typedMarkdownCandidates,
462
484
  ...manifestCandidates,
@@ -0,0 +1,554 @@
1
+ import { createHash } from "node:crypto";
2
+ import { parseEntityFromList, parseListOfLists } from "kibi-cli/prolog/codec";
3
+ const DEFAULT_MIN_SCORE = 0.35;
4
+ const DEFAULT_MAX_CANDIDATES = 5;
5
+ const BUILT_IN_PREDICATE_SCHEMAS = [
6
+ {
7
+ id: "FACT-SCHEMA-STATE",
8
+ predicate_name: "state",
9
+ title: "State assertion",
10
+ description: "A subject has or enters a named state.",
11
+ argument_names: ["subject", "state"],
12
+ argument_types: ["entity", "state"],
13
+ keywords: ["state", "mode", "idle", "active", "draft", "edit"],
14
+ examples: ["state(editor.annotation, idle)"],
15
+ tags: ["state", "workflow"],
16
+ },
17
+ {
18
+ id: "FACT-SCHEMA-TRANSITION",
19
+ predicate_name: "transition",
20
+ title: "State transition",
21
+ description: "A subject transitions between states because of a trigger.",
22
+ argument_names: ["subject", "from_state", "to_state", "trigger"],
23
+ argument_types: ["entity", "state", "state", "trigger"],
24
+ keywords: [
25
+ "transition",
26
+ "enter",
27
+ "leave",
28
+ "idle",
29
+ "navigate",
30
+ "cancel",
31
+ "escape",
32
+ ],
33
+ examples: ["transition(editor.annotation, draft, idle, navigation)"],
34
+ tags: ["state", "workflow"],
35
+ },
36
+ {
37
+ id: "FACT-SCHEMA-GUARD",
38
+ predicate_name: "guard",
39
+ title: "Behavior guard",
40
+ description: "A condition gates or forbids behavior for a subject.",
41
+ argument_names: ["subject", "condition", "expected"],
42
+ argument_types: ["entity", "condition", "boolean"],
43
+ keywords: ["guard", "unless", "readonly", "scrubbing"],
44
+ examples: ["guard(editor.annotation, isReadOnly, false)"],
45
+ tags: ["guard", "workflow"],
46
+ },
47
+ {
48
+ id: "FACT-SCHEMA-HAS-UNSAVED-CHANGES",
49
+ predicate_name: "has_unsaved_changes",
50
+ title: "Unsaved change state",
51
+ description: "A subject has unsaved or dirty local edits.",
52
+ argument_names: ["subject", "expected"],
53
+ argument_types: ["entity", "boolean"],
54
+ keywords: ["unsaved", "dirty", "draft", "edits", "changes"],
55
+ examples: ["has_unsaved_changes(editor.annotation, true)"],
56
+ tags: ["state", "persistence"],
57
+ },
58
+ {
59
+ id: "FACT-SCHEMA-COMMIT-ACTION",
60
+ predicate_name: "commit_action",
61
+ title: "Commit or save action",
62
+ description: "A trigger commits, saves, or persists a subject within a scope.",
63
+ argument_names: ["subject", "trigger", "scope"],
64
+ argument_types: ["entity", "trigger", "scope"],
65
+ keywords: [
66
+ "save",
67
+ "saves",
68
+ "saved",
69
+ "auto-save",
70
+ "autosave",
71
+ "commit",
72
+ "persist",
73
+ "navigation",
74
+ "navigates",
75
+ "draft",
76
+ ],
77
+ examples: ["commit_action(editor.annotation, navigation, draft)"],
78
+ tags: ["persistence", "workflow"],
79
+ },
80
+ {
81
+ id: "FACT-SCHEMA-DISCARD-ACTION",
82
+ predicate_name: "discard_action",
83
+ title: "Discard action",
84
+ description: "A trigger discards or cancels changes for a subject within a scope.",
85
+ argument_names: ["subject", "trigger", "scope"],
86
+ argument_types: ["entity", "trigger", "scope"],
87
+ keywords: ["discard", "cancel", "escape", "revert", "without save"],
88
+ examples: ["discard_action(editor.annotation, escape, active_annotation)"],
89
+ tags: ["persistence", "workflow"],
90
+ },
91
+ {
92
+ id: "FACT-SCHEMA-ACCESSIBILITY",
93
+ predicate_name: "accessibility_requirement",
94
+ title: "Accessibility requirement",
95
+ description: "A subject must satisfy an accessibility standard or severity target.",
96
+ argument_names: ["subject", "standard", "severity"],
97
+ argument_types: ["entity", "standard", "severity"],
98
+ keywords: ["accessibility", "a11y", "wcag", "keyboard", "screen reader"],
99
+ examples: ["accessibility_requirement(game.flow, WCAG, high)"],
100
+ tags: ["accessibility", "quality"],
101
+ },
102
+ {
103
+ id: "FACT-SCHEMA-RETENTION-POLICY",
104
+ predicate_name: "retention_policy",
105
+ title: "Retention policy",
106
+ description: "A subject is retained for a bounded duration.",
107
+ argument_names: ["subject", "duration", "unit"],
108
+ argument_types: ["entity", "number", "unit"],
109
+ keywords: ["retain", "retained", "retention", "days", "months", "years"],
110
+ examples: ["retention_policy(customer.data, 7, years)"],
111
+ tags: ["data", "policy"],
112
+ },
113
+ {
114
+ id: "FACT-SCHEMA-RESOURCE-CONSTRAINT",
115
+ predicate_name: "resource_constraint",
116
+ title: "Resource constraint",
117
+ description: "A subject constrains a resource by operator, threshold, and unit.",
118
+ argument_names: ["subject", "resource", "operator", "threshold", "unit"],
119
+ argument_types: ["entity", "resource", "operator", "number", "unit"],
120
+ keywords: ["limit", "maximum", "minimum", "latency", "timeout", "size"],
121
+ examples: ["resource_constraint(api.search, latency, lte, 200, ms)"],
122
+ tags: ["performance", "constraint"],
123
+ },
124
+ {
125
+ id: "FACT-SCHEMA-FEATURE-GATE",
126
+ predicate_name: "feature_gate",
127
+ title: "Feature gate",
128
+ description: "A subject is controlled by a runtime or configuration gate.",
129
+ argument_names: ["subject", "gate", "expected"],
130
+ argument_types: ["entity", "flag", "boolean"],
131
+ keywords: ["flag", "feature gate", "enabled", "disabled", "kill switch"],
132
+ examples: ["feature_gate(checkout.v2, checkoutV2Enabled, true)"],
133
+ tags: ["flag", "runtime"],
134
+ },
135
+ {
136
+ id: "FACT-SCHEMA-EVENT-PUBLISH",
137
+ predicate_name: "publishes_event",
138
+ title: "Event publication",
139
+ description: "A subject publishes a domain or system event.",
140
+ argument_names: ["subject", "event"],
141
+ argument_types: ["entity", "event"],
142
+ keywords: ["publish", "publishes", "emit", "emits", "event"],
143
+ examples: ["publishes_event(order.checkout, OrderSubmitted)"],
144
+ tags: ["event", "architecture"],
145
+ },
146
+ {
147
+ id: "FACT-SCHEMA-ACCEPTANCE-RULE",
148
+ predicate_name: "acceptance_rule",
149
+ title: "Acceptance rule",
150
+ description: "A subject has an observable acceptance outcome.",
151
+ argument_names: ["subject", "outcome"],
152
+ argument_types: ["entity", "outcome"],
153
+ keywords: [
154
+ "acceptance",
155
+ "observable",
156
+ "outcome",
157
+ "must show",
158
+ "must display",
159
+ ],
160
+ examples: ["acceptance_rule(search.results, shows_empty_state)"],
161
+ tags: ["acceptance", "quality"],
162
+ },
163
+ ];
164
+ function normalizeText(text) {
165
+ const normalized = String(text ?? "").trim();
166
+ if (!normalized) {
167
+ throw new Error("Predicate suggestion failed: text must be a non-empty string");
168
+ }
169
+ return normalized;
170
+ }
171
+ function normalizeOptionalString(value) {
172
+ const normalized = String(value ?? "").trim();
173
+ return normalized.length > 0 ? normalized : undefined;
174
+ }
175
+ function clampInteger(value, fallback, min, max) {
176
+ const numeric = typeof value === "number" && Number.isFinite(value) ? value : fallback;
177
+ return Math.min(max, Math.max(min, Math.trunc(numeric)));
178
+ }
179
+ function clampScore(value) {
180
+ const numeric = typeof value === "number" && Number.isFinite(value)
181
+ ? value
182
+ : DEFAULT_MIN_SCORE;
183
+ return Math.min(1, Math.max(0, numeric));
184
+ }
185
+ function slug(value) {
186
+ return value
187
+ .trim()
188
+ .toLowerCase()
189
+ .replace(/[^a-z0-9]+/g, "_")
190
+ .replace(/^_+|_+$/g, "");
191
+ }
192
+ function hashId(prefix, parts) {
193
+ const digest = createHash("sha256")
194
+ .update(parts.join("\u0000"))
195
+ .digest("hex")
196
+ .slice(0, 12)
197
+ .toUpperCase();
198
+ return `${prefix}-${digest}`;
199
+ }
200
+ function inferSubject(text, subjectHint) {
201
+ const explicit = normalizeOptionalString(subjectHint);
202
+ if (explicit)
203
+ return explicit;
204
+ const lower = text.toLowerCase();
205
+ if (lower.includes("annotation"))
206
+ return "editor.annotation";
207
+ if (lower.includes("editor"))
208
+ return "editor";
209
+ if (lower.includes("session"))
210
+ return "session";
211
+ if (lower.includes("customer data"))
212
+ return "customer.data";
213
+ if (lower.includes("user"))
214
+ return "user";
215
+ return "requirement.subject";
216
+ }
217
+ function inferTrigger(text) {
218
+ const lower = text.toLowerCase();
219
+ if (lower.includes("navigate"))
220
+ return "navigation";
221
+ if (lower.includes("escape"))
222
+ return "escape";
223
+ if (lower.includes("cancel"))
224
+ return "cancel";
225
+ if (lower.includes("submit"))
226
+ return "submit";
227
+ return "unspecified_trigger";
228
+ }
229
+ function inferScope(text) {
230
+ const lower = text.toLowerCase();
231
+ if (lower.includes("draft"))
232
+ return "draft";
233
+ if (lower.includes("annotation"))
234
+ return "active_annotation";
235
+ if (lower.includes("session"))
236
+ return "session";
237
+ return "subject";
238
+ }
239
+ function inferArgs(schema, text, subject) {
240
+ const lower = text.toLowerCase();
241
+ switch (schema.predicate_name) {
242
+ case "state":
243
+ return [subject, lower.includes("idle") ? "idle" : "active"];
244
+ case "transition":
245
+ return [
246
+ subject,
247
+ lower.includes("edit") ? "edit" : "draft",
248
+ lower.includes("idle") ? "idle" : "active",
249
+ inferTrigger(text),
250
+ ];
251
+ case "guard":
252
+ return [
253
+ subject,
254
+ lower.includes("readonly") ? "isReadOnly" : "condition",
255
+ "true",
256
+ ];
257
+ case "has_unsaved_changes":
258
+ return [subject, lower.includes("no unsaved") ? "false" : "true"];
259
+ case "commit_action":
260
+ case "discard_action":
261
+ return [subject, inferTrigger(text), inferScope(text)];
262
+ case "accessibility_requirement":
263
+ return [
264
+ subject,
265
+ lower.includes("wcag") ? "WCAG" : "accessibility",
266
+ "required",
267
+ ];
268
+ case "retention_policy":
269
+ return [subject, inferDuration(text), inferDurationUnit(text)];
270
+ case "resource_constraint":
271
+ return [
272
+ subject,
273
+ inferResource(text),
274
+ inferOperator(text),
275
+ inferNumber(text),
276
+ inferUnit(text),
277
+ ];
278
+ case "feature_gate":
279
+ return [
280
+ subject,
281
+ inferGate(text),
282
+ lower.includes("disabled") ? "false" : "true",
283
+ ];
284
+ case "publishes_event":
285
+ return [subject, inferEvent(text)];
286
+ case "acceptance_rule":
287
+ return [subject, slug(text).slice(0, 64) || "observable_outcome"];
288
+ default:
289
+ return schema.argument_names.map((name) => name === "subject" ? subject : "unknown");
290
+ }
291
+ }
292
+ function inferDuration(text) {
293
+ return text.match(/\b\d+\b/)?.[0] ?? "1";
294
+ }
295
+ function inferDurationUnit(text) {
296
+ const lower = text.toLowerCase();
297
+ if (lower.includes("year"))
298
+ return "years";
299
+ if (lower.includes("month"))
300
+ return "months";
301
+ if (lower.includes("day"))
302
+ return "days";
303
+ return "unit";
304
+ }
305
+ function inferResource(text) {
306
+ const lower = text.toLowerCase();
307
+ if (lower.includes("latency"))
308
+ return "latency";
309
+ if (lower.includes("timeout"))
310
+ return "timeout";
311
+ if (lower.includes("size"))
312
+ return "size";
313
+ return "resource";
314
+ }
315
+ function inferOperator(text) {
316
+ const lower = text.toLowerCase();
317
+ if (lower.includes("minimum") || lower.includes("at least"))
318
+ return "gte";
319
+ if (lower.includes("not exceed") ||
320
+ lower.includes("not be more than") ||
321
+ lower.includes("no more than") ||
322
+ lower.includes("at most") ||
323
+ lower.includes("maximum")) {
324
+ return "lte";
325
+ }
326
+ if (lower.includes("not"))
327
+ return "neq";
328
+ return "lte";
329
+ }
330
+ function inferNumber(text) {
331
+ return text.match(/\b\d+(?:\.\d+)?\b/)?.[0] ?? "0";
332
+ }
333
+ function inferUnit(text) {
334
+ const lower = text.toLowerCase();
335
+ if (lower.includes("ms"))
336
+ return "ms";
337
+ if (lower.includes("seconds"))
338
+ return "seconds";
339
+ if (lower.includes("mb"))
340
+ return "mb";
341
+ return "unit";
342
+ }
343
+ function inferGate(text) {
344
+ const quoted = text.match(/[`'"](?<gate>[A-Za-z0-9_.:-]+)[`'"]/)?.groups
345
+ ?.gate;
346
+ return quoted ?? "feature_gate";
347
+ }
348
+ function inferEvent(text) {
349
+ const eventName = text.match(/\b[A-Z][A-Za-z0-9]+Event\b/)?.[0];
350
+ return eventName ?? "domain_event";
351
+ }
352
+ function scoreSchema(schema, text) {
353
+ const lower = text.toLowerCase();
354
+ const keywordHits = schema.keywords.filter((keyword) => lower.includes(keyword.toLowerCase())).length;
355
+ if (keywordHits === 0)
356
+ return 0;
357
+ const normalized = keywordHits / Math.max(3, schema.keywords.length / 2);
358
+ const score = Math.min(0.98, 0.24 + normalized * 0.5 + keywordHits * 0.06);
359
+ return Math.round(score * 100) / 100;
360
+ }
361
+ function schemaForCandidate(schema) {
362
+ return {
363
+ id: schema.id,
364
+ predicate_name: schema.predicate_name,
365
+ title: schema.title,
366
+ description: schema.description,
367
+ argument_names: schema.argument_names,
368
+ argument_types: schema.argument_types,
369
+ examples: schema.examples,
370
+ tags: schema.tags,
371
+ };
372
+ }
373
+ async function loadExistingPredicateSchemas(prolog, includeExistingSchemas, warnings) {
374
+ if (!includeExistingSchemas || prolog === null) {
375
+ return [];
376
+ }
377
+ try {
378
+ const queryResult = await prolog.query("findall([Id,'fact',Props], (kb_entity(Id, 'fact', Props), member(fact_kind=predicate_schema, Props)), Results)");
379
+ if (!queryResult.success) {
380
+ throw new Error(queryResult.error || "Query failed with unknown error");
381
+ }
382
+ const facts = queryResult.bindings.Results
383
+ ? parseListOfLists(queryResult.bindings.Results).map(parseEntityFromList)
384
+ : [];
385
+ return facts.flatMap((fact) => predicateSchemaFromEntity(fact));
386
+ }
387
+ catch (error) {
388
+ const message = error instanceof Error ? error.message : String(error);
389
+ warnings.push(`Existing predicate_schema facts could not be loaded: ${message}`);
390
+ return [];
391
+ }
392
+ }
393
+ function predicateSchemaFromEntity(entity) {
394
+ if (entity.fact_kind !== "predicate_schema")
395
+ return [];
396
+ const predicateName = normalizeOptionalString(typeof entity.predicate_name === "string"
397
+ ? entity.predicate_name
398
+ : undefined);
399
+ if (!predicateName)
400
+ return [];
401
+ return [
402
+ {
403
+ id: String(entity.id ?? hashId("FACT-SCHEMA", [predicateName])),
404
+ predicate_name: predicateName,
405
+ title: String(entity.title ?? predicateName),
406
+ description: String(entity.description ??
407
+ `Project-local ${predicateName} predicate schema.`),
408
+ argument_names: stringArray(entity.argument_names),
409
+ argument_types: stringArray(entity.argument_types),
410
+ keywords: [
411
+ predicateName,
412
+ ...stringArray(entity.aliases),
413
+ ...stringArray(entity.tags),
414
+ ],
415
+ examples: stringArray(entity.examples),
416
+ tags: stringArray(entity.tags),
417
+ },
418
+ ];
419
+ }
420
+ function stringArray(value) {
421
+ if (!Array.isArray(value))
422
+ return [];
423
+ return value.flatMap((item) => {
424
+ const normalized = normalizeOptionalString(typeof item === "string" ? item : undefined);
425
+ return normalized ? [normalized] : [];
426
+ });
427
+ }
428
+ function buildSuggestion(schema, text, subject, score) {
429
+ const predicateArgs = inferArgs(schema, text, subject);
430
+ const canonicalKey = `${schema.predicate_name}(${predicateArgs.join(",")})`;
431
+ return {
432
+ id: hashId("SUGGEST", [schema.id, canonicalKey, text]),
433
+ predicate_name: schema.predicate_name,
434
+ predicate_args: predicateArgs,
435
+ canonical_key: canonicalKey,
436
+ polarity: "assert",
437
+ score,
438
+ rationale: `Matched ${schema.predicate_name} because the prose overlaps with ${schema.tags.join(", ")} cues.`,
439
+ schema: schemaForCandidate(schema),
440
+ };
441
+ }
442
+ function buildPredicateApplyPlan(suggestion, args) {
443
+ const factId = hashId("FACT-PRED", [
444
+ args.requirementId ?? "",
445
+ args.source ?? "",
446
+ suggestion.canonical_key,
447
+ ]);
448
+ return [
449
+ {
450
+ type: "fact",
451
+ id: factId,
452
+ properties: {
453
+ title: `Predicate: ${suggestion.canonical_key}`,
454
+ status: "active",
455
+ source: args.source ?? "mcp://kibi/suggest-predicates",
456
+ text_ref: args.source,
457
+ tags: [
458
+ "lane:ontology",
459
+ "predicate-suggestion",
460
+ ...suggestion.schema.tags.map((tag) => `predicate:${tag}`),
461
+ ],
462
+ fact_kind: "predicate",
463
+ predicate_name: suggestion.predicate_name,
464
+ predicate_args: suggestion.predicate_args,
465
+ canonical_key: suggestion.canonical_key,
466
+ polarity: suggestion.polarity,
467
+ },
468
+ relationships: [],
469
+ },
470
+ ];
471
+ }
472
+ function buildRelationshipPlan(factId, requirementId) {
473
+ if (!factId || !requirementId)
474
+ return null;
475
+ return {
476
+ applyAfter: factId,
477
+ requiresExistingReq: requirementId,
478
+ relationship: {
479
+ type: "requires_predicate",
480
+ from: requirementId,
481
+ to: factId,
482
+ },
483
+ instructions: "Apply the predicate fact first, then attach this relationship from the existing requirement without overwriting requirement metadata.",
484
+ };
485
+ }
486
+ function buildGapApplyPlan(text, args) {
487
+ const factId = hashId("FACT-ONTOLOGY-GAP", [
488
+ args.requirementId ?? "",
489
+ args.source ?? "",
490
+ text,
491
+ ]);
492
+ return [
493
+ {
494
+ type: "fact",
495
+ id: factId,
496
+ properties: {
497
+ title: "Ontology gap: predicate schema needed",
498
+ status: "active",
499
+ source: args.source ?? "mcp://kibi/suggest-predicates",
500
+ text_ref: args.source,
501
+ tags: ["review:ontology-gap", "needs_schema_extension"],
502
+ fact_kind: "observation",
503
+ value_string: text,
504
+ },
505
+ relationships: [],
506
+ },
507
+ ];
508
+ }
509
+ // implements REQ-mcp-suggest-predicates
510
+ export async function handleKbSuggestPredicates(prolog, args) {
511
+ const text = normalizeText(args.text);
512
+ const maxCandidates = clampInteger(args.maxCandidates, DEFAULT_MAX_CANDIDATES, 1, 20);
513
+ const minScore = clampScore(args.minScore);
514
+ const warnings = [];
515
+ const subject = inferSubject(text, args.subjectHint);
516
+ const existingSchemas = await loadExistingPredicateSchemas(prolog, args.includeExistingSchemas ?? true, warnings);
517
+ const schemas = [...existingSchemas, ...BUILT_IN_PREDICATE_SCHEMAS];
518
+ const candidates = schemas
519
+ .map((schema) => ({ schema, score: scoreSchema(schema, text) }))
520
+ .filter((scored) => scored.score >= minScore)
521
+ .sort((left, right) => {
522
+ if (right.score !== left.score)
523
+ return right.score - left.score;
524
+ return left.schema.predicate_name.localeCompare(right.schema.predicate_name);
525
+ })
526
+ .slice(0, maxCandidates)
527
+ .map((scored) => buildSuggestion(scored.schema, text, subject, scored.score));
528
+ const recommendedAction = candidates.length > 0 ? "apply_requires_predicate" : "record_ontology_gap";
529
+ const firstCandidate = candidates[0];
530
+ const applyPlan = firstCandidate
531
+ ? buildPredicateApplyPlan(firstCandidate, args)
532
+ : buildGapApplyPlan(text, args);
533
+ const relationshipPlan = firstCandidate
534
+ ? buildRelationshipPlan(String(applyPlan[0]?.id ?? ""), args.requirementId)
535
+ : null;
536
+ const textSummary = candidates.length > 0
537
+ ? `Suggested ${candidates.length} predicate candidate(s). Top match: ${candidates[0]?.predicate_name}. Apply structured predicate facts before falling back to prose.`
538
+ : "No predicate candidate met the confidence threshold; record an ontology gap instead of silently writing prose.";
539
+ return {
540
+ content: [{ type: "text", text: textSummary }],
541
+ structuredContent: {
542
+ text,
543
+ source: args.source ?? null,
544
+ requirementId: args.requirementId ?? null,
545
+ subject,
546
+ candidates,
547
+ recommendedAction,
548
+ applyPlan,
549
+ relationshipPlan,
550
+ warnings,
551
+ },
552
+ applyPlan,
553
+ };
554
+ }
@@ -249,6 +249,7 @@ function collectNarrowExportNames(filePath, content) {
249
249
  scriptKind: chooseScriptKind(filePath),
250
250
  });
251
251
  const names = new Set();
252
+ const methodNameCounts = new Map();
252
253
  for (const fn of sourceFile.getFunctions()) {
253
254
  if (fn.isExported()) {
254
255
  const name = fn.getName();
@@ -261,8 +262,18 @@ function collectNarrowExportNames(filePath, content) {
261
262
  const name = cls.getName();
262
263
  if (name)
263
264
  names.add(name);
265
+ for (const method of cls.getMethods()) {
266
+ const methodName = method.getName();
267
+ if (name)
268
+ names.add(`${name}.${methodName}`);
269
+ methodNameCounts.set(methodName, (methodNameCounts.get(methodName) ?? 0) + 1);
270
+ }
264
271
  }
265
272
  }
273
+ for (const [methodName, count] of methodNameCounts) {
274
+ if (count === 1)
275
+ names.add(methodName);
276
+ }
266
277
  for (const iface of sourceFile.getInterfaces()) {
267
278
  if (iface.isExported())
268
279
  names.add(iface.getName());
@@ -566,6 +566,51 @@ const BASE_TOOLS = [
566
566
  },
567
567
  },
568
568
  },
569
+ {
570
+ name: "kb_suggest_predicates",
571
+ description: "Suggest ontology predicate schemas for prose requirements before agents write facts. Read-only guidance returns ranked candidates, a safe predicate-fact applyPlan, a separate requires_predicate relationshipPlan when a requirement ID is supplied, or an explicit ontology-gap observation when no predicate fits.",
572
+ inputSchema: {
573
+ type: "object",
574
+ required: ["text"],
575
+ properties: {
576
+ text: {
577
+ type: "string",
578
+ description: "Required prose requirement or claim to classify into ontology predicates. Example: 'When users navigate away, draft edits must auto-save.'.",
579
+ },
580
+ requirementId: {
581
+ type: "string",
582
+ description: "Optional existing requirement ID. When provided, the response includes a relationshipPlan describing the req -> fact requires_predicate link to attach after preserving existing requirement metadata.",
583
+ },
584
+ source: {
585
+ type: "string",
586
+ description: "Optional provenance or text reference for generated predicate facts or ontology-gap observations.",
587
+ },
588
+ subjectHint: {
589
+ type: "string",
590
+ description: "Optional canonical subject key to use as the first predicate argument. Example: 'editor.annotation'.",
591
+ },
592
+ maxCandidates: {
593
+ type: "integer",
594
+ default: 5,
595
+ minimum: 1,
596
+ maximum: 20,
597
+ description: "Maximum ranked predicate candidates to return. Default: 5.",
598
+ },
599
+ minScore: {
600
+ type: "number",
601
+ default: 0.35,
602
+ minimum: 0,
603
+ maximum: 1,
604
+ description: "Minimum candidate score. Higher values make ontology-gap fallback more likely. Default: 0.35.",
605
+ },
606
+ includeExistingSchemas: {
607
+ type: "boolean",
608
+ default: true,
609
+ description: "Whether to include existing KB fact_kind=predicate_schema facts alongside Kibi's built-in predicate catalog. Default: true.",
610
+ },
611
+ },
612
+ },
613
+ },
569
614
  {
570
615
  name: "kb_autopilot_generate",
571
616
  description: "Generate agent-centric bootstrap output for KB population. Read-only analysis that returns activation state, bootstrap guidance, candidate entities with evidence, payoff summary, and exact applyPlan payloads for later kb_upsert calls. No mutation side effects.",
@@ -635,11 +680,10 @@ const BASE_TOOLS = [
635
680
  ];
636
681
  /**
637
682
  * Inject _diagnostic_telemetry schema into tool inputs when diagnostic mode is enabled.
638
- * TODO: This function is compile-time guarded by DIAGNOSTIC_MODE_ENABLED and only
639
- * executes when the server starts with the --diagnostic-mode flag. It cannot be
640
- * covered without a CLI integration test.
683
+ * Exported for unit coverage; TOOLS still applies it only when the server starts
684
+ * with the --diagnostic-mode flag.
641
685
  */
642
- function withDiagnosticTelemetrySchema(tools) {
686
+ export function withDiagnosticTelemetrySchema(tools) {
643
687
  return tools.map((tool) => {
644
688
  const schema = tool.inputSchema;
645
689
  const properties = schema.properties && typeof schema.properties === "object"
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "kibi-mcp",
3
- "version": "0.15.3",
3
+ "version": "0.16.0",
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.12.3",
12
+ "kibi-cli": "^0.12.4",
13
13
  "kibi-core": "^0.6.0",
14
14
  "mcpcat": "^0.1.12",
15
15
  "ts-morph": "^23.0.0",