kibi-mcp 0.1.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.
@@ -0,0 +1,208 @@
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
+ /*
19
+ How to apply this header to source files (examples)
20
+
21
+ 1) Prepend header to a single file (POSIX shells):
22
+
23
+ cat LICENSE_HEADER.txt "$FILE" > "$FILE".with-header && mv "$FILE".with-header "$FILE"
24
+
25
+ 2) Apply to multiple files (example: the project's main entry files):
26
+
27
+ for f in packages/cli/bin/kibi packages/mcp/bin/kibi-mcp packages/cli/src/*.ts packages/mcp/src/*.ts; do
28
+ if [ -f "$f" ]; then
29
+ cp "$f" "$f".bak
30
+ (cat LICENSE_HEADER.txt; echo; cat "$f" ) > "$f".new && mv "$f".new "$f"
31
+ fi
32
+ done
33
+
34
+ 3) Avoid duplicating the header: run a quick guard to only add if missing
35
+
36
+ for f in packages/cli/bin/kibi packages/mcp/bin/kibi-mcp; do
37
+ if [ -f "$f" ]; then
38
+ if ! head -n 5 "$f" | grep -q "Copyright (C) 2026 Piotr Franczyk"; then
39
+ cp "$f" "$f".bak
40
+ (cat LICENSE_HEADER.txt; echo; cat "$f" ) > "$f".new && mv "$f".new "$f"
41
+ fi
42
+ fi
43
+ done
44
+ */
45
+ import { execSync } from "node:child_process";
46
+ import * as fs from "node:fs";
47
+ import * as path from "node:path";
48
+ import { resolveKbPath, resolveWorkspaceRoot } from "../workspace.js";
49
+ /**
50
+ * Handle kb_branch_ensure tool calls - create branch KB if not exists
51
+ */
52
+ export async function handleKbBranchEnsure(_prolog, args) {
53
+ const { branch } = args;
54
+ if (!branch || branch.trim() === "") {
55
+ throw new Error("Branch name is required");
56
+ }
57
+ // Sanitize branch name (prevent path traversal)
58
+ const isSafe = (name) => {
59
+ // No empty or excessively long names
60
+ if (!name || name.length > 255)
61
+ return false;
62
+ // No path traversal or absolute paths
63
+ if (name.includes("..") || path.isAbsolute(name) || name.startsWith("/")) {
64
+ return false;
65
+ }
66
+ // Whitelist characters (alphanumeric, dot, underscore, hyphen, forward slash)
67
+ if (!/^[a-zA-Z0-9._\-/]+$/.test(name))
68
+ return false;
69
+ // No redundant slashes or trailing slash/dot
70
+ if (name.includes("//") ||
71
+ name.endsWith("/") ||
72
+ name.endsWith(".") ||
73
+ name.includes("\\")) {
74
+ return false;
75
+ }
76
+ return true;
77
+ };
78
+ if (!isSafe(branch)) {
79
+ throw new Error(`Invalid branch name: ${branch}`);
80
+ }
81
+ const safeBranch = branch;
82
+ try {
83
+ const workspaceRoot = resolveWorkspaceRoot();
84
+ const branchPath = resolveKbPath(workspaceRoot, safeBranch);
85
+ const developPath = resolveKbPath(workspaceRoot, "develop");
86
+ // Check if branch KB already exists
87
+ if (fs.existsSync(branchPath)) {
88
+ return {
89
+ content: [
90
+ {
91
+ type: "text",
92
+ text: `Branch KB '${safeBranch}' already exists`,
93
+ },
94
+ ],
95
+ structuredContent: {
96
+ created: false,
97
+ path: branchPath,
98
+ },
99
+ };
100
+ }
101
+ // Ensure develop branch exists
102
+ if (!fs.existsSync(developPath)) {
103
+ throw new Error("Develop branch KB does not exist. Run 'kb init' first.");
104
+ }
105
+ // Copy develop branch KB to new branch
106
+ fs.cpSync(developPath, branchPath, { recursive: true });
107
+ return {
108
+ content: [
109
+ {
110
+ type: "text",
111
+ text: `Created branch KB '${safeBranch}' from develop`,
112
+ },
113
+ ],
114
+ structuredContent: {
115
+ created: true,
116
+ path: branchPath,
117
+ },
118
+ };
119
+ }
120
+ catch (error) {
121
+ const message = error instanceof Error ? error.message : String(error);
122
+ throw new Error(`Branch ensure failed: ${message}`);
123
+ }
124
+ }
125
+ /**
126
+ * Handle kb_branch_gc tool calls - garbage collect stale branch KBs
127
+ */
128
+ export async function handleKbBranchGc(_prolog, args) {
129
+ const { dry_run = true } = args;
130
+ try {
131
+ const workspaceRoot = resolveWorkspaceRoot();
132
+ const kbRoot = path.dirname(resolveKbPath(workspaceRoot, "develop"));
133
+ // Check if .kb/branches exists
134
+ if (!fs.existsSync(kbRoot)) {
135
+ return {
136
+ content: [
137
+ {
138
+ type: "text",
139
+ text: "No branch KBs found (.kb/branches does not exist)",
140
+ },
141
+ ],
142
+ structuredContent: {
143
+ stale: [],
144
+ deleted: 0,
145
+ },
146
+ };
147
+ }
148
+ let gitBranches;
149
+ try {
150
+ execSync("git rev-parse --git-dir", {
151
+ encoding: "utf-8",
152
+ cwd: workspaceRoot,
153
+ stdio: ["pipe", "pipe", "pipe"],
154
+ env: process.env,
155
+ });
156
+ const output = execSync("git branch --format='%(refname:short)'", {
157
+ encoding: "utf-8",
158
+ cwd: workspaceRoot,
159
+ stdio: ["pipe", "pipe", "pipe"],
160
+ env: process.env,
161
+ });
162
+ gitBranches = new Set(output
163
+ .trim()
164
+ .split("\n")
165
+ .map((b) => b.trim().replace(/^'|'$/g, ""))
166
+ .filter((b) => b));
167
+ }
168
+ catch (error) {
169
+ const message = error instanceof Error ? error.message : String(error);
170
+ throw new Error(`Not in a git repository or git command failed: ${message}`);
171
+ }
172
+ // Get all KB branches
173
+ const kbBranches = fs
174
+ .readdirSync(kbRoot, { withFileTypes: true })
175
+ .filter((dirent) => dirent.isDirectory())
176
+ .map((dirent) => dirent.name);
177
+ // Find stale branches (KB exists but git branch doesn't, excluding develop)
178
+ const staleBranches = kbBranches.filter((kb) => kb !== "develop" && !gitBranches.has(kb));
179
+ // Delete stale branches if not dry run
180
+ let deletedCount = 0;
181
+ if (!dry_run && staleBranches.length > 0) {
182
+ for (const branch of staleBranches) {
183
+ const branchPath = path.join(kbRoot, branch);
184
+ fs.rmSync(branchPath, { recursive: true, force: true });
185
+ deletedCount++;
186
+ }
187
+ }
188
+ const summary = dry_run
189
+ ? `Found ${staleBranches.length} stale branch KB(s) (dry run - not deleted)`
190
+ : `Deleted ${deletedCount} stale branch KB(s)`;
191
+ return {
192
+ content: [
193
+ {
194
+ type: "text",
195
+ text: summary,
196
+ },
197
+ ],
198
+ structuredContent: {
199
+ stale: staleBranches,
200
+ deleted: deletedCount,
201
+ },
202
+ };
203
+ }
204
+ catch (error) {
205
+ const message = error instanceof Error ? error.message : String(error);
206
+ throw new Error(`Branch GC failed: ${message}`);
207
+ }
208
+ }
@@ -0,0 +1,349 @@
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
+ /*
19
+ How to apply this header to source files (examples)
20
+
21
+ 1) Prepend header to a single file (POSIX shells):
22
+
23
+ cat LICENSE_HEADER.txt "$FILE" > "$FILE".with-header && mv "$FILE".with-header "$FILE"
24
+
25
+ 2) Apply to multiple files (example: the project's main entry files):
26
+
27
+ for f in packages/cli/bin/kibi packages/mcp/bin/kibi-mcp packages/cli/src/*.ts packages/mcp/src/*.ts; do
28
+ if [ -f "$f" ]; then
29
+ cp "$f" "$f".bak
30
+ (cat LICENSE_HEADER.txt; echo; cat "$f" ) > "$f".new && mv "$f".new "$f"
31
+ fi
32
+ done
33
+
34
+ 3) Avoid duplicating the header: run a quick guard to only add if missing
35
+
36
+ for f in packages/cli/bin/kibi packages/mcp/bin/kibi-mcp; do
37
+ if [ -f "$f" ]; then
38
+ if ! head -n 5 "$f" | grep -q "Copyright (C) 2026 Piotr Franczyk"; then
39
+ cp "$f" "$f".bak
40
+ (cat LICENSE_HEADER.txt; echo; cat "$f" ) > "$f".new && mv "$f".new "$f"
41
+ fi
42
+ fi
43
+ done
44
+ */
45
+ import * as path from "node:path";
46
+ import { parsePairList } from "./prolog-list.js";
47
+ /**
48
+ * Handle kb_check tool calls - run validation rules on the KB
49
+ * Reuses validation logic from CLI check command
50
+ */
51
+ export async function handleKbCheck(prolog, args) {
52
+ const { rules } = args;
53
+ try {
54
+ const violations = [];
55
+ const allEntityIds = await getAllEntityIds(prolog);
56
+ // Run all validation rules (or specific rules if provided)
57
+ const allRules = [
58
+ "must-priority-coverage",
59
+ "no-dangling-refs",
60
+ "no-cycles",
61
+ "required-fields",
62
+ "symbol-coverage",
63
+ ];
64
+ const rulesToRun = rules && rules.length > 0 ? rules : allRules;
65
+ if (rulesToRun.includes("must-priority-coverage")) {
66
+ violations.push(...(await checkMustPriorityCoverage(prolog)));
67
+ }
68
+ if (rulesToRun.includes("no-dangling-refs")) {
69
+ violations.push(...(await checkNoDanglingRefs(prolog)));
70
+ }
71
+ if (rulesToRun.includes("no-cycles")) {
72
+ violations.push(...(await checkNoCycles(prolog)));
73
+ }
74
+ if (rulesToRun.includes("required-fields")) {
75
+ violations.push(...(await checkRequiredFields(prolog, allEntityIds)));
76
+ }
77
+ if (rulesToRun.includes("symbol-coverage")) {
78
+ violations.push(...(await checkSymbolCoverage(prolog)));
79
+ }
80
+ // Return MCP structured response
81
+ const summary = violations.length === 0
82
+ ? "No violations found"
83
+ : `${violations.length} violations found`;
84
+ return {
85
+ content: [
86
+ {
87
+ type: "text",
88
+ text: summary,
89
+ },
90
+ ],
91
+ structuredContent: {
92
+ violations,
93
+ count: violations.length,
94
+ },
95
+ };
96
+ }
97
+ catch (error) {
98
+ const message = error instanceof Error ? error.message : String(error);
99
+ throw new Error(`Check execution failed: ${message}`);
100
+ }
101
+ }
102
+ async function checkMustPriorityCoverage(prolog) {
103
+ const violations = [];
104
+ const gapsResult = await prolog.query("setof([Req,Reason], coverage_gap(Req, Reason), Rows)");
105
+ if (!gapsResult.success || !gapsResult.bindings.Rows) {
106
+ return violations;
107
+ }
108
+ const gaps = parsePairList(gapsResult.bindings.Rows);
109
+ for (const [reqId, reason] of gaps) {
110
+ const entityResult = await prolog.query(`kb_entity('${reqId}', req, Props)`);
111
+ let source = "";
112
+ if (entityResult.success && entityResult.bindings.Props) {
113
+ const propsStr = entityResult.bindings.Props;
114
+ const sourceMatch = propsStr.match(/source\s*=\s*\^\^?\("([^"]+)"/);
115
+ if (sourceMatch) {
116
+ source = sourceMatch[1];
117
+ }
118
+ }
119
+ const missing = [];
120
+ if (reason.includes("scenario")) {
121
+ missing.push("scenario");
122
+ }
123
+ if (reason.includes("test")) {
124
+ missing.push("test");
125
+ }
126
+ violations.push({
127
+ rule: "must-priority-coverage",
128
+ entityId: reqId,
129
+ description: `Must-priority requirement lacks ${missing.join(" and ")} coverage`,
130
+ source,
131
+ suggestion: missing
132
+ .map((m) => `Create ${m} that covers this requirement`)
133
+ .join("; "),
134
+ });
135
+ }
136
+ return violations;
137
+ }
138
+ async function getAllEntityIds(prolog, type) {
139
+ const typeFilter = type ? `, Type = ${type}` : "";
140
+ const query = `findall(Id, (kb_entity(Id, Type, _)${typeFilter}), Ids)`;
141
+ const result = await prolog.query(query);
142
+ if (!result.success || !result.bindings.Ids) {
143
+ return [];
144
+ }
145
+ const idsStr = result.bindings.Ids;
146
+ const match = idsStr.match(/\[(.*)\]/);
147
+ if (!match) {
148
+ return [];
149
+ }
150
+ const content = match[1].trim();
151
+ if (!content) {
152
+ return [];
153
+ }
154
+ return content.split(",").map((id) => id.trim().replace(/^'|'$/g, ""));
155
+ }
156
+ async function checkNoDanglingRefs(prolog) {
157
+ const violations = [];
158
+ // Get all entity IDs once
159
+ const allEntityIds = new Set(await getAllEntityIds(prolog));
160
+ // Get all relationships by querying all known relationship types
161
+ const relTypes = [
162
+ "depends_on",
163
+ "verified_by",
164
+ "validates",
165
+ "specified_by",
166
+ "relates_to",
167
+ ];
168
+ const allRels = [];
169
+ for (const relType of relTypes) {
170
+ const relsResult = await prolog.query(`findall([From,To], kb_relationship(${relType}, From, To), Rels)`);
171
+ if (relsResult.success && relsResult.bindings.Rels) {
172
+ const relsStr = relsResult.bindings.Rels;
173
+ const match = relsStr.match(/\[(.*)\]/);
174
+ if (match) {
175
+ const content = match[1].trim();
176
+ if (content) {
177
+ const relMatches = content.matchAll(/\[([^,]+),([^\]]+)\]/g);
178
+ for (const relMatch of relMatches) {
179
+ const fromId = relMatch[1].trim().replace(/^'|'$/g, "");
180
+ const toId = relMatch[2].trim().replace(/^'|'$/g, "");
181
+ allRels.push({ from: fromId, to: toId });
182
+ }
183
+ }
184
+ }
185
+ }
186
+ }
187
+ // Check all collected relationships for dangling refs
188
+ for (const rel of allRels) {
189
+ if (!allEntityIds.has(rel.from)) {
190
+ violations.push({
191
+ rule: "no-dangling-refs",
192
+ entityId: rel.from,
193
+ description: `Relationship references non-existent entity: ${rel.from}`,
194
+ suggestion: "Remove relationship or create missing entity",
195
+ });
196
+ }
197
+ if (!allEntityIds.has(rel.to)) {
198
+ violations.push({
199
+ rule: "no-dangling-refs",
200
+ entityId: rel.to,
201
+ description: `Relationship references non-existent entity: ${rel.to}`,
202
+ suggestion: "Remove relationship or create missing entity",
203
+ });
204
+ }
205
+ }
206
+ return violations;
207
+ }
208
+ async function checkNoCycles(prolog) {
209
+ const violations = [];
210
+ // Get all depends_on relationships
211
+ const depsResult = await prolog.query("findall([From,To], kb_relationship(depends_on, From, To), Deps)");
212
+ if (!depsResult.success || !depsResult.bindings.Deps) {
213
+ return violations;
214
+ }
215
+ const depsStr = depsResult.bindings.Deps;
216
+ const match = depsStr.match(/\[(.*)\]/);
217
+ if (!match) {
218
+ return violations;
219
+ }
220
+ const content = match[1].trim();
221
+ if (!content) {
222
+ return violations;
223
+ }
224
+ // Build adjacency map
225
+ const graph = new Map();
226
+ const depMatches = content.matchAll(/\[([^,]+),([^\]]+)\]/g);
227
+ for (const depMatch of depMatches) {
228
+ const from = depMatch[1].trim().replace(/^'|'$/g, "");
229
+ const to = depMatch[2].trim().replace(/^'|'$/g, "");
230
+ if (!graph.has(from)) {
231
+ graph.set(from, []);
232
+ }
233
+ const fromList = graph.get(from);
234
+ if (fromList) {
235
+ fromList.push(to);
236
+ }
237
+ }
238
+ // DFS to detect cycles
239
+ const visited = new Set();
240
+ const recStack = new Set();
241
+ function hasCycleDFS(node, path) {
242
+ visited.add(node);
243
+ recStack.add(node);
244
+ path.push(node);
245
+ const neighbors = graph.get(node) || [];
246
+ for (const neighbor of neighbors) {
247
+ if (!visited.has(neighbor)) {
248
+ const cyclePath = hasCycleDFS(neighbor, [...path]);
249
+ if (cyclePath)
250
+ return cyclePath;
251
+ }
252
+ else if (recStack.has(neighbor)) {
253
+ // Cycle detected
254
+ return [...path, neighbor];
255
+ }
256
+ }
257
+ recStack.delete(node);
258
+ return null;
259
+ }
260
+ // Check each node for cycles
261
+ for (const node of graph.keys()) {
262
+ if (!visited.has(node)) {
263
+ const cyclePath = hasCycleDFS(node, []);
264
+ if (cyclePath) {
265
+ const cycleWithSources = [];
266
+ for (const entityId of cyclePath) {
267
+ const entityResult = await prolog.query(`kb_entity('${entityId}', _, Props)`);
268
+ let sourceName = entityId;
269
+ if (entityResult.success && entityResult.bindings.Props) {
270
+ const propsStr = entityResult.bindings.Props;
271
+ const sourceMatch = propsStr.match(/source\s*=\s*\^\^?\("([^"]+)"/);
272
+ if (sourceMatch) {
273
+ sourceName = path.basename(sourceMatch[1], ".md");
274
+ }
275
+ }
276
+ cycleWithSources.push(sourceName);
277
+ }
278
+ violations.push({
279
+ rule: "no-cycles",
280
+ entityId: cyclePath[0],
281
+ description: `Circular dependency detected: ${cycleWithSources.join(" → ")}`,
282
+ suggestion: "Break the cycle by removing one of the depends_on relationships",
283
+ });
284
+ break; // Report only first cycle found
285
+ }
286
+ }
287
+ }
288
+ return violations;
289
+ }
290
+ async function checkRequiredFields(prolog, allEntityIds) {
291
+ const violations = [];
292
+ const required = [
293
+ "id",
294
+ "title",
295
+ "status",
296
+ "created_at",
297
+ "updated_at",
298
+ "source",
299
+ ];
300
+ for (const entityId of allEntityIds) {
301
+ const result = await prolog.query(`kb_entity('${entityId}', Type, Props)`);
302
+ if (result.success && result.bindings.Props) {
303
+ // Parse properties list: [key1=value1, key2=value2, ...]
304
+ const propsStr = result.bindings.Props;
305
+ const propKeys = new Set();
306
+ // Extract keys from Props
307
+ const keyMatches = propsStr.matchAll(/(\w+)\s*=/g);
308
+ for (const match of keyMatches) {
309
+ propKeys.add(match[1]);
310
+ }
311
+ // Check for missing required fields
312
+ for (const field of required) {
313
+ if (!propKeys.has(field)) {
314
+ violations.push({
315
+ rule: "required-fields",
316
+ entityId: entityId,
317
+ description: `Missing required field: ${field}`,
318
+ suggestion: `Add ${field} to entity definition`,
319
+ });
320
+ }
321
+ }
322
+ }
323
+ }
324
+ return violations;
325
+ }
326
+ async function checkSymbolCoverage(prolog) {
327
+ const violations = [];
328
+ const uncoveredResult = await prolog.query("setof(Symbol, (kb_entity(Symbol, symbol, _), \\+ transitively_implements(Symbol, _)), Symbols)");
329
+ if (uncoveredResult.success && uncoveredResult.bindings.Symbols) {
330
+ const symbolsStr = uncoveredResult.bindings.Symbols;
331
+ const match = symbolsStr.match(/\[(.*)\]/);
332
+ if (match) {
333
+ const content = match[1].trim();
334
+ if (content) {
335
+ const symbolMatches = content.matchAll(/'([^']+)'/g);
336
+ for (const symbolMatch of symbolMatches) {
337
+ const symbolId = symbolMatch[1];
338
+ violations.push({
339
+ rule: "symbol-coverage",
340
+ entityId: symbolId,
341
+ description: "Code symbol is not traceable to any functional requirement.",
342
+ suggestion: "Update symbols.yaml to link this symbol to a related requirement.",
343
+ });
344
+ }
345
+ }
346
+ }
347
+ }
348
+ return violations;
349
+ }