kibi-agent-core 0.0.0-stage → 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.
package/CHANGELOG.md ADDED
@@ -0,0 +1,15 @@
1
+ # kibi-agent-core
2
+
3
+ ## 0.1.0
4
+
5
+ ### Minor Changes
6
+
7
+ - 5a06c03: Kibi's agent plugins now share one fast, consistent implementation for source-path classification, Kibi MCP tool recognition, and symbol-manifest indexing. Codex and ZCode now recognize production code outside `src/`, while Cursor reuses the same size-and-mtime-keyed scanner as Claude instead of parsing the full symbol manifest before an edit.
8
+
9
+ - Add `kibi-agent-core` as the common Node 18-compatible hook-helper package.
10
+ - Keep host adapters thin while preserving their host-specific event and state contracts.
11
+ - Replace Cursor's YAML parser dependency with the shared cached line scanner.
12
+
13
+ ## 0.0.0
14
+
15
+ - Initial shared hook-helper package.
@@ -0,0 +1,3 @@
1
+ export * from "./kb-mcp-tools.js";
2
+ export * from "./knowledge-index.js";
3
+ export * from "./path-policy.js";
package/dist/index.js ADDED
@@ -0,0 +1,3 @@
1
+ export * from "./kb-mcp-tools.js";
2
+ export * from "./knowledge-index.js";
3
+ export * from "./path-policy.js";
@@ -0,0 +1,16 @@
1
+ /**
2
+ * Canonical Kibi operation name for a host tool name. Hosts prefix MCP tools
3
+ * (`mcp__kibi__kb_check`, `mcp__plugin_<plugin>_kibi__kb_check`,
4
+ * `MCP:kb_check`, `kibi_kb_check`); an unprefixed `kb_check` passes through.
5
+ */
6
+ export declare function canonicalKbToolName(toolName: string | undefined): string | undefined;
7
+ export type KbMcpToolCall = {
8
+ toolName: string;
9
+ impactCheckRun: boolean;
10
+ sourceFiles: string[];
11
+ };
12
+ export type McpState = "observed" | "unknown";
13
+ export type KibiInterface = "mcp" | "cli" | "setup";
14
+ export declare function resolveKibiInterface(mcpState: McpState, workspaceTrusted: boolean): KibiInterface;
15
+ export declare function extractKbMcpToolCall(toolName: string | undefined, toolInput: unknown): KbMcpToolCall | undefined;
16
+ export declare function extractKbMcpToolName(toolName: string | undefined, toolInput: unknown): string | undefined;
@@ -0,0 +1,164 @@
1
+ // implements REQ-cursor-kibi-plugin-v1
2
+ function isRecord(value) {
3
+ return typeof value === "object" && value !== null && !Array.isArray(value);
4
+ }
5
+ function readString(record, keys) {
6
+ for (const key of keys) {
7
+ const value = record[key];
8
+ if (typeof value === "string") {
9
+ return value;
10
+ }
11
+ }
12
+ return undefined;
13
+ }
14
+ function readBoolean(record, keys) {
15
+ for (const key of keys) {
16
+ const value = record[key];
17
+ if (typeof value === "boolean") {
18
+ return value;
19
+ }
20
+ }
21
+ return undefined;
22
+ }
23
+ function readStringArray(record, keys) {
24
+ for (const key of keys) {
25
+ const value = record[key];
26
+ if (!Array.isArray(value)) {
27
+ continue;
28
+ }
29
+ return value.filter((item) => typeof item === "string" && item.length > 0);
30
+ }
31
+ return [];
32
+ }
33
+ function readRecord(record, keys) {
34
+ for (const key of keys) {
35
+ const value = record[key];
36
+ if (isRecord(value)) {
37
+ return value;
38
+ }
39
+ }
40
+ return undefined;
41
+ }
42
+ /**
43
+ * Canonical Kibi operation name for a host tool name. Hosts prefix MCP tools
44
+ * (`mcp__kibi__kb_check`, `mcp__plugin_<plugin>_kibi__kb_check`,
45
+ * `MCP:kb_check`, `kibi_kb_check`); an unprefixed `kb_check` passes through.
46
+ */
47
+ export function canonicalKbToolName(toolName) {
48
+ const trimmed = toolName?.trim() ?? "";
49
+ const lastSegment = trimmed.includes("__")
50
+ ? (trimmed.split("__").at(-1) ?? "")
51
+ : trimmed.replace(/^MCP:/i, "");
52
+ const operation = lastSegment.replace(/^kibi_/, "");
53
+ return operation.startsWith("kb_") ? operation : undefined;
54
+ }
55
+ export function resolveKibiInterface(mcpState, workspaceTrusted) {
56
+ if (mcpState === "observed") {
57
+ return "mcp";
58
+ }
59
+ return workspaceTrusted ? "cli" : "setup";
60
+ }
61
+ export function extractKbMcpToolCall(toolName, toolInput) {
62
+ let normalizedToolName = canonicalKbToolName(toolName);
63
+ if (isRecord(toolInput)) {
64
+ normalizedToolName ??= readString(toolInput, [
65
+ "toolName",
66
+ "tool_name",
67
+ "name",
68
+ ]);
69
+ const args = readRecord(toolInput, ["arguments", "args"]);
70
+ const payload = args ?? toolInput;
71
+ const includeImpactDiagnostics = readBoolean(payload, [
72
+ "includeImpactDiagnostics",
73
+ "include_impact_diagnostics",
74
+ ]);
75
+ const includeWorkingTreeDiff = readBoolean(payload, [
76
+ "includeWorkingTreeDiff",
77
+ "include_working_tree_diff",
78
+ ]);
79
+ const sourceFiles = readStringArray(payload, [
80
+ "sourceFiles",
81
+ "source_files",
82
+ ]);
83
+ if (normalizedToolName?.startsWith("kb_")) {
84
+ return {
85
+ toolName: normalizedToolName,
86
+ impactCheckRun: normalizedToolName === "kb_check" &&
87
+ includeImpactDiagnostics === true &&
88
+ includeWorkingTreeDiff === true &&
89
+ sourceFiles.length > 0,
90
+ sourceFiles,
91
+ };
92
+ }
93
+ const nestedArgs = toolInput.arguments ?? toolInput.args;
94
+ if (isRecord(nestedArgs)) {
95
+ const nestedTool = readString(nestedArgs, [
96
+ "toolName",
97
+ "tool_name",
98
+ "name",
99
+ ]);
100
+ if (nestedTool?.startsWith("kb_")) {
101
+ return { toolName: nestedTool, impactCheckRun: false, sourceFiles: [] };
102
+ }
103
+ }
104
+ }
105
+ if (normalizedToolName?.startsWith("kb_")) {
106
+ return {
107
+ toolName: normalizedToolName,
108
+ impactCheckRun: false,
109
+ sourceFiles: [],
110
+ };
111
+ }
112
+ return undefined;
113
+ }
114
+ export function extractKbMcpToolName(toolName, toolInput) {
115
+ const toolCall = extractKbMcpToolCall(toolName, toolInput);
116
+ // rationale: the fallbacks below re-derive the same name from the same
117
+ // inputs, so dropping the toolCall shortcut is observationally equivalent.
118
+ // Stryker disable next-line ConditionalExpression, BlockStatement
119
+ if (toolCall) {
120
+ return toolCall.toolName;
121
+ }
122
+ // rationale: any tool name that trims to a kb_ prefix already produced a
123
+ // tool call above, so this branch only sees equivalent names.
124
+ // Stryker disable next-line MethodExpression
125
+ if (toolName) {
126
+ // rationale: any name that trims to a kb_ prefix already produced a tool
127
+ // call above, so this trim cannot change the returned name.
128
+ // Stryker disable MethodExpression
129
+ const normalized = toolName.trim();
130
+ if (normalized.startsWith("kb_")) {
131
+ return normalized;
132
+ }
133
+ // Stryker restore
134
+ }
135
+ if (!isRecord(toolInput)) {
136
+ return undefined;
137
+ }
138
+ // rationale: this alias list duplicates the one inside
139
+ // extractKbMcpToolCall, which already returned for every kb_ match.
140
+ // Stryker disable next-line StringLiteral
141
+ const directTool = readString(toolInput, ["toolName", "tool_name", "name"]);
142
+ if (directTool?.startsWith("kb_")) {
143
+ return directTool;
144
+ }
145
+ const nestedArgs = toolInput.arguments ?? toolInput.args;
146
+ if (isRecord(nestedArgs)) {
147
+ // rationale: this alias list duplicates the one inside
148
+ // extractKbMcpToolCall, which already returned for every kb_ match.
149
+ // Stryker disable next-line StringLiteral
150
+ // rationale: duplicate alias list; extractKbMcpToolCall already
151
+ // returned for every kb_ match.
152
+ // Stryker disable StringLiteral
153
+ const nestedTool = readString(nestedArgs, [
154
+ "toolName",
155
+ "tool_name",
156
+ "name",
157
+ ]);
158
+ // Stryker restore
159
+ if (nestedTool?.startsWith("kb_")) {
160
+ return nestedTool;
161
+ }
162
+ }
163
+ return undefined;
164
+ }
@@ -0,0 +1,50 @@
1
+ export type KnowledgeIndexOptions = {
2
+ /** Legacy string links mean requirement ownership only in adapters that opt in. */
3
+ legacyLinksAsImplements?: boolean;
4
+ /** Lets multiple adapter indexes coexist in one host-owned cache directory. */
5
+ cacheFileName?: string;
6
+ };
7
+ export type IndexedSymbol = {
8
+ id: string;
9
+ title: string;
10
+ line?: number;
11
+ endLine?: number;
12
+ implements: string[];
13
+ coveredBy: string[];
14
+ executableFor: string[];
15
+ };
16
+ export type KnowledgeIndex = {
17
+ /** Workspace-relative source file -> symbols with at least one relationship. */
18
+ files: Record<string, IndexedSymbol[]>;
19
+ symbolCount: number;
20
+ };
21
+ type RawRecord = Record<string, unknown>;
22
+ /**
23
+ * Scan the symbols manifest into raw records. Accepts both the canonical
24
+ * `symbols:` mapping and a bare top-level sequence.
25
+ */
26
+ export declare function scanSymbolsManifest(text: string): RawRecord[];
27
+ export type Coordinates = Record<string, {
28
+ line?: number;
29
+ endLine?: number;
30
+ }>;
31
+ /** Scan `coordinates:` -> `<symbol id>:` -> `sourceLine` / `sourceEndLine`. */
32
+ export declare function scanSymbolCoordinates(text: string): Coordinates;
33
+ /** Collapse raw manifest records and coordinates into the file-keyed index. */
34
+ export declare function buildKnowledgeIndex(records: readonly RawRecord[], coordinates?: Coordinates, options?: KnowledgeIndexOptions): KnowledgeIndex;
35
+ /**
36
+ * Load the index for a workspace, reusing the cache in `cacheDir` while both
37
+ * manifests keep their size and mtime.
38
+ */
39
+ export declare function loadKnowledgeIndex(workspaceRoot: string, cacheDir?: string, options?: KnowledgeIndexOptions): KnowledgeIndex;
40
+ export type EntitySummary = {
41
+ id: string;
42
+ title?: string;
43
+ status?: string;
44
+ };
45
+ /**
46
+ * Read an entity's title and status from its canonical authored document
47
+ * (`.kb/<lane>/<ID>.md`). Only the frontmatter head is read.
48
+ */
49
+ export declare function readEntitySummary(workspaceRoot: string, entityId: string): EntitySummary;
50
+ export {};
@@ -0,0 +1,388 @@
1
+ // implements REQ-claude-code-kibi-plugin-v1
2
+ import { randomUUID } from "node:crypto";
3
+ import fs from "node:fs";
4
+ import path from "node:path";
5
+ /**
6
+ * File-keyed view of the Kibi symbol manifest, built for hook latency.
7
+ *
8
+ * Hooks run before every read and edit, so they cannot afford a KB query
9
+ * (a CLI round trip costs seconds) or a full YAML parse of a large manifest.
10
+ * This module line-scans `.kb/symbols.yaml` and `.kb/symbol-coordinates.yaml`
11
+ * once, keeps only symbols that carry relationships, and caches the result as
12
+ * JSON keyed by both files' size and mtime. Every later hook pays one stat per
13
+ * file plus a JSON parse.
14
+ *
15
+ * The scanner understands the subset the Kibi writer emits (block mappings and
16
+ * sequences, quoted scalars, no line folding) plus hand-authored flow lists
17
+ * such as `links: [REQ-001]`. The manifest is a hint for progressive
18
+ * disclosure, not the source of truth: snippets always point at kb_query for
19
+ * the authoritative view.
20
+ */
21
+ const SYMBOLS_MANIFEST = ".kb/symbols.yaml";
22
+ const SYMBOL_COORDINATES = ".kb/symbol-coordinates.yaml";
23
+ const INDEX_FORMAT = "kibi-agent-core.knowledge-index.v1";
24
+ const CACHE_FILE = "knowledge-index.json";
25
+ function unquote(raw) {
26
+ const value = raw.trim();
27
+ if (value.startsWith('"') && value.endsWith('"') && value.length >= 2) {
28
+ try {
29
+ return JSON.parse(value);
30
+ }
31
+ catch {
32
+ return value.slice(1, -1);
33
+ }
34
+ }
35
+ if (value.startsWith("'") && value.endsWith("'") && value.length >= 2) {
36
+ return value.slice(1, -1).replaceAll("''", "'");
37
+ }
38
+ // Plain scalars may carry a trailing comment.
39
+ const comment = value.search(/\s#/);
40
+ return comment >= 0 ? value.slice(0, comment).trimEnd() : value;
41
+ }
42
+ function parseFlowList(raw) {
43
+ const value = raw.trim();
44
+ if (!value.startsWith("[") || !value.endsWith("]"))
45
+ return undefined;
46
+ const inner = value.slice(1, -1).trim();
47
+ if (inner.length === 0)
48
+ return [];
49
+ return inner
50
+ .split(",")
51
+ .map(unquote)
52
+ .filter((item) => item.length > 0);
53
+ }
54
+ const keyValue = /^([A-Za-z0-9_"'-][^:]*?):(?:\s+(.*))?$/;
55
+ function parseScalarOrFlow(raw) {
56
+ if (raw === undefined || raw.trim().length === 0)
57
+ return undefined;
58
+ return parseFlowList(raw) ?? unquote(raw);
59
+ }
60
+ /**
61
+ * Scan the symbols manifest into raw records. Accepts both the canonical
62
+ * `symbols:` mapping and a bare top-level sequence.
63
+ */
64
+ export function scanSymbolsManifest(text) {
65
+ const records = [];
66
+ let itemIndent;
67
+ let keyIndent = 0;
68
+ let current;
69
+ let listKey;
70
+ let listItem;
71
+ // Top-level section: symbols are the `symbols:` sequence or a bare root
72
+ // sequence; sequences under any other top-level key are ignored.
73
+ let section = "root";
74
+ for (const rawLine of text.split(/\r?\n/)) {
75
+ const content = rawLine.trim();
76
+ if (content.length === 0 || content.startsWith("#"))
77
+ continue;
78
+ const indent = rawLine.length - rawLine.trimStart().length;
79
+ const dash = /^(\s*)-(\s+)(.*)$/.exec(rawLine);
80
+ if (indent === 0 && !dash) {
81
+ const pair = keyValue.exec(content);
82
+ section =
83
+ pair?.[1] !== undefined && unquote(pair[1]) === "symbols"
84
+ ? "symbols"
85
+ : "other";
86
+ current = undefined;
87
+ itemIndent = undefined;
88
+ continue;
89
+ }
90
+ if (section === "other")
91
+ continue;
92
+ if (dash && (itemIndent === undefined || indent === itemIndent)) {
93
+ itemIndent = indent;
94
+ keyIndent = (dash[1]?.length ?? 0) + 1 + (dash[2]?.length ?? 1);
95
+ current = {};
96
+ records.push(current);
97
+ listKey = undefined;
98
+ listItem = undefined;
99
+ const pair = keyValue.exec(dash[3] ?? "");
100
+ if (pair?.[1] !== undefined) {
101
+ const value = parseScalarOrFlow(pair[2]);
102
+ if (value !== undefined)
103
+ current[unquote(pair[1])] = value;
104
+ else
105
+ listKey = unquote(pair[1]);
106
+ }
107
+ continue;
108
+ }
109
+ if (current === undefined || itemIndent === undefined)
110
+ continue;
111
+ if (indent <= itemIndent)
112
+ continue;
113
+ if (dash) {
114
+ if (listKey === undefined)
115
+ continue;
116
+ const list = Array.isArray(current[listKey])
117
+ ? current[listKey]
118
+ : [];
119
+ current[listKey] = list;
120
+ const pair = keyValue.exec(dash[3] ?? "");
121
+ if (pair?.[1] !== undefined && pair[2] !== undefined) {
122
+ listItem = { [unquote(pair[1])]: unquote(pair[2]) };
123
+ list.push(listItem);
124
+ }
125
+ else {
126
+ listItem = undefined;
127
+ list.push(unquote(dash[3] ?? ""));
128
+ }
129
+ continue;
130
+ }
131
+ const pair = keyValue.exec(content);
132
+ if (pair?.[1] === undefined)
133
+ continue;
134
+ const key = unquote(pair[1]);
135
+ if (indent === keyIndent) {
136
+ listItem = undefined;
137
+ const value = parseScalarOrFlow(pair[2]);
138
+ if (value === undefined) {
139
+ listKey = key;
140
+ }
141
+ else {
142
+ current[key] = value;
143
+ listKey = undefined;
144
+ }
145
+ }
146
+ else if (listItem !== undefined && pair[2] !== undefined) {
147
+ listItem[key] = unquote(pair[2]);
148
+ }
149
+ }
150
+ return records;
151
+ }
152
+ /** Scan `coordinates:` -> `<symbol id>:` -> `sourceLine` / `sourceEndLine`. */
153
+ export function scanSymbolCoordinates(text) {
154
+ const coordinates = {};
155
+ let inCoordinates = false;
156
+ let idIndent;
157
+ let currentId;
158
+ for (const rawLine of text.split(/\r?\n/)) {
159
+ const content = rawLine.trim();
160
+ if (content.length === 0 || content.startsWith("#"))
161
+ continue;
162
+ const indent = rawLine.length - rawLine.trimStart().length;
163
+ const pair = keyValue.exec(content);
164
+ if (pair?.[1] === undefined)
165
+ continue;
166
+ const key = unquote(pair[1]);
167
+ if (indent === 0) {
168
+ inCoordinates = key === "coordinates";
169
+ idIndent = undefined;
170
+ currentId = undefined;
171
+ continue;
172
+ }
173
+ if (!inCoordinates)
174
+ continue;
175
+ if (idIndent === undefined || indent === idIndent) {
176
+ idIndent = indent;
177
+ currentId = key;
178
+ coordinates[currentId] ??= {};
179
+ continue;
180
+ }
181
+ if (currentId === undefined || indent < idIndent)
182
+ continue;
183
+ const entry = coordinates[currentId] ?? {};
184
+ const numeric = Number.parseInt(unquote(pair[2] ?? ""), 10);
185
+ if (!Number.isFinite(numeric))
186
+ continue;
187
+ if (key === "sourceLine")
188
+ entry.line = numeric;
189
+ if (key === "sourceEndLine")
190
+ entry.endLine = numeric;
191
+ coordinates[currentId] = entry;
192
+ }
193
+ return coordinates;
194
+ }
195
+ function toPositiveInt(value) {
196
+ const parsed = typeof value === "number" ? value : Number.parseInt(String(value), 10);
197
+ return Number.isFinite(parsed) && parsed > 0 ? parsed : undefined;
198
+ }
199
+ function pushUnique(list, value) {
200
+ if (!list.includes(value))
201
+ list.push(value);
202
+ }
203
+ /** Collapse raw manifest records and coordinates into the file-keyed index. */
204
+ export function buildKnowledgeIndex(records, coordinates = {}, options = {}) {
205
+ const files = {};
206
+ let symbolCount = 0;
207
+ for (const record of records) {
208
+ const id = typeof record.id === "string" ? record.id : undefined;
209
+ const sourceFile = typeof record.sourceFile === "string"
210
+ ? record.sourceFile.replaceAll("\\", "/").replace(/^\.\//, "")
211
+ : undefined;
212
+ if (!id || !sourceFile)
213
+ continue;
214
+ symbolCount += 1;
215
+ const symbol = {
216
+ id,
217
+ title: typeof record.title === "string" ? record.title : id,
218
+ implements: [],
219
+ coveredBy: [],
220
+ executableFor: [],
221
+ };
222
+ const relationships = [];
223
+ if (options.legacyLinksAsImplements !== false) {
224
+ let links = [];
225
+ if (typeof record.links === "string")
226
+ links = [record.links];
227
+ else if (Array.isArray(record.links))
228
+ links = record.links;
229
+ for (const link of links) {
230
+ if (typeof link === "string") {
231
+ relationships.push({ type: "implements", target: link });
232
+ }
233
+ else if (link !== null && typeof link === "object") {
234
+ const typed = link;
235
+ if (typeof typed.type === "string" &&
236
+ typeof typed.target === "string") {
237
+ relationships.push({ type: typed.type, target: typed.target });
238
+ }
239
+ }
240
+ }
241
+ }
242
+ for (const relation of Array.isArray(record.relationships)
243
+ ? record.relationships
244
+ : []) {
245
+ if (relation === null || typeof relation !== "object")
246
+ continue;
247
+ const typed = relation;
248
+ if (typeof typed.type === "string" && typeof typed.target === "string")
249
+ relationships.push({ type: typed.type, target: typed.target });
250
+ }
251
+ for (const { type, target } of relationships) {
252
+ if (type === "implements")
253
+ pushUnique(symbol.implements, target);
254
+ else if (type === "covered_by")
255
+ pushUnique(symbol.coveredBy, target);
256
+ else if (type === "executable_for")
257
+ pushUnique(symbol.executableFor, target);
258
+ }
259
+ if (symbol.implements.length +
260
+ symbol.coveredBy.length +
261
+ symbol.executableFor.length ===
262
+ 0) {
263
+ continue;
264
+ }
265
+ const line = toPositiveInt(record.sourceLine) ?? coordinates[id]?.line ?? undefined;
266
+ const endLine = toPositiveInt(record.sourceEndLine) ??
267
+ coordinates[id]?.endLine ??
268
+ undefined;
269
+ if (line !== undefined)
270
+ symbol.line = line;
271
+ if (endLine !== undefined)
272
+ symbol.endLine = endLine;
273
+ const bucket = files[sourceFile] ?? [];
274
+ bucket.push(symbol);
275
+ files[sourceFile] = bucket;
276
+ }
277
+ return { files, symbolCount };
278
+ }
279
+ function fileSignature(filePath) {
280
+ try {
281
+ const stats = fs.statSync(filePath);
282
+ return `${stats.size}:${stats.mtimeMs}`;
283
+ }
284
+ catch {
285
+ return "missing";
286
+ }
287
+ }
288
+ function readText(filePath) {
289
+ try {
290
+ return fs.readFileSync(filePath, "utf8");
291
+ }
292
+ catch {
293
+ return undefined;
294
+ }
295
+ }
296
+ function writeCacheAtomically(cachePath, payload) {
297
+ try {
298
+ fs.mkdirSync(path.dirname(cachePath), { recursive: true });
299
+ const temporary = `${cachePath}.${process.pid}.${randomUUID()}.tmp`;
300
+ fs.writeFileSync(temporary, payload);
301
+ fs.renameSync(temporary, cachePath);
302
+ }
303
+ catch {
304
+ // The cache is an optimization; a failed write only costs the next hook a rescan.
305
+ }
306
+ }
307
+ /**
308
+ * Load the index for a workspace, reusing the cache in `cacheDir` while both
309
+ * manifests keep their size and mtime.
310
+ */
311
+ export function loadKnowledgeIndex(workspaceRoot, cacheDir, options = {}) {
312
+ const symbolsPath = path.join(workspaceRoot, SYMBOLS_MANIFEST);
313
+ const coordinatesPath = path.join(workspaceRoot, SYMBOL_COORDINATES);
314
+ const legacyMode = options.legacyLinksAsImplements === false ? "typed" : "legacy";
315
+ const signature = `${INDEX_FORMAT}|${legacyMode}|${fileSignature(symbolsPath)}|${fileSignature(coordinatesPath)}`;
316
+ const cachePath = cacheDir
317
+ ? path.join(cacheDir, options.cacheFileName ?? CACHE_FILE)
318
+ : undefined;
319
+ if (cachePath) {
320
+ const cached = readText(cachePath);
321
+ if (cached !== undefined) {
322
+ try {
323
+ const parsed = JSON.parse(cached);
324
+ if (parsed.signature === signature && parsed.index)
325
+ return parsed.index;
326
+ }
327
+ catch {
328
+ // Corrupt cache: rebuild below.
329
+ }
330
+ }
331
+ }
332
+ const symbolsText = readText(symbolsPath);
333
+ const index = symbolsText === undefined
334
+ ? { files: {}, symbolCount: 0 }
335
+ : buildKnowledgeIndex(scanSymbolsManifest(symbolsText), scanSymbolCoordinates(readText(coordinatesPath) ?? ""), options);
336
+ if (cachePath) {
337
+ writeCacheAtomically(cachePath, JSON.stringify({ signature, index }));
338
+ }
339
+ return index;
340
+ }
341
+ const ENTITY_LANES = [
342
+ ["REQ-", "requirements"],
343
+ ["SCEN-", "scenarios"],
344
+ ["TEST-", "tests"],
345
+ ["ADR-", "adr"],
346
+ ["FACT-", "facts"],
347
+ ["FLAG-", "flags"],
348
+ ["EVT-", "events"],
349
+ ];
350
+ const SAFE_ENTITY_ID = /^[A-Za-z0-9][A-Za-z0-9._-]*$/;
351
+ /**
352
+ * Read an entity's title and status from its canonical authored document
353
+ * (`.kb/<lane>/<ID>.md`). Only the frontmatter head is read.
354
+ */
355
+ export function readEntitySummary(workspaceRoot, entityId) {
356
+ const lane = ENTITY_LANES.find(([prefix]) => entityId.startsWith(prefix));
357
+ if (!lane || !SAFE_ENTITY_ID.test(entityId))
358
+ return { id: entityId };
359
+ let head;
360
+ try {
361
+ const descriptor = fs.openSync(path.join(workspaceRoot, ".kb", lane[1], `${entityId}.md`), "r");
362
+ try {
363
+ const buffer = Buffer.alloc(4096);
364
+ const bytes = fs.readSync(descriptor, buffer, 0, buffer.length, 0);
365
+ head = buffer.subarray(0, bytes).toString("utf8");
366
+ }
367
+ finally {
368
+ fs.closeSync(descriptor);
369
+ }
370
+ }
371
+ catch {
372
+ return { id: entityId };
373
+ }
374
+ const lines = head.split(/\r?\n/);
375
+ if (lines[0]?.trim() !== "---")
376
+ return { id: entityId };
377
+ const summary = { id: entityId };
378
+ for (const line of lines.slice(1)) {
379
+ if (line.trim() === "---")
380
+ break;
381
+ const match = /^(title|status):\s*(.*)$/.exec(line);
382
+ if (match?.[1] === "title" && match[2])
383
+ summary.title = unquote(match[2]);
384
+ if (match?.[1] === "status" && match[2])
385
+ summary.status = unquote(match[2]);
386
+ }
387
+ return summary;
388
+ }
@@ -0,0 +1,39 @@
1
+ /**
2
+ * What a workspace path means for Kibi guidance.
3
+ *
4
+ * - `source`: production code whose behavior requirements may own.
5
+ * - `test`: test code (owned through TEST entities / executable_for).
6
+ * - `kb`: Kibi-managed knowledge under `.kb/`.
7
+ * - `other`: docs, config, generated output, dependencies — no snippets.
8
+ *
9
+ * Source detection deliberately does not require a `src/` segment: `lib/`,
10
+ * `app/`, and root-level packages are source too.
11
+ */
12
+ export type PathKind = "source" | "test" | "kb" | "other";
13
+ export type WorkspacePath = {
14
+ /** Path relative to the workspace root, forward slashes. */
15
+ relative: string;
16
+ absolute: string;
17
+ };
18
+ export type CanonicalWorkspacePath = {
19
+ workspaceRelative: string;
20
+ absolute: string;
21
+ };
22
+ export declare function normalizeWorkspacePath(candidate: string): string;
23
+ export declare function extractExplicitPathFields(input: unknown): string[];
24
+ export declare function canonicalizeWorkspacePath(workspaceRoot: string, options: {
25
+ eventCwd?: string | undefined;
26
+ base?: string | undefined;
27
+ rawPath: string;
28
+ }): CanonicalWorkspacePath | undefined;
29
+ /**
30
+ * Resolve a tool-call path against the event cwd and express it relative to
31
+ * the Kibi workspace root. Paths outside the workspace return undefined.
32
+ */
33
+ export declare function toWorkspacePath(workspaceRoot: string, rawPath: string, eventCwd?: string): WorkspacePath | undefined;
34
+ export declare function classifyPath(relativePath: string): PathKind;
35
+ export declare function isKbPath(candidate: string): boolean;
36
+ export declare function isRootKbPath(candidate: string): boolean;
37
+ export declare function isMeaningfulTrackedPath(candidate: string): boolean;
38
+ export declare function isSourceImpactRelevantPath(candidate: string): boolean;
39
+ export declare function isDocumentationTrackedPath(candidate: string): boolean;
@@ -0,0 +1,221 @@
1
+ // implements REQ-claude-code-kibi-plugin-v1
2
+ import path from "node:path";
3
+ const codeExtensions = new Set([
4
+ ".c",
5
+ ".cc",
6
+ ".cjs",
7
+ ".cpp",
8
+ ".cs",
9
+ ".css",
10
+ ".go",
11
+ ".h",
12
+ ".hpp",
13
+ ".html",
14
+ ".java",
15
+ ".js",
16
+ ".jsx",
17
+ ".kt",
18
+ ".lua",
19
+ ".mjs",
20
+ ".mts",
21
+ ".cts",
22
+ ".php",
23
+ ".pl",
24
+ ".py",
25
+ ".rb",
26
+ ".rs",
27
+ ".scala",
28
+ ".sh",
29
+ ".swift",
30
+ ".ts",
31
+ ".tsx",
32
+ ".vue",
33
+ ".svelte",
34
+ ]);
35
+ /** Directories whose contents are generated, vendored, or tool state. */
36
+ const ignoredSegments = new Set([
37
+ "node_modules",
38
+ "dist",
39
+ "build",
40
+ "out",
41
+ "coverage",
42
+ "vendor",
43
+ "target",
44
+ ".git",
45
+ ".next",
46
+ ".turbo",
47
+ ".cache",
48
+ "__pycache__",
49
+ ]);
50
+ const testSegments = new Set([
51
+ "test",
52
+ "tests",
53
+ "__tests__",
54
+ "spec",
55
+ "specs",
56
+ "e2e",
57
+ "__mocks__",
58
+ "fixtures",
59
+ ]);
60
+ const testBasename = /(\.|_)(test|spec|e2e)\.[^.]+$|^test_[^/]+\.py$/;
61
+ const documentationExtensions = new Set([".md", ".mdx", ".rst", ".txt"]);
62
+ const documentationSegments = new Set(["docs", "documentation"]);
63
+ const canonicalKbKnowledgeLanes = new Set([
64
+ "requirements",
65
+ "scenarios",
66
+ "tests",
67
+ "facts",
68
+ "adr",
69
+ "flags",
70
+ "events",
71
+ ]);
72
+ const canonicalKbKnowledgeFiles = new Set([
73
+ "symbols.yaml",
74
+ "symbol-coordinates.yaml",
75
+ ]);
76
+ const explicitPathKeys = new Set([
77
+ "absolute_path",
78
+ "file",
79
+ "file_path",
80
+ "filepath",
81
+ "new_path",
82
+ "old_path",
83
+ "path",
84
+ "paths",
85
+ "relative_path",
86
+ "target_path",
87
+ ]);
88
+ function isRecord(value) {
89
+ return typeof value === "object" && value !== null && !Array.isArray(value);
90
+ }
91
+ export function normalizeWorkspacePath(candidate) {
92
+ return candidate.trim().replaceAll("\\", "/");
93
+ }
94
+ function pathSegments(candidate) {
95
+ return normalizeWorkspacePath(candidate).split("/").filter(Boolean);
96
+ }
97
+ function collectPathValues(value, output) {
98
+ if (typeof value === "string") {
99
+ const normalized = normalizeWorkspacePath(value);
100
+ if (normalized.length > 0)
101
+ output.push(normalized);
102
+ return;
103
+ }
104
+ if (!Array.isArray(value))
105
+ return;
106
+ for (const item of value)
107
+ collectPathValues(item, output);
108
+ }
109
+ function visitExplicitPathFields(value, output) {
110
+ if (Array.isArray(value)) {
111
+ for (const item of value)
112
+ visitExplicitPathFields(item, output);
113
+ return;
114
+ }
115
+ if (!isRecord(value))
116
+ return;
117
+ for (const [key, child] of Object.entries(value)) {
118
+ if (explicitPathKeys.has(key.toLowerCase()))
119
+ collectPathValues(child, output);
120
+ visitExplicitPathFields(child, output);
121
+ }
122
+ }
123
+ export function extractExplicitPathFields(input) {
124
+ const paths = [];
125
+ visitExplicitPathFields(input, paths);
126
+ return [...new Set(paths)];
127
+ }
128
+ export function canonicalizeWorkspacePath(workspaceRoot, options) {
129
+ const trimmed = normalizeWorkspacePath(options.rawPath);
130
+ if (trimmed.length === 0)
131
+ return undefined;
132
+ const base = options.base ?? options.eventCwd ?? workspaceRoot;
133
+ const absolute = path.isAbsolute(trimmed)
134
+ ? path.resolve(trimmed)
135
+ : path.resolve(base, trimmed);
136
+ const workspaceRelative = path
137
+ .relative(workspaceRoot, absolute)
138
+ .replaceAll("\\", "/");
139
+ if (workspaceRelative.length === 0 ||
140
+ workspaceRelative === ".." ||
141
+ workspaceRelative.startsWith("../") ||
142
+ path.isAbsolute(workspaceRelative)) {
143
+ return undefined;
144
+ }
145
+ return { workspaceRelative, absolute };
146
+ }
147
+ /**
148
+ * Resolve a tool-call path against the event cwd and express it relative to
149
+ * the Kibi workspace root. Paths outside the workspace return undefined.
150
+ */
151
+ export function toWorkspacePath(workspaceRoot, rawPath, eventCwd) {
152
+ const canonical = canonicalizeWorkspacePath(workspaceRoot, {
153
+ rawPath,
154
+ eventCwd,
155
+ });
156
+ return canonical
157
+ ? { relative: canonical.workspaceRelative, absolute: canonical.absolute }
158
+ : undefined;
159
+ }
160
+ export function classifyPath(relativePath) {
161
+ const segments = pathSegments(relativePath);
162
+ if (segments[0] === ".kb")
163
+ return "kb";
164
+ if (segments.some((segment) => ignoredSegments.has(segment)))
165
+ return "other";
166
+ if (segments.some((segment) => documentationSegments.has(segment))) {
167
+ return "other";
168
+ }
169
+ const basename = segments.at(-1) ?? "";
170
+ const extension = path.extname(basename).toLowerCase();
171
+ if (!codeExtensions.has(extension))
172
+ return "other";
173
+ if (testBasename.test(basename) ||
174
+ segments.slice(0, -1).some((segment) => testSegments.has(segment))) {
175
+ return "test";
176
+ }
177
+ return "source";
178
+ }
179
+ export function isKbPath(candidate) {
180
+ return pathSegments(candidate).includes(".kb");
181
+ }
182
+ export function isRootKbPath(candidate) {
183
+ return pathSegments(candidate)[0] === ".kb";
184
+ }
185
+ function isCanonicalKbKnowledgePath(segments) {
186
+ const kbIndex = segments.indexOf(".kb");
187
+ if (kbIndex < 0)
188
+ return false;
189
+ const lane = segments[kbIndex + 1];
190
+ return (lane !== undefined &&
191
+ (canonicalKbKnowledgeFiles.has(lane) || canonicalKbKnowledgeLanes.has(lane)));
192
+ }
193
+ export function isMeaningfulTrackedPath(candidate) {
194
+ const segments = pathSegments(candidate);
195
+ if (segments.some((segment) => ignoredSegments.has(segment)))
196
+ return false;
197
+ if (segments.includes(".kb"))
198
+ return isCanonicalKbKnowledgePath(segments);
199
+ const basename = segments.at(-1) ?? "";
200
+ const extension = path.extname(basename).toLowerCase();
201
+ if (basename === "README.md")
202
+ return true;
203
+ if (segments.some((segment) => documentationSegments.has(segment))) {
204
+ return documentationExtensions.has(extension);
205
+ }
206
+ if (testBasename.test(basename) ||
207
+ segments.slice(0, -1).some((segment) => testSegments.has(segment))) {
208
+ return (codeExtensions.has(extension) || documentationExtensions.has(extension));
209
+ }
210
+ return codeExtensions.has(extension);
211
+ }
212
+ export function isSourceImpactRelevantPath(candidate) {
213
+ return classifyPath(normalizeWorkspacePath(candidate)) === "source";
214
+ }
215
+ export function isDocumentationTrackedPath(candidate) {
216
+ const segments = pathSegments(candidate);
217
+ const basename = segments.at(-1) ?? "";
218
+ return (basename === "README.md" ||
219
+ segments.some((segment) => documentationSegments.has(segment)) ||
220
+ documentationExtensions.has(path.extname(basename).toLowerCase()));
221
+ }
package/package.json CHANGED
@@ -1,6 +1,50 @@
1
1
  {
2
2
  "name": "kibi-agent-core",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "0.1.0",
4
+ "description": "Shared low-latency hook helpers for Kibi agent adapters",
5
+ "type": "module",
6
+ "main": "./dist/index.js",
7
+ "types": "./dist/index.d.ts",
8
+ "exports": {
9
+ ".": {
10
+ "types": "./dist/index.d.ts",
11
+ "default": "./dist/index.js"
12
+ },
13
+ "./kb-mcp-tools": {
14
+ "types": "./dist/kb-mcp-tools.d.ts",
15
+ "default": "./dist/kb-mcp-tools.js"
16
+ },
17
+ "./knowledge-index": {
18
+ "types": "./dist/knowledge-index.d.ts",
19
+ "default": "./dist/knowledge-index.js"
20
+ },
21
+ "./path-policy": {
22
+ "types": "./dist/path-policy.d.ts",
23
+ "default": "./dist/path-policy.js"
24
+ }
25
+ },
26
+ "files": ["dist", "CHANGELOG.md"],
27
+ "scripts": {
28
+ "build": "tsc -p tsconfig.json",
29
+ "typecheck": "tsc -p tsconfig.json --noEmit",
30
+ "prepack": "npm run build && node ../../scripts/verify-package-contract.mjs kibi-agent-core"
31
+ },
32
+ "engines": {
33
+ "node": ">=18"
34
+ },
35
+ "license": "AGPL-3.0-or-later",
36
+ "author": "Piotr Franczyk",
37
+ "repository": {
38
+ "type": "git",
39
+ "url": "https://github.com/Looted/kibi.git"
40
+ },
41
+ "publishConfig": {
42
+ "access": "public"
43
+ },
44
+ "devDependencies": {
45
+ "@types/node": "^25.9.8",
46
+ "semver": "^7.8.5",
47
+ "typescript": "^5.7.0",
48
+ "yaml": "^2.9.0"
49
+ }
50
+ }
package/README.md DELETED
@@ -1,3 +0,0 @@
1
- # Temporary Holding Version
2
-
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.