kibi-agent-core 0.1.1 → 0.2.1
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 +36 -0
- package/README.md +17 -0
- package/dist/hook-usage-log.d.ts +66 -0
- package/dist/hook-usage-log.js +112 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.js +2 -0
- package/dist/kb-mcp-tools.d.ts +10 -0
- package/dist/kb-mcp-tools.js +47 -0
- package/dist/knowledge-index.d.ts +13 -2
- package/dist/knowledge-index.js +110 -8
- package/dist/path-policy.d.ts +10 -0
- package/dist/path-policy.js +34 -0
- package/dist/snippets.d.ts +85 -0
- package/dist/snippets.js +327 -0
- package/package.json +15 -2
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,41 @@
|
|
|
1
1
|
# kibi-agent-core
|
|
2
2
|
|
|
3
|
+
## 0.2.1
|
|
4
|
+
|
|
5
|
+
### Patch Changes
|
|
6
|
+
|
|
7
|
+
- 2a2b2db: Every Kibi package page on npm now has a README that says what the package is for and how to install it, and links to the documentation site. Package metadata now points npm's "Homepage" link at the documentation site, its "Repository" link at the package's own folder on GitHub, and adds an "Issues" link.
|
|
8
|
+
|
|
9
|
+
Adds READMEs to `kibi-cli`, `kibi-mcp`, `kibi-core`, `kibi-runtime`, `kibi-agent-core`, `kibi-codex`, `kibi-plugin-builtin` and `kibi-plugin-sdk`. Sets `homepage` to https://looted.github.io/kibi/, adds `repository.directory` and `bugs` to every package, and refreshes the `kibi-plugin-treesitter` integrity manifest and source-analyzer approval for its changed `package.json`.
|
|
10
|
+
|
|
11
|
+
## 0.2.0
|
|
12
|
+
|
|
13
|
+
### Minor Changes
|
|
14
|
+
|
|
15
|
+
- f8fff87: The telemetry acceptance and remediation reports now show whether agents look requirements up before they change requirement-linked code. A new `lookup_before_first_edit` metric gives, per host session, the share of sessions that ran `kb_search` or `kb_query` (through MCP or the CLI) before their first edit of a file whose symbols implement a requirement. Sessions that edited first appear in the report with the file and its exact `.kb/usage.log` line. The data comes from opt-in hook rows that every Kibi host plugin now writes when `KIBI_DIAGNOSTIC_MODE` is set; nothing is recorded otherwise.
|
|
16
|
+
- `kibi usage-metrics` / `kb_check` telemetry acceptance: new metric `lookup_before_first_edit` (threshold `>=` policy `lookupBeforeFirstEditMinimum`, default 1) with evidence `unguidedEditPaths` and `lookupOperations`. It is `not_applicable` when no host hook recorded a requirement-linked edit, so logs without hook rows are judged as before. A failed metric adds the advisory diagnostic `lookup_before_first_edit_bypassed` (rank 35).
|
|
17
|
+
- `kibi usage-remediation`: one event item per session whose first linked edit had no earlier lookup, pointing at that hook row.
|
|
18
|
+
- `parseTelemetryUsageLog` still returns only Kibi operation rows; hook rows stay attached to the returned array (read them with `partitionTelemetryUsage`). Remediation `logLine` values now count hook rows and blank lines, so they match the file.
|
|
19
|
+
- `kibi-agent-core/hook-usage-log`: shared `appendHookUsage` / `appendHookUsageRows`, `kbUsageTrace` and `editTraces`. Hook rows (`interface: "hook"`) carry `host`, `session_id`, `hook_action` (`kb_usage` or `edited`), `kb_operation`, `path`, `path_kind` and `requirement_ids`. `hostKbOperation` recognizes MCP tool names and `kibi <route>` shell commands; `extractEditedPaths` reads `apply_patch` headers.
|
|
20
|
+
- Claude Code: `edited` rows now carry the file's `requirement_ids`, and every row names its `host`. Cursor (`postToolUse`), Codex and ZCode (`PostToolUse`) and OpenCode (`tool.execute.after`) now write the same `kb_usage` and `edited` rows.
|
|
21
|
+
|
|
22
|
+
- 1012d1c: When an agent edits a file that implements a requirement, every Kibi host plugin (Claude Code, Cursor, Codex, ZCode and OpenCode) now says what that requirement must keep true and the decision behind it, not just its ID. The extra lines come from the requirement's linked facts and ADR, so agents see the constraint before they change the code. All hosts build the snippet with one shared builder in `kibi-agent-core`, so they show the same lines within each host's size limits, and none of them presents a superseded or retired requirement as current.
|
|
23
|
+
- New `kibi-agent-core/snippets` export: `fileKnowledgeSnippet`, `requirementGroundingLines`, `editKnowledgeContext`, `editFocus` and `createEntitySummarizer`. Edit snippets add "`<REQ>` must keep true: …" (up to two facts linked via `constrains`, `requires_property`, `requires_predicate` or `requires_rule`) and "Decision: `<ADR>`" for the lead requirement. Read snippets keep the requirement and test lines only.
|
|
24
|
+
- A superseded, deprecated or retired lead requirement gets no "must keep true" or "Decision" lines, so retired policy is not shown as current.
|
|
25
|
+
- Claude Code: the `PreToolUse` edit snippet now comes from the shared builder (no change in content).
|
|
26
|
+
- Cursor: `preToolUse` edit guidance and `beforeReadFile` / read guidance include the shared snippet for files whose symbols implement a requirement, followed by the existing "query before you change it" follow-up.
|
|
27
|
+
- Codex: `PreToolUse` on `apply_patch` (and other edit tools) returns the snippet as `additionalContext`, for up to three changed files per call and once per file per session. Paths come from the patch's `*** Update/Add/Delete File:` headers.
|
|
28
|
+
- ZCode: `PreToolUse` on edit tools returns the snippet as `additionalContext`, including "The edit is inside `<symbol>`" when the edited text is found, once per file per session.
|
|
29
|
+
- OpenCode: the edit-guidance system prompt adds one "must keep true … Decision …" bullet (one fact) for the focus file, within the existing word budget.
|
|
30
|
+
- `readEntitySummary` returns frontmatter `links` (plain entries read as `relates_to`; `type` and `target` in either order) merged with the entity's records in `.kb/relationships` shards, so grounding stored only in shards still reaches the snippet.
|
|
31
|
+
- Session and discovery hints point at `kb_search` questions and its answer layer instead of asking for `rankingMode: "intent-v1"`, which is now the default.
|
|
32
|
+
|
|
33
|
+
### Patch Changes
|
|
34
|
+
|
|
35
|
+
- 15356de: Host hooks no longer ask for a `kb_check` after a `kb_upsert` dry run. A dry run only validates, so the Cursor, Codex, ZCode and OpenCode hooks now count it as validation rather than a KB write.
|
|
36
|
+
- `extractKbMcpToolCall` reports `kb_upsert` with `dryRun: true` as `kb_validate_upsert`.
|
|
37
|
+
- OpenCode's freshness evidence records a dry-run upsert as `kb_validate_upsert`, which carries no mutation evidence.
|
|
38
|
+
|
|
3
39
|
## 0.1.1
|
|
4
40
|
|
|
5
41
|
### Patch Changes
|
package/README.md
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
# kibi-agent-core
|
|
2
|
+
|
|
3
|
+
Shared, low-latency helpers for [Kibi](https://looted.github.io/kibi/) agent-host adapters. The Claude Code, Cursor, Codex, OpenCode and ZCode integrations use it to build the short requirement snippets their hooks show before an agent reads or edits linked code (the requirement, what it must keep true, the decision behind it and its tests), to classify paths, and to log opt-in hook usage.
|
|
4
|
+
|
|
5
|
+
Entry points: `kibi-agent-core`, plus the subpaths `/snippets`, `/knowledge-index`, `/path-policy`, `/kb-mcp-tools` and `/hook-usage-log`.
|
|
6
|
+
|
|
7
|
+
It is an internal building block of the host plugins, not something you install by hand. To use Kibi, see the [quick start](https://looted.github.io/kibi/guide/quick-start.html).
|
|
8
|
+
|
|
9
|
+
## Links
|
|
10
|
+
|
|
11
|
+
- [Documentation](https://looted.github.io/kibi/)
|
|
12
|
+
- [Source](https://github.com/Looted/kibi)
|
|
13
|
+
- [Issues](https://github.com/Looted/kibi/issues)
|
|
14
|
+
|
|
15
|
+
## License
|
|
16
|
+
|
|
17
|
+
AGPL-3.0-or-later
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Opt-in hook telemetry shared by every host adapter.
|
|
3
|
+
*
|
|
4
|
+
* MCP and CLI usage rows show which Kibi operations ran, but not what the
|
|
5
|
+
* agent was doing around them. Hook rows fill that gap: they record when the
|
|
6
|
+
* agent read or edited a source file (and which requirements the file's
|
|
7
|
+
* symbols implement), whether Kibi context was shown or suppressed, and which
|
|
8
|
+
* Kibi operations the agent ran through MCP or the project-local CLI. Joined
|
|
9
|
+
* on the host session id they show whether lookups happen before edits or
|
|
10
|
+
* only afterwards; the telemetry acceptance report turns that into the
|
|
11
|
+
* `lookup_before_first_edit` metric.
|
|
12
|
+
*
|
|
13
|
+
* Rows go to the same `.kb/usage.log` as MCP and CLI rows, tagged
|
|
14
|
+
* `interface: "hook"`, so the existing ignore rule covers them. They are
|
|
15
|
+
* written only when the operator opted in with `KIBI_DIAGNOSTIC_MODE`, the
|
|
16
|
+
* same signal the MCP server and CLI honor; installing a plugin never
|
|
17
|
+
* enables them.
|
|
18
|
+
*/
|
|
19
|
+
/** What the hook did for one tool call. Unset `action` means "nothing to record". */
|
|
20
|
+
export type HookTrace = {
|
|
21
|
+
action?: string;
|
|
22
|
+
path?: string;
|
|
23
|
+
pathKind?: string;
|
|
24
|
+
/** Requirements the touched file's symbols implement (symbol manifest). */
|
|
25
|
+
requirementIds?: string[];
|
|
26
|
+
/** Canonical Kibi operation (`kb_search`, `kb_query`, …) a call ran. */
|
|
27
|
+
kbOperation?: string;
|
|
28
|
+
/** Session state before this call: had the agent used Kibi yet? */
|
|
29
|
+
kbUsedBefore?: boolean | undefined;
|
|
30
|
+
};
|
|
31
|
+
/** Hook actions the acceptance report reads; hosts emit these names. */
|
|
32
|
+
export declare const HOOK_ACTION_EDITED = "edited";
|
|
33
|
+
export declare const HOOK_ACTION_KB_USAGE = "kb_usage";
|
|
34
|
+
export declare function hookTelemetryEnabled(env?: NodeJS.ProcessEnv): boolean;
|
|
35
|
+
export type HookUsageRow = {
|
|
36
|
+
/** Host agent, e.g. `claude-code`, `cursor`, `codex`, `zcode`, `opencode`. */
|
|
37
|
+
host: string;
|
|
38
|
+
/** Version of the adapter package that wrote the row, when known. */
|
|
39
|
+
packageVersion?: string | null | undefined;
|
|
40
|
+
workspaceRoot: string;
|
|
41
|
+
event: string;
|
|
42
|
+
sessionId: string | undefined;
|
|
43
|
+
hostTool: string | undefined;
|
|
44
|
+
trace: HookTrace;
|
|
45
|
+
startedAt: Date;
|
|
46
|
+
};
|
|
47
|
+
/** Append one hook row to `.kb/usage.log` when the operator opted in. */
|
|
48
|
+
export declare function appendHookUsage(row: HookUsageRow, env?: NodeJS.ProcessEnv): void;
|
|
49
|
+
/**
|
|
50
|
+
* Trace for a tool call that ran a Kibi operation through MCP or the
|
|
51
|
+
* project-local CLI, or undefined for any other tool.
|
|
52
|
+
*/
|
|
53
|
+
export declare function kbUsageTrace(toolName: string | undefined, toolInput: unknown): HookTrace | undefined;
|
|
54
|
+
/**
|
|
55
|
+
* One `edited` trace per workspace-relative path an edit tool changed,
|
|
56
|
+
* skipping docs, config, and generated paths. `requirementIdsFor` names the
|
|
57
|
+
* requirements a file's symbols implement; KB files implement none.
|
|
58
|
+
*/
|
|
59
|
+
export declare function editTraces(relativePaths: readonly string[], requirementIdsFor: (relativePath: string) => string[]): HookTrace[];
|
|
60
|
+
/** Append one row per trace, sharing the call's host, session and tool. */
|
|
61
|
+
export declare function appendHookUsageRows(row: Omit<HookUsageRow, "trace">, traces: readonly HookTrace[], env?: NodeJS.ProcessEnv): void;
|
|
62
|
+
/**
|
|
63
|
+
* Version of the adapter package whose `package.json` sits at
|
|
64
|
+
* `packageJsonPath`, or null when it cannot be read.
|
|
65
|
+
*/
|
|
66
|
+
export declare function readPackageVersion(packageJsonPath: string): string | null;
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
// implements REQ-claude-hook-usage-telemetry-v2, REQ-kibi-telemetry-acceptance-gate-v2
|
|
2
|
+
import { randomUUID } from "node:crypto";
|
|
3
|
+
import fs from "node:fs";
|
|
4
|
+
import path from "node:path";
|
|
5
|
+
import { hostKbOperation } from "./kb-mcp-tools.js";
|
|
6
|
+
import { classifyPath } from "./path-policy.js";
|
|
7
|
+
// implements REQ-claude-hook-usage-telemetry-v2, REQ-kibi-telemetry-acceptance-gate-v2
|
|
8
|
+
/** Hook actions the acceptance report reads; hosts emit these names. */
|
|
9
|
+
export const HOOK_ACTION_EDITED = "edited";
|
|
10
|
+
// implements REQ-claude-hook-usage-telemetry-v2, REQ-kibi-telemetry-acceptance-gate-v2
|
|
11
|
+
export const HOOK_ACTION_KB_USAGE = "kb_usage";
|
|
12
|
+
// implements REQ-claude-hook-usage-telemetry-v2
|
|
13
|
+
export function hookTelemetryEnabled(env = process.env) {
|
|
14
|
+
const value = env.KIBI_DIAGNOSTIC_MODE?.trim().toLowerCase();
|
|
15
|
+
return value === "1" || value === "true";
|
|
16
|
+
}
|
|
17
|
+
// implements REQ-claude-hook-usage-telemetry-v2
|
|
18
|
+
/** Append one hook row to `.kb/usage.log` when the operator opted in. */
|
|
19
|
+
export function appendHookUsage(row, env = process.env) {
|
|
20
|
+
if (!hookTelemetryEnabled(env) || row.trace.action === undefined)
|
|
21
|
+
return;
|
|
22
|
+
const finishedAt = new Date();
|
|
23
|
+
const { trace } = row;
|
|
24
|
+
const record = {
|
|
25
|
+
timestamp: finishedAt.toISOString(),
|
|
26
|
+
request_id: `hook-${randomUUID()}`,
|
|
27
|
+
tool: `hook_${row.event}`,
|
|
28
|
+
interface: "hook",
|
|
29
|
+
host: row.host,
|
|
30
|
+
package_version: row.packageVersion ?? null,
|
|
31
|
+
workspace_root: row.workspaceRoot,
|
|
32
|
+
session_id: row.sessionId ?? null,
|
|
33
|
+
hook_event: row.event,
|
|
34
|
+
host_tool: row.hostTool ?? null,
|
|
35
|
+
hook_action: trace.action,
|
|
36
|
+
path: trace.path ?? null,
|
|
37
|
+
path_kind: trace.pathKind ?? null,
|
|
38
|
+
requirement_ids: trace.requirementIds ?? [],
|
|
39
|
+
kb_operation: trace.kbOperation ?? null,
|
|
40
|
+
kb_used_before: trace.kbUsedBefore ?? null,
|
|
41
|
+
status: "success",
|
|
42
|
+
duration_ms: finishedAt.getTime() - row.startedAt.getTime(),
|
|
43
|
+
};
|
|
44
|
+
try {
|
|
45
|
+
const logPath = path.join(row.workspaceRoot, ".kb", "usage.log");
|
|
46
|
+
fs.mkdirSync(path.dirname(logPath), { recursive: true });
|
|
47
|
+
fs.appendFileSync(logPath, `${JSON.stringify(record)}\n`, "utf8");
|
|
48
|
+
}
|
|
49
|
+
catch {
|
|
50
|
+
// Telemetry is best effort and must never disturb the tool call.
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
// implements REQ-claude-hook-usage-telemetry-v2, REQ-kibi-telemetry-acceptance-gate-v2
|
|
54
|
+
/**
|
|
55
|
+
* Trace for a tool call that ran a Kibi operation through MCP or the
|
|
56
|
+
* project-local CLI, or undefined for any other tool.
|
|
57
|
+
*/
|
|
58
|
+
export function kbUsageTrace(toolName, toolInput) {
|
|
59
|
+
const kbOperation = hostKbOperation(toolName, toolInput);
|
|
60
|
+
return kbOperation
|
|
61
|
+
? { action: HOOK_ACTION_KB_USAGE, kbOperation }
|
|
62
|
+
: undefined;
|
|
63
|
+
}
|
|
64
|
+
// implements REQ-claude-hook-usage-telemetry-v2, REQ-kibi-telemetry-acceptance-gate-v2
|
|
65
|
+
/**
|
|
66
|
+
* One `edited` trace per workspace-relative path an edit tool changed,
|
|
67
|
+
* skipping docs, config, and generated paths. `requirementIdsFor` names the
|
|
68
|
+
* requirements a file's symbols implement; KB files implement none.
|
|
69
|
+
*/
|
|
70
|
+
export function editTraces(relativePaths, requirementIdsFor) {
|
|
71
|
+
const traces = [];
|
|
72
|
+
for (const relativePath of new Set(relativePaths)) {
|
|
73
|
+
const pathKind = classifyPath(relativePath);
|
|
74
|
+
if (pathKind === "other")
|
|
75
|
+
continue;
|
|
76
|
+
traces.push({
|
|
77
|
+
action: HOOK_ACTION_EDITED,
|
|
78
|
+
path: relativePath,
|
|
79
|
+
pathKind,
|
|
80
|
+
requirementIds: pathKind === "kb" ? [] : requirementIdsFor(relativePath),
|
|
81
|
+
});
|
|
82
|
+
}
|
|
83
|
+
return traces;
|
|
84
|
+
}
|
|
85
|
+
// implements REQ-claude-hook-usage-telemetry-v2
|
|
86
|
+
/** Append one row per trace, sharing the call's host, session and tool. */
|
|
87
|
+
export function appendHookUsageRows(row, traces, env = process.env) {
|
|
88
|
+
if (!hookTelemetryEnabled(env))
|
|
89
|
+
return;
|
|
90
|
+
for (const trace of traces)
|
|
91
|
+
appendHookUsage({ ...row, trace }, env);
|
|
92
|
+
}
|
|
93
|
+
// implements REQ-claude-hook-usage-telemetry-v2
|
|
94
|
+
/**
|
|
95
|
+
* Version of the adapter package whose `package.json` sits at
|
|
96
|
+
* `packageJsonPath`, or null when it cannot be read.
|
|
97
|
+
*/
|
|
98
|
+
export function readPackageVersion(packageJsonPath) {
|
|
99
|
+
try {
|
|
100
|
+
const parsed = JSON.parse(fs.readFileSync(packageJsonPath, "utf8"));
|
|
101
|
+
if (typeof parsed === "object" &&
|
|
102
|
+
parsed !== null &&
|
|
103
|
+
"version" in parsed &&
|
|
104
|
+
typeof parsed.version === "string") {
|
|
105
|
+
return parsed.version;
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
catch {
|
|
109
|
+
// An unreadable manifest leaves the version unknown.
|
|
110
|
+
}
|
|
111
|
+
return null;
|
|
112
|
+
}
|
package/dist/index.d.ts
CHANGED
package/dist/index.js
CHANGED
package/dist/kb-mcp-tools.d.ts
CHANGED
|
@@ -32,3 +32,13 @@ export type KibiInterface = "mcp" | "cli" | "setup";
|
|
|
32
32
|
export declare function resolveKibiInterface(mcpState: McpState, workspaceTrusted: boolean): KibiInterface;
|
|
33
33
|
export declare function extractKbMcpToolCall(toolName: string | undefined, toolInput: unknown): KbMcpToolCall | undefined;
|
|
34
34
|
export declare function extractKbMcpToolName(toolName: string | undefined, toolInput: unknown): string | undefined;
|
|
35
|
+
/**
|
|
36
|
+
* The Kibi operation a shell command runs through the project-local CLI
|
|
37
|
+
* (`kibi search`, `npx --no-install kibi query --input -`), or undefined.
|
|
38
|
+
*/
|
|
39
|
+
export declare function kibiCliOperation(command: unknown): string | undefined;
|
|
40
|
+
/**
|
|
41
|
+
* The Kibi operation a host tool call ran through either peer surface: a Kibi
|
|
42
|
+
* MCP tool under any host prefix, or the project-local CLI in a shell tool.
|
|
43
|
+
*/
|
|
44
|
+
export declare function hostKbOperation(toolName: string | undefined, toolInput: unknown): string | undefined;
|
package/dist/kb-mcp-tools.js
CHANGED
|
@@ -112,6 +112,12 @@ export function extractKbMcpToolCall(toolName, toolInput) {
|
|
|
112
112
|
"sourceFiles",
|
|
113
113
|
"source_files",
|
|
114
114
|
]);
|
|
115
|
+
// A dry-run upsert only validates; report it as the read-only operation
|
|
116
|
+
// it routes to so hooks do not treat it as a KB write.
|
|
117
|
+
if (normalizedToolName === "kb_upsert" &&
|
|
118
|
+
readBoolean(payload, ["dryRun"]) === true) {
|
|
119
|
+
normalizedToolName = "kb_validate_upsert";
|
|
120
|
+
}
|
|
115
121
|
if (normalizedToolName?.startsWith("kb_")) {
|
|
116
122
|
return {
|
|
117
123
|
toolName: normalizedToolName,
|
|
@@ -194,3 +200,44 @@ export function extractKbMcpToolName(toolName, toolInput) {
|
|
|
194
200
|
}
|
|
195
201
|
return undefined;
|
|
196
202
|
}
|
|
203
|
+
/** Project-local CLI routes and the Kibi operation each one runs. */
|
|
204
|
+
const KIBI_CLI_ROUTES = {
|
|
205
|
+
check: "kb_check",
|
|
206
|
+
query: "kb_query",
|
|
207
|
+
"kb-query": "kb_query",
|
|
208
|
+
search: "kb_search",
|
|
209
|
+
status: "kb_status",
|
|
210
|
+
graph: "kb_graph",
|
|
211
|
+
coverage: "kb_coverage",
|
|
212
|
+
"find-gaps": "kb_find_gaps",
|
|
213
|
+
gaps: "kb_find_gaps",
|
|
214
|
+
upsert: "kb_upsert",
|
|
215
|
+
sync: "kb_sync",
|
|
216
|
+
};
|
|
217
|
+
/**
|
|
218
|
+
* The Kibi operation a shell command runs through the project-local CLI
|
|
219
|
+
* (`kibi search`, `npx --no-install kibi query --input -`), or undefined.
|
|
220
|
+
*/
|
|
221
|
+
// implements REQ-claude-code-kibi-plugin-v1
|
|
222
|
+
export function kibiCliOperation(command) {
|
|
223
|
+
if (typeof command !== "string")
|
|
224
|
+
return undefined;
|
|
225
|
+
const route = /(?:^|[\s;&|(/])kibi\s+([a-z-]+)/.exec(command)?.[1];
|
|
226
|
+
return route ? KIBI_CLI_ROUTES[route] : undefined;
|
|
227
|
+
}
|
|
228
|
+
/** Host tool names that run a shell command from `tool_input.command`. */
|
|
229
|
+
const SHELL_TOOL_NAMES = new Set(["Bash", "bash", "Shell", "shell"]);
|
|
230
|
+
/**
|
|
231
|
+
* The Kibi operation a host tool call ran through either peer surface: a Kibi
|
|
232
|
+
* MCP tool under any host prefix, or the project-local CLI in a shell tool.
|
|
233
|
+
*/
|
|
234
|
+
// implements REQ-claude-code-kibi-plugin-v1, REQ-cursor-kibi-plugin-v1
|
|
235
|
+
export function hostKbOperation(toolName, toolInput) {
|
|
236
|
+
const mcp = canonicalKbToolName(toolName);
|
|
237
|
+
if (mcp !== undefined && /^kb_[a-z_]+$/.test(mcp))
|
|
238
|
+
return mcp;
|
|
239
|
+
if (!toolName || !SHELL_TOOL_NAMES.has(toolName) || !isRecord(toolInput)) {
|
|
240
|
+
return undefined;
|
|
241
|
+
}
|
|
242
|
+
return kibiCliOperation(toolInput.command);
|
|
243
|
+
}
|
|
@@ -37,14 +37,25 @@ export declare function buildKnowledgeIndex(records: readonly RawRecord[], coord
|
|
|
37
37
|
* manifests keep their size and mtime.
|
|
38
38
|
*/
|
|
39
39
|
export declare function loadKnowledgeIndex(workspaceRoot: string, cacheDir?: string, options?: KnowledgeIndexOptions): KnowledgeIndex;
|
|
40
|
+
export type EntityLink = {
|
|
41
|
+
type: string;
|
|
42
|
+
target: string;
|
|
43
|
+
};
|
|
40
44
|
export type EntitySummary = {
|
|
41
45
|
id: string;
|
|
42
46
|
title?: string;
|
|
43
47
|
status?: string;
|
|
48
|
+
/**
|
|
49
|
+
* Frontmatter `links` (plain string entries read as `relates_to`) merged
|
|
50
|
+
* with canonical relationship-shard records whose `from` is this entity.
|
|
51
|
+
*/
|
|
52
|
+
links?: EntityLink[];
|
|
44
53
|
};
|
|
45
54
|
/**
|
|
46
|
-
* Read an entity's title and
|
|
47
|
-
*
|
|
55
|
+
* Read an entity's title, status and outgoing links. Title and status come
|
|
56
|
+
* from the frontmatter head of its canonical authored document
|
|
57
|
+
* (`.kb/<lane>/<ID>.md`); links come from that frontmatter plus the canonical
|
|
58
|
+
* relationship shard (`.kb/relationships/<shard>.yaml`) holding its records.
|
|
48
59
|
*/
|
|
49
60
|
export declare function readEntitySummary(workspaceRoot: string, entityId: string): EntitySummary;
|
|
50
61
|
export {};
|
package/dist/knowledge-index.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
// implements REQ-claude-code-kibi-plugin-v1
|
|
2
|
-
import { randomUUID } from "node:crypto";
|
|
2
|
+
import { createHash, randomUUID } from "node:crypto";
|
|
3
3
|
import fs from "node:fs";
|
|
4
4
|
import path from "node:path";
|
|
5
5
|
/**
|
|
@@ -62,14 +62,21 @@ function parseScalarOrFlow(raw) {
|
|
|
62
62
|
* `symbols:` mapping and a bare top-level sequence.
|
|
63
63
|
*/
|
|
64
64
|
export function scanSymbolsManifest(text) {
|
|
65
|
+
return scanRecordSequence(text, "symbols");
|
|
66
|
+
}
|
|
67
|
+
/**
|
|
68
|
+
* Scan the block sequence under top-level `sectionKey` (or a bare root
|
|
69
|
+
* sequence) into raw records.
|
|
70
|
+
*/
|
|
71
|
+
function scanRecordSequence(text, sectionKey) {
|
|
65
72
|
const records = [];
|
|
66
73
|
let itemIndent;
|
|
67
74
|
let keyIndent = 0;
|
|
68
75
|
let current;
|
|
69
76
|
let listKey;
|
|
70
77
|
let listItem;
|
|
71
|
-
// Top-level section:
|
|
72
|
-
// sequence; sequences under any other top-level key are ignored.
|
|
78
|
+
// Top-level section: records are the `<sectionKey>:` sequence or a bare
|
|
79
|
+
// root sequence; sequences under any other top-level key are ignored.
|
|
73
80
|
let section = "root";
|
|
74
81
|
for (const rawLine of text.split(/\r?\n/)) {
|
|
75
82
|
const content = rawLine.trim();
|
|
@@ -80,8 +87,8 @@ export function scanSymbolsManifest(text) {
|
|
|
80
87
|
if (indent === 0 && !dash) {
|
|
81
88
|
const pair = keyValue.exec(content);
|
|
82
89
|
section =
|
|
83
|
-
pair?.[1] !== undefined && unquote(pair[1]) ===
|
|
84
|
-
? "
|
|
90
|
+
pair?.[1] !== undefined && unquote(pair[1]) === sectionKey
|
|
91
|
+
? "records"
|
|
85
92
|
: "other";
|
|
86
93
|
current = undefined;
|
|
87
94
|
itemIndent = undefined;
|
|
@@ -348,9 +355,58 @@ const ENTITY_LANES = [
|
|
|
348
355
|
["EVT-", "events"],
|
|
349
356
|
];
|
|
350
357
|
const SAFE_ENTITY_ID = /^[A-Za-z0-9][A-Za-z0-9._-]*$/;
|
|
358
|
+
const MAX_SUMMARY_LINKS = 24;
|
|
359
|
+
const RELATIONSHIPS_DIR = ".kb/relationships";
|
|
360
|
+
/**
|
|
361
|
+
* Parsed relationship shards keyed by absolute path and revalidated by size
|
|
362
|
+
* and mtime, so one process parses each shard once however many summaries it
|
|
363
|
+
* reads.
|
|
364
|
+
*/
|
|
365
|
+
const shardCache = new Map();
|
|
366
|
+
/** Shard holding a `from` id's records: first two hex chars of SHA-256(id). */
|
|
367
|
+
function shardPathFor(workspaceRoot, entityId) {
|
|
368
|
+
const shard = createHash("sha256").update(entityId).digest("hex");
|
|
369
|
+
return path.join(workspaceRoot, RELATIONSHIPS_DIR, `${shard.slice(0, 2)}.yaml`);
|
|
370
|
+
}
|
|
371
|
+
function shardLinksFrom(workspaceRoot, entityId) {
|
|
372
|
+
const shardPath = shardPathFor(workspaceRoot, entityId);
|
|
373
|
+
const signature = fileSignature(shardPath);
|
|
374
|
+
if (signature === "missing") {
|
|
375
|
+
shardCache.delete(shardPath);
|
|
376
|
+
return [];
|
|
377
|
+
}
|
|
378
|
+
let cached = shardCache.get(shardPath);
|
|
379
|
+
if (cached?.signature !== signature) {
|
|
380
|
+
const byFrom = new Map();
|
|
381
|
+
const records = scanRecordSequence(readText(shardPath) ?? "", "relationships");
|
|
382
|
+
for (const { type, from, to } of records) {
|
|
383
|
+
if (typeof type !== "string" ||
|
|
384
|
+
typeof from !== "string" ||
|
|
385
|
+
typeof to !== "string") {
|
|
386
|
+
continue;
|
|
387
|
+
}
|
|
388
|
+
const list = byFrom.get(from) ?? [];
|
|
389
|
+
list.push({ type, target: to });
|
|
390
|
+
byFrom.set(from, list);
|
|
391
|
+
}
|
|
392
|
+
cached = { signature, byFrom };
|
|
393
|
+
shardCache.set(shardPath, cached);
|
|
394
|
+
}
|
|
395
|
+
return cached.byFrom.get(entityId) ?? [];
|
|
396
|
+
}
|
|
397
|
+
function flushLink(links, pending) {
|
|
398
|
+
if (pending?.target !== undefined) {
|
|
399
|
+
links.push({ type: pending.type ?? "relates_to", target: pending.target });
|
|
400
|
+
}
|
|
401
|
+
else if (pending?.bare !== undefined) {
|
|
402
|
+
links.push({ type: "relates_to", target: pending.bare });
|
|
403
|
+
}
|
|
404
|
+
}
|
|
351
405
|
/**
|
|
352
|
-
* Read an entity's title and
|
|
353
|
-
*
|
|
406
|
+
* Read an entity's title, status and outgoing links. Title and status come
|
|
407
|
+
* from the frontmatter head of its canonical authored document
|
|
408
|
+
* (`.kb/<lane>/<ID>.md`); links come from that frontmatter plus the canonical
|
|
409
|
+
* relationship shard (`.kb/relationships/<shard>.yaml`) holding its records.
|
|
354
410
|
*/
|
|
355
411
|
export function readEntitySummary(workspaceRoot, entityId) {
|
|
356
412
|
const lane = ENTITY_LANES.find(([prefix]) => entityId.startsWith(prefix));
|
|
@@ -360,7 +416,7 @@ export function readEntitySummary(workspaceRoot, entityId) {
|
|
|
360
416
|
try {
|
|
361
417
|
const descriptor = fs.openSync(path.join(workspaceRoot, ".kb", lane[1], `${entityId}.md`), "r");
|
|
362
418
|
try {
|
|
363
|
-
const buffer = Buffer.alloc(
|
|
419
|
+
const buffer = Buffer.alloc(8192);
|
|
364
420
|
const bytes = fs.readSync(descriptor, buffer, 0, buffer.length, 0);
|
|
365
421
|
head = buffer.subarray(0, bytes).toString("utf8");
|
|
366
422
|
}
|
|
@@ -375,6 +431,9 @@ export function readEntitySummary(workspaceRoot, entityId) {
|
|
|
375
431
|
if (lines[0]?.trim() !== "---")
|
|
376
432
|
return { id: entityId };
|
|
377
433
|
const summary = { id: entityId };
|
|
434
|
+
const authored = [];
|
|
435
|
+
let inLinks = false;
|
|
436
|
+
let pending;
|
|
378
437
|
for (const line of lines.slice(1)) {
|
|
379
438
|
if (line.trim() === "---")
|
|
380
439
|
break;
|
|
@@ -383,6 +442,49 @@ export function readEntitySummary(workspaceRoot, entityId) {
|
|
|
383
442
|
summary.title = unquote(match[2]);
|
|
384
443
|
if (match?.[1] === "status" && match[2])
|
|
385
444
|
summary.status = unquote(match[2]);
|
|
445
|
+
if (/^\S/.test(line)) {
|
|
446
|
+
flushLink(authored, pending);
|
|
447
|
+
pending = undefined;
|
|
448
|
+
inLinks = /^links:\s*$/.test(line);
|
|
449
|
+
continue;
|
|
450
|
+
}
|
|
451
|
+
if (!inLinks)
|
|
452
|
+
continue;
|
|
453
|
+
// Typed entries carry `type: X` and `target: Y` in either order, so an
|
|
454
|
+
// entry is pushed only when the next entry or section starts; a bare
|
|
455
|
+
// `- ID` entry is a generic relates_to link.
|
|
456
|
+
const entry = /^\s*-\s*(.*)$/.exec(line);
|
|
457
|
+
if (entry) {
|
|
458
|
+
flushLink(authored, pending);
|
|
459
|
+
pending = {};
|
|
460
|
+
}
|
|
461
|
+
if (!pending)
|
|
462
|
+
continue;
|
|
463
|
+
const body = (entry ? (entry[1] ?? "") : line).trim();
|
|
464
|
+
const field = /^(type|target):\s*(.+)$/.exec(body);
|
|
465
|
+
if (field?.[1] === "type")
|
|
466
|
+
pending.type = unquote(field[2] ?? "");
|
|
467
|
+
else if (field?.[1] === "target")
|
|
468
|
+
pending.target = unquote(field[2] ?? "");
|
|
469
|
+
else if (entry && SAFE_ENTITY_ID.test(unquote(body)))
|
|
470
|
+
pending.bare = unquote(body);
|
|
471
|
+
}
|
|
472
|
+
flushLink(authored, pending);
|
|
473
|
+
const links = [];
|
|
474
|
+
const seen = new Set();
|
|
475
|
+
for (const link of [
|
|
476
|
+
...authored,
|
|
477
|
+
...shardLinksFrom(workspaceRoot, entityId),
|
|
478
|
+
]) {
|
|
479
|
+
const key = `${link.type}\u0000${link.target}`;
|
|
480
|
+
if (link.target.length === 0 || seen.has(key))
|
|
481
|
+
continue;
|
|
482
|
+
seen.add(key);
|
|
483
|
+
links.push(link);
|
|
484
|
+
if (links.length >= MAX_SUMMARY_LINKS)
|
|
485
|
+
break;
|
|
386
486
|
}
|
|
487
|
+
if (links.length > 0)
|
|
488
|
+
summary.links = links;
|
|
387
489
|
return summary;
|
|
388
490
|
}
|
package/dist/path-policy.d.ts
CHANGED
|
@@ -21,6 +21,16 @@ export type CanonicalWorkspacePath = {
|
|
|
21
21
|
};
|
|
22
22
|
export declare function normalizeWorkspacePath(candidate: string): string;
|
|
23
23
|
export declare function extractExplicitPathFields(input: unknown): string[];
|
|
24
|
+
/**
|
|
25
|
+
* Files named by an `apply_patch` envelope (`*** Update File: <path>`,
|
|
26
|
+
* `*** Add File:`, `*** Delete File:`), in patch order.
|
|
27
|
+
*/
|
|
28
|
+
export declare function extractPatchFilePaths(patch: string): string[];
|
|
29
|
+
/**
|
|
30
|
+
* Paths an edit tool call targets: explicit path fields plus the files named
|
|
31
|
+
* in patch text (Codex sends `apply_patch` with the patch in `command`).
|
|
32
|
+
*/
|
|
33
|
+
export declare function extractEditedPaths(toolInput: unknown): string[];
|
|
24
34
|
export declare function canonicalizeWorkspacePath(workspaceRoot: string, options: {
|
|
25
35
|
eventCwd?: string | undefined;
|
|
26
36
|
base?: string | undefined;
|
package/dist/path-policy.js
CHANGED
|
@@ -125,6 +125,40 @@ export function extractExplicitPathFields(input) {
|
|
|
125
125
|
visitExplicitPathFields(input, paths);
|
|
126
126
|
return [...new Set(paths)];
|
|
127
127
|
}
|
|
128
|
+
const patchFileHeader = /^\*\*\* (?:Update|Add|Delete) File: (.+)$/;
|
|
129
|
+
const patchTextKeys = ["command", "patch", "input", "patchText"];
|
|
130
|
+
/**
|
|
131
|
+
* Files named by an `apply_patch` envelope (`*** Update File: <path>`,
|
|
132
|
+
* `*** Add File:`, `*** Delete File:`), in patch order.
|
|
133
|
+
*/
|
|
134
|
+
// implements REQ-codex-kibi-plugin-v1
|
|
135
|
+
export function extractPatchFilePaths(patch) {
|
|
136
|
+
const paths = [];
|
|
137
|
+
for (const line of patch.split(/\r?\n/)) {
|
|
138
|
+
const match = patchFileHeader.exec(line.trim());
|
|
139
|
+
const candidate = match?.[1] ? normalizeWorkspacePath(match[1]) : "";
|
|
140
|
+
if (candidate.length > 0)
|
|
141
|
+
paths.push(candidate);
|
|
142
|
+
}
|
|
143
|
+
return [...new Set(paths)];
|
|
144
|
+
}
|
|
145
|
+
/**
|
|
146
|
+
* Paths an edit tool call targets: explicit path fields plus the files named
|
|
147
|
+
* in patch text (Codex sends `apply_patch` with the patch in `command`).
|
|
148
|
+
*/
|
|
149
|
+
// implements REQ-codex-kibi-plugin-v1, REQ-zcode-kibi-plugin-v1
|
|
150
|
+
export function extractEditedPaths(toolInput) {
|
|
151
|
+
const paths = extractExplicitPathFields(toolInput);
|
|
152
|
+
if (isRecord(toolInput)) {
|
|
153
|
+
for (const key of patchTextKeys) {
|
|
154
|
+
const value = toolInput[key];
|
|
155
|
+
if (typeof value === "string" && value.includes("*** ")) {
|
|
156
|
+
paths.push(...extractPatchFilePaths(value));
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
return [...new Set(paths)];
|
|
161
|
+
}
|
|
128
162
|
export function canonicalizeWorkspacePath(workspaceRoot, options) {
|
|
129
163
|
const trimmed = normalizeWorkspacePath(options.rawPath);
|
|
130
164
|
if (trimmed.length === 0)
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
import { type EntitySummary, type IndexedSymbol } from "./knowledge-index.js";
|
|
2
|
+
export declare const MAX_SNIPPET_CHARS = 1200;
|
|
3
|
+
export type LineRange = {
|
|
4
|
+
start: number;
|
|
5
|
+
end: number;
|
|
6
|
+
};
|
|
7
|
+
export type Summarize = (entityId: string) => EntitySummary;
|
|
8
|
+
export type SnippetInput = {
|
|
9
|
+
relativePath: string;
|
|
10
|
+
symbols: readonly IndexedSymbol[];
|
|
11
|
+
surface: "read" | "edit";
|
|
12
|
+
/** Lines the tool call targets (edit location or read window). */
|
|
13
|
+
focus?: readonly LineRange[] | undefined;
|
|
14
|
+
summarize: Summarize;
|
|
15
|
+
/** Host context budget for this snippet; defaults to MAX_SNIPPET_CHARS. */
|
|
16
|
+
maxChars?: number | undefined;
|
|
17
|
+
};
|
|
18
|
+
/** Symbols whose coordinates intersect the focus, innermost first. */
|
|
19
|
+
export declare function focusedSymbols(symbols: readonly IndexedSymbol[], focus: readonly LineRange[] | undefined): IndexedSymbol[];
|
|
20
|
+
/**
|
|
21
|
+
* Lines an edit will replace, located in the current file content from the
|
|
22
|
+
* edit's `old_string`/`oldString` (single edits and `edits` lists).
|
|
23
|
+
*/
|
|
24
|
+
export declare function editFocus(absolutePath: string, toolInput: unknown): LineRange[] | undefined;
|
|
25
|
+
/** True for a status that marks an entity as no longer current policy. */
|
|
26
|
+
export declare function isRetiredStatus(status: string | undefined): boolean;
|
|
27
|
+
/** `ID (status): title`, with the status shown only when it is retired. */
|
|
28
|
+
export declare function describeEntity(summary: EntitySummary): string;
|
|
29
|
+
/** Link types through which a requirement is grounded in semantic facts. */
|
|
30
|
+
export declare const GROUNDING_LINK_TYPES: readonly string[];
|
|
31
|
+
/**
|
|
32
|
+
* What a requirement says must stay true (its linked facts) and the decision
|
|
33
|
+
* behind it (a linked ADR), read from its frontmatter links and
|
|
34
|
+
* relationship-shard records. A retired requirement is not current policy, so
|
|
35
|
+
* its grounding is not presented as something to keep true.
|
|
36
|
+
*/
|
|
37
|
+
export declare function requirementGroundingLines(requirementId: string, summarize: Summarize, options?: {
|
|
38
|
+
maxFacts?: number;
|
|
39
|
+
}): string[];
|
|
40
|
+
/** Requirement IDs a file's symbols implement, in manifest order. */
|
|
41
|
+
export declare function implementedRequirementIds(symbols: readonly IndexedSymbol[]): string[];
|
|
42
|
+
/**
|
|
43
|
+
* Memoized entity summaries for one hook process: titles, statuses and links
|
|
44
|
+
* are read from the workspace once per entity however often a snippet asks.
|
|
45
|
+
*/
|
|
46
|
+
export declare function createEntitySummarizer(workspaceRoot: string): Summarize;
|
|
47
|
+
/**
|
|
48
|
+
* Knowledge snippet for a file with requirement-linked symbols, or undefined
|
|
49
|
+
* when the manifest links nothing to it.
|
|
50
|
+
*/
|
|
51
|
+
export declare function fileKnowledgeSnippet(input: SnippetInput): string | undefined;
|
|
52
|
+
/**
|
|
53
|
+
* One-line focus update for a file whose snippet was already shown this
|
|
54
|
+
* session: only the new fact (which symbol the edit lands in) is repeated.
|
|
55
|
+
*/
|
|
56
|
+
export declare function focusUpdate(relativePath: string, symbols: readonly IndexedSymbol[], focus: readonly LineRange[] | undefined): string | undefined;
|
|
57
|
+
export type EditKnowledgeInput = {
|
|
58
|
+
/** Workspace-relative paths the edit call targets, in call order. */
|
|
59
|
+
relativePaths: readonly string[];
|
|
60
|
+
/** Symbols with relationships for a workspace-relative path. */
|
|
61
|
+
symbolsFor: (relativePath: string) => readonly IndexedSymbol[];
|
|
62
|
+
summarize: Summarize;
|
|
63
|
+
/** Where the once-per-session markers live; undefined shows every time. */
|
|
64
|
+
stateDir: string | undefined;
|
|
65
|
+
/** Host session the markers are scoped to. */
|
|
66
|
+
sessionId: string | undefined;
|
|
67
|
+
/** Lines the edit targets in a file, when the host input names them. */
|
|
68
|
+
focusFor?: ((relativePath: string) => LineRange[] | undefined) | undefined;
|
|
69
|
+
/** Most files described in one call (an `apply_patch` may touch many). */
|
|
70
|
+
maxFiles?: number | undefined;
|
|
71
|
+
maxChars?: number | undefined;
|
|
72
|
+
};
|
|
73
|
+
/**
|
|
74
|
+
* Pre-edit context for hosts without their own snippet memory: the shared
|
|
75
|
+
* edit snippet for each linked file the call targets, shown once per file per
|
|
76
|
+
* session, at most `maxFiles` per call.
|
|
77
|
+
*/
|
|
78
|
+
export declare function editKnowledgeContext(input: EditKnowledgeInput): string | undefined;
|
|
79
|
+
/**
|
|
80
|
+
* Claim the one-time slot for `key` under `stateDir`: true the first time a
|
|
81
|
+
* key is claimed, false afterwards. The marker is created exclusively, so
|
|
82
|
+
* hooks for parallel tool calls never both show the same snippet. Without a
|
|
83
|
+
* state directory every call is a first time.
|
|
84
|
+
*/
|
|
85
|
+
export declare function claimSnippetSlot(stateDir: string | undefined, key: string): boolean;
|
package/dist/snippets.js
ADDED
|
@@ -0,0 +1,327 @@
|
|
|
1
|
+
// implements REQ-claude-code-kibi-plugin-v1, REQ-cursor-kibi-plugin-v1, REQ-codex-kibi-plugin-v1, REQ-zcode-kibi-plugin-v1, REQ-opencode-kibi-plugin-v1
|
|
2
|
+
import { createHash } from "node:crypto";
|
|
3
|
+
import fs from "node:fs";
|
|
4
|
+
import path from "node:path";
|
|
5
|
+
import { readEntitySummary, } from "./knowledge-index.js";
|
|
6
|
+
/**
|
|
7
|
+
* Progressive-disclosure snippets shared by every host adapter.
|
|
8
|
+
*
|
|
9
|
+
* Each snippet is the first layer only: requirement IDs with titles, the
|
|
10
|
+
* symbols that link them, what the lead requirement must keep true and the
|
|
11
|
+
* decision behind it (edits only), and the exact follow-up call that opens the
|
|
12
|
+
* next layer. Text is phrased as project facts rather than instructions (hosts
|
|
13
|
+
* may treat imperative out-of-band text as a possible prompt injection) and
|
|
14
|
+
* stays within a small budget so repeated reads never flood the context.
|
|
15
|
+
*/
|
|
16
|
+
const MAX_REQUIREMENTS = 4;
|
|
17
|
+
const MAX_SYMBOLS_PER_REQUIREMENT = 3;
|
|
18
|
+
const MAX_TESTS = 3;
|
|
19
|
+
const MAX_TITLE = 80;
|
|
20
|
+
const MAX_GROUNDING = 2;
|
|
21
|
+
/** Files larger than this are not scanned to locate an edit. */
|
|
22
|
+
const MAX_FOCUS_SCAN_BYTES = 2 * 1024 * 1024;
|
|
23
|
+
// implements REQ-claude-code-kibi-plugin-v1, REQ-cursor-kibi-plugin-v1
|
|
24
|
+
export const MAX_SNIPPET_CHARS = 1200;
|
|
25
|
+
function truncate(text, limit) {
|
|
26
|
+
return text.length <= limit ? text : `${text.slice(0, limit - 1)}…`;
|
|
27
|
+
}
|
|
28
|
+
function overlaps(symbol, range) {
|
|
29
|
+
if (symbol.line === undefined)
|
|
30
|
+
return false;
|
|
31
|
+
const end = symbol.endLine ?? symbol.line;
|
|
32
|
+
return symbol.line <= range.end && end >= range.start;
|
|
33
|
+
}
|
|
34
|
+
// implements REQ-claude-code-kibi-plugin-v1, REQ-cursor-kibi-plugin-v1
|
|
35
|
+
/** Symbols whose coordinates intersect the focus, innermost first. */
|
|
36
|
+
export function focusedSymbols(symbols, focus) {
|
|
37
|
+
if (!focus || focus.length === 0)
|
|
38
|
+
return [];
|
|
39
|
+
return symbols
|
|
40
|
+
.filter((symbol) => focus.some((range) => overlaps(symbol, range)))
|
|
41
|
+
.sort((left, right) => (left.endLine ?? left.line ?? 0) -
|
|
42
|
+
(left.line ?? 0) -
|
|
43
|
+
((right.endLine ?? right.line ?? 0) - (right.line ?? 0)));
|
|
44
|
+
}
|
|
45
|
+
function isRecord(value) {
|
|
46
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
47
|
+
}
|
|
48
|
+
// implements REQ-claude-code-kibi-plugin-v1, REQ-cursor-kibi-plugin-v1, REQ-zcode-kibi-plugin-v1
|
|
49
|
+
/**
|
|
50
|
+
* Lines an edit will replace, located in the current file content from the
|
|
51
|
+
* edit's `old_string`/`oldString` (single edits and `edits` lists).
|
|
52
|
+
*/
|
|
53
|
+
export function editFocus(absolutePath, toolInput) {
|
|
54
|
+
if (!isRecord(toolInput))
|
|
55
|
+
return undefined;
|
|
56
|
+
const needles = [];
|
|
57
|
+
const collect = (record) => {
|
|
58
|
+
for (const key of ["old_string", "oldString"]) {
|
|
59
|
+
const value = record[key];
|
|
60
|
+
if (typeof value === "string")
|
|
61
|
+
needles.push(value);
|
|
62
|
+
}
|
|
63
|
+
};
|
|
64
|
+
collect(toolInput);
|
|
65
|
+
if (Array.isArray(toolInput.edits)) {
|
|
66
|
+
for (const edit of toolInput.edits) {
|
|
67
|
+
if (isRecord(edit))
|
|
68
|
+
collect(edit);
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
const usable = needles.filter((needle) => needle.length > 0);
|
|
72
|
+
if (usable.length === 0)
|
|
73
|
+
return undefined;
|
|
74
|
+
let content;
|
|
75
|
+
try {
|
|
76
|
+
if (fs.statSync(absolutePath).size > MAX_FOCUS_SCAN_BYTES)
|
|
77
|
+
return undefined;
|
|
78
|
+
content = fs.readFileSync(absolutePath, "utf8");
|
|
79
|
+
}
|
|
80
|
+
catch {
|
|
81
|
+
return undefined;
|
|
82
|
+
}
|
|
83
|
+
const ranges = [];
|
|
84
|
+
for (const needle of usable) {
|
|
85
|
+
const offset = content.indexOf(needle);
|
|
86
|
+
if (offset < 0)
|
|
87
|
+
continue;
|
|
88
|
+
const start = content.slice(0, offset).split("\n").length;
|
|
89
|
+
ranges.push({ start, end: start + needle.split("\n").length - 1 });
|
|
90
|
+
}
|
|
91
|
+
return ranges.length > 0 ? ranges : undefined;
|
|
92
|
+
}
|
|
93
|
+
function formatList(items, limit) {
|
|
94
|
+
const shown = items.slice(0, limit).join(", ");
|
|
95
|
+
return items.length > limit ? `${shown} +${items.length - limit}` : shown;
|
|
96
|
+
}
|
|
97
|
+
const RETIRED_STATUS = /supersed|deprecat|reject|obsolete|retired/i;
|
|
98
|
+
// implements REQ-agent-core-edit-snippets, REQ-claude-code-kibi-plugin-v1, REQ-opencode-kibi-plugin-v1
|
|
99
|
+
/** True for a status that marks an entity as no longer current policy. */
|
|
100
|
+
export function isRetiredStatus(status) {
|
|
101
|
+
return status !== undefined && RETIRED_STATUS.test(status);
|
|
102
|
+
}
|
|
103
|
+
// implements REQ-agent-core-edit-snippets, REQ-claude-code-kibi-plugin-v1, REQ-opencode-kibi-plugin-v1
|
|
104
|
+
/** `ID (status): title`, with the status shown only when it is retired. */
|
|
105
|
+
export function describeEntity(summary) {
|
|
106
|
+
const notable = isRetiredStatus(summary.status) ? ` (${summary.status})` : "";
|
|
107
|
+
return summary.title
|
|
108
|
+
? `${summary.id}${notable}: ${truncate(summary.title, MAX_TITLE)}`
|
|
109
|
+
: `${summary.id}${notable}`;
|
|
110
|
+
}
|
|
111
|
+
function jsonString(value) {
|
|
112
|
+
return JSON.stringify(value);
|
|
113
|
+
}
|
|
114
|
+
// implements REQ-agent-core-edit-snippets, REQ-claude-code-kibi-plugin-v1, REQ-opencode-kibi-plugin-v1
|
|
115
|
+
/** Link types through which a requirement is grounded in semantic facts. */
|
|
116
|
+
export const GROUNDING_LINK_TYPES = [
|
|
117
|
+
"constrains",
|
|
118
|
+
"requires_property",
|
|
119
|
+
"requires_predicate",
|
|
120
|
+
"requires_rule",
|
|
121
|
+
];
|
|
122
|
+
// implements REQ-agent-core-edit-snippets, REQ-claude-code-kibi-plugin-v1, REQ-opencode-kibi-plugin-v1
|
|
123
|
+
/**
|
|
124
|
+
* What a requirement says must stay true (its linked facts) and the decision
|
|
125
|
+
* behind it (a linked ADR), read from its frontmatter links and
|
|
126
|
+
* relationship-shard records. A retired requirement is not current policy, so
|
|
127
|
+
* its grounding is not presented as something to keep true.
|
|
128
|
+
*/
|
|
129
|
+
export function requirementGroundingLines(requirementId, summarize, options = {}) {
|
|
130
|
+
const maxFacts = options.maxFacts ?? MAX_GROUNDING;
|
|
131
|
+
const requirement = summarize(requirementId);
|
|
132
|
+
if (isRetiredStatus(requirement.status))
|
|
133
|
+
return [];
|
|
134
|
+
const links = requirement.links ?? [];
|
|
135
|
+
const facts = [
|
|
136
|
+
...new Set(links
|
|
137
|
+
.filter((link) => GROUNDING_LINK_TYPES.includes(link.type))
|
|
138
|
+
.map((link) => link.target)),
|
|
139
|
+
];
|
|
140
|
+
const adrs = [
|
|
141
|
+
...new Set(links
|
|
142
|
+
.filter((link) => link.target.startsWith("ADR-"))
|
|
143
|
+
.map((link) => link.target)),
|
|
144
|
+
];
|
|
145
|
+
const lines = [];
|
|
146
|
+
if (facts.length > 0) {
|
|
147
|
+
const shown = facts
|
|
148
|
+
.slice(0, maxFacts)
|
|
149
|
+
.map((id) => describeEntity(summarize(id)));
|
|
150
|
+
const more = facts.length > maxFacts ? ` +${facts.length - maxFacts}` : "";
|
|
151
|
+
lines.push(`${requirementId} must keep true: ${shown.join("; ")}${more}.`);
|
|
152
|
+
}
|
|
153
|
+
const adr = adrs[0];
|
|
154
|
+
if (adr)
|
|
155
|
+
lines.push(`Decision: ${describeEntity(summarize(adr))}.`);
|
|
156
|
+
return lines;
|
|
157
|
+
}
|
|
158
|
+
// implements REQ-claude-code-kibi-plugin-v1, REQ-cursor-kibi-plugin-v1, REQ-codex-kibi-plugin-v1, REQ-zcode-kibi-plugin-v1
|
|
159
|
+
/** Requirement IDs a file's symbols implement, in manifest order. */
|
|
160
|
+
export function implementedRequirementIds(symbols) {
|
|
161
|
+
return [...new Set(symbols.flatMap((symbol) => symbol.implements))];
|
|
162
|
+
}
|
|
163
|
+
// implements REQ-agent-core-edit-snippets, REQ-claude-code-kibi-plugin-v1, REQ-cursor-kibi-plugin-v1, REQ-codex-kibi-plugin-v1, REQ-zcode-kibi-plugin-v1, REQ-opencode-kibi-plugin-v1
|
|
164
|
+
/**
|
|
165
|
+
* Memoized entity summaries for one hook process: titles, statuses and links
|
|
166
|
+
* are read from the workspace once per entity however often a snippet asks.
|
|
167
|
+
*/
|
|
168
|
+
export function createEntitySummarizer(workspaceRoot) {
|
|
169
|
+
const summaries = new Map();
|
|
170
|
+
return (entityId) => {
|
|
171
|
+
let summary = summaries.get(entityId);
|
|
172
|
+
if (!summary) {
|
|
173
|
+
summary = readEntitySummary(workspaceRoot, entityId);
|
|
174
|
+
summaries.set(entityId, summary);
|
|
175
|
+
}
|
|
176
|
+
return summary;
|
|
177
|
+
};
|
|
178
|
+
}
|
|
179
|
+
// implements REQ-agent-core-edit-snippets, REQ-claude-code-kibi-plugin-v1, REQ-cursor-kibi-plugin-v1, REQ-codex-kibi-plugin-v1, REQ-zcode-kibi-plugin-v1
|
|
180
|
+
/**
|
|
181
|
+
* Knowledge snippet for a file with requirement-linked symbols, or undefined
|
|
182
|
+
* when the manifest links nothing to it.
|
|
183
|
+
*/
|
|
184
|
+
export function fileKnowledgeSnippet(input) {
|
|
185
|
+
const { relativePath, symbols, surface, summarize } = input;
|
|
186
|
+
const focus = focusedSymbols(symbols, input.focus);
|
|
187
|
+
// Requirement -> symbol titles, focus symbols first so their owners lead.
|
|
188
|
+
const owners = new Map();
|
|
189
|
+
const ordered = [
|
|
190
|
+
...focus,
|
|
191
|
+
...symbols.filter((symbol) => !focus.includes(symbol)),
|
|
192
|
+
];
|
|
193
|
+
for (const symbol of ordered) {
|
|
194
|
+
for (const requirement of symbol.implements) {
|
|
195
|
+
const titles = owners.get(requirement) ?? [];
|
|
196
|
+
if (!titles.includes(symbol.title))
|
|
197
|
+
titles.push(symbol.title);
|
|
198
|
+
owners.set(requirement, titles);
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
const tests = [
|
|
202
|
+
...new Set(symbols.flatMap((symbol) => symbol.coveredBy)),
|
|
203
|
+
].sort();
|
|
204
|
+
const executes = [
|
|
205
|
+
...new Set(symbols.flatMap((symbol) => symbol.executableFor)),
|
|
206
|
+
].sort();
|
|
207
|
+
if (owners.size === 0 && tests.length === 0 && executes.length === 0) {
|
|
208
|
+
return undefined;
|
|
209
|
+
}
|
|
210
|
+
const focusOwners = new Set(focus.flatMap((symbol) => symbol.implements));
|
|
211
|
+
const requirementIds = [...owners.keys()].sort((left, right) => {
|
|
212
|
+
const focusOrder = Number(focusOwners.has(right)) - Number(focusOwners.has(left));
|
|
213
|
+
return focusOrder !== 0
|
|
214
|
+
? focusOrder
|
|
215
|
+
: (owners.get(right)?.length ?? 0) - (owners.get(left)?.length ?? 0);
|
|
216
|
+
});
|
|
217
|
+
const lines = [
|
|
218
|
+
`Kibi knowledge for ${relativePath} (symbol manifest):`,
|
|
219
|
+
];
|
|
220
|
+
for (const requirementId of requirementIds.slice(0, MAX_REQUIREMENTS)) {
|
|
221
|
+
const symbolTitles = owners.get(requirementId) ?? [];
|
|
222
|
+
lines.push(`- ${describeEntity(summarize(requirementId))} — ${formatList(symbolTitles, MAX_SYMBOLS_PER_REQUIREMENT)}`);
|
|
223
|
+
}
|
|
224
|
+
if (requirementIds.length > MAX_REQUIREMENTS) {
|
|
225
|
+
lines.push(`- +${requirementIds.length - MAX_REQUIREMENTS} more requirements: kb_query({sourceFile:${jsonString(relativePath)}})`);
|
|
226
|
+
}
|
|
227
|
+
const leadId = requirementIds[0];
|
|
228
|
+
if (surface === "edit" && leadId) {
|
|
229
|
+
lines.push(...requirementGroundingLines(leadId, summarize));
|
|
230
|
+
}
|
|
231
|
+
if (tests.length > 0) {
|
|
232
|
+
lines.push(`Covered by: ${formatList(tests, MAX_TESTS)}.`);
|
|
233
|
+
}
|
|
234
|
+
if (executes.length > 0) {
|
|
235
|
+
lines.push(`Test code for: ${formatList(executes, MAX_TESTS)}.`);
|
|
236
|
+
}
|
|
237
|
+
const primaryFocus = focus[0];
|
|
238
|
+
if (primaryFocus) {
|
|
239
|
+
const owner = primaryFocus.implements[0];
|
|
240
|
+
const verb = surface === "edit" ? "The edit is inside" : "These lines are inside";
|
|
241
|
+
lines.push(owner
|
|
242
|
+
? `${verb} ${primaryFocus.title}, which implements ${owner}.`
|
|
243
|
+
: `${verb} ${primaryFocus.title}.`);
|
|
244
|
+
}
|
|
245
|
+
const location = primaryFocus
|
|
246
|
+
? `{path:${jsonString(relativePath)}, symbol:${jsonString(primaryFocus.title)}}`
|
|
247
|
+
: `{path:${jsonString(relativePath)}}`;
|
|
248
|
+
const next = [
|
|
249
|
+
leadId
|
|
250
|
+
? `kb_query({id:${jsonString(leadId)}}) returns full requirement text`
|
|
251
|
+
: undefined,
|
|
252
|
+
`kb_search({query:"<topic>", sourceLocations:[${location}]}) answers with governing requirements, facts, decisions, and tests`,
|
|
253
|
+
].filter((part) => part !== undefined);
|
|
254
|
+
lines.push(`Next layer: ${next.join("; ")}.`);
|
|
255
|
+
if (surface === "edit") {
|
|
256
|
+
lines.push(`Behavior changes here are traced to these requirements; kb_check({sourceFiles:[${jsonString(relativePath)}], includeImpactDiagnostics:true, includeWorkingTreeDiff:true}) reports ownership drift after the edit.`);
|
|
257
|
+
}
|
|
258
|
+
return truncate(lines.join("\n"), input.maxChars ?? MAX_SNIPPET_CHARS);
|
|
259
|
+
}
|
|
260
|
+
// implements REQ-claude-code-kibi-plugin-v1
|
|
261
|
+
/**
|
|
262
|
+
* One-line focus update for a file whose snippet was already shown this
|
|
263
|
+
* session: only the new fact (which symbol the edit lands in) is repeated.
|
|
264
|
+
*/
|
|
265
|
+
export function focusUpdate(relativePath, symbols, focus) {
|
|
266
|
+
const symbol = focusedSymbols(symbols, focus)[0];
|
|
267
|
+
if (!symbol)
|
|
268
|
+
return undefined;
|
|
269
|
+
const owners = symbol.implements.length > 0
|
|
270
|
+
? `, which implements ${formatList(symbol.implements, 2)}`
|
|
271
|
+
: "";
|
|
272
|
+
return `Kibi: this edit to ${relativePath} is inside ${symbol.title}${owners}.`;
|
|
273
|
+
}
|
|
274
|
+
// implements REQ-agent-core-edit-snippets, REQ-codex-kibi-plugin-v1, REQ-zcode-kibi-plugin-v1
|
|
275
|
+
/**
|
|
276
|
+
* Pre-edit context for hosts without their own snippet memory: the shared
|
|
277
|
+
* edit snippet for each linked file the call targets, shown once per file per
|
|
278
|
+
* session, at most `maxFiles` per call.
|
|
279
|
+
*/
|
|
280
|
+
export function editKnowledgeContext(input) {
|
|
281
|
+
const snippets = [];
|
|
282
|
+
for (const relativePath of new Set(input.relativePaths)) {
|
|
283
|
+
if (snippets.length >= (input.maxFiles ?? 3))
|
|
284
|
+
break;
|
|
285
|
+
const symbols = input.symbolsFor(relativePath);
|
|
286
|
+
if (symbols.length === 0)
|
|
287
|
+
continue;
|
|
288
|
+
const snippet = fileKnowledgeSnippet({
|
|
289
|
+
relativePath,
|
|
290
|
+
symbols,
|
|
291
|
+
surface: "edit",
|
|
292
|
+
focus: input.focusFor?.(relativePath),
|
|
293
|
+
summarize: input.summarize,
|
|
294
|
+
maxChars: input.maxChars,
|
|
295
|
+
});
|
|
296
|
+
if (!snippet)
|
|
297
|
+
continue;
|
|
298
|
+
const key = `${input.sessionId ?? ""}\u0000edit\u0000${relativePath}`;
|
|
299
|
+
if (!claimSnippetSlot(input.stateDir, key))
|
|
300
|
+
continue;
|
|
301
|
+
snippets.push(snippet);
|
|
302
|
+
}
|
|
303
|
+
return snippets.length > 0 ? snippets.join("\n\n") : undefined;
|
|
304
|
+
}
|
|
305
|
+
// implements REQ-agent-core-edit-snippets, REQ-codex-kibi-plugin-v1, REQ-zcode-kibi-plugin-v1
|
|
306
|
+
/**
|
|
307
|
+
* Claim the one-time slot for `key` under `stateDir`: true the first time a
|
|
308
|
+
* key is claimed, false afterwards. The marker is created exclusively, so
|
|
309
|
+
* hooks for parallel tool calls never both show the same snippet. Without a
|
|
310
|
+
* state directory every call is a first time.
|
|
311
|
+
*/
|
|
312
|
+
export function claimSnippetSlot(stateDir, key) {
|
|
313
|
+
if (!stateDir)
|
|
314
|
+
return true;
|
|
315
|
+
const marker = path.join(stateDir, "shown-snippets", createHash("sha256").update(key).digest("hex").slice(0, 32));
|
|
316
|
+
try {
|
|
317
|
+
fs.mkdirSync(path.dirname(marker), { recursive: true });
|
|
318
|
+
fs.writeFileSync(marker, "", { flag: "wx" });
|
|
319
|
+
return true;
|
|
320
|
+
}
|
|
321
|
+
catch {
|
|
322
|
+
// An existing marker means the snippet was already shown; an unwritable
|
|
323
|
+
// state directory also stays quiet rather than repeating the snippet on
|
|
324
|
+
// every call.
|
|
325
|
+
return false;
|
|
326
|
+
}
|
|
327
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "kibi-agent-core",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.2.1",
|
|
4
4
|
"description": "Shared low-latency hook helpers for Kibi agent adapters",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./dist/index.js",
|
|
@@ -10,6 +10,10 @@
|
|
|
10
10
|
"types": "./dist/index.d.ts",
|
|
11
11
|
"default": "./dist/index.js"
|
|
12
12
|
},
|
|
13
|
+
"./hook-usage-log": {
|
|
14
|
+
"types": "./dist/hook-usage-log.d.ts",
|
|
15
|
+
"default": "./dist/hook-usage-log.js"
|
|
16
|
+
},
|
|
13
17
|
"./kb-mcp-tools": {
|
|
14
18
|
"types": "./dist/kb-mcp-tools.d.ts",
|
|
15
19
|
"default": "./dist/kb-mcp-tools.js"
|
|
@@ -21,6 +25,10 @@
|
|
|
21
25
|
"./path-policy": {
|
|
22
26
|
"types": "./dist/path-policy.d.ts",
|
|
23
27
|
"default": "./dist/path-policy.js"
|
|
28
|
+
},
|
|
29
|
+
"./snippets": {
|
|
30
|
+
"types": "./dist/snippets.d.ts",
|
|
31
|
+
"default": "./dist/snippets.js"
|
|
24
32
|
}
|
|
25
33
|
},
|
|
26
34
|
"files": ["dist", "CHANGELOG.md"],
|
|
@@ -34,9 +42,14 @@
|
|
|
34
42
|
},
|
|
35
43
|
"license": "AGPL-3.0-or-later",
|
|
36
44
|
"author": "Piotr Franczyk",
|
|
45
|
+
"homepage": "https://looted.github.io/kibi/",
|
|
37
46
|
"repository": {
|
|
38
47
|
"type": "git",
|
|
39
|
-
"url": "https://github.com/Looted/kibi.git"
|
|
48
|
+
"url": "https://github.com/Looted/kibi.git",
|
|
49
|
+
"directory": "packages/agent-core"
|
|
50
|
+
},
|
|
51
|
+
"bugs": {
|
|
52
|
+
"url": "https://github.com/Looted/kibi/issues"
|
|
40
53
|
},
|
|
41
54
|
"publishConfig": {
|
|
42
55
|
"access": "public"
|