kibi-agent-core 0.5.0 → 0.7.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 CHANGED
@@ -1,5 +1,26 @@
1
1
  # kibi-agent-core
2
2
 
3
+ ## 0.7.0
4
+
5
+ ### Minor Changes
6
+
7
+ - b9ec91d: The Claude Code hooks now act on recorded decisions while the work happens, not only after it.
8
+ - **Firm requirements are confirmed before the edit.** When an `Edit`, `MultiEdit`, `Write` or in-place shell write lands in a symbol that implements a current firm requirement (marked `firmness: firm`, or written by a person rather than an agent), the `PreToolUse` hook returns `permissionDecision: "ask"` with the symbol and requirement in the prompt, so the person decides before the code changes. Once an edit is allowed, that requirement is not asked about again in the session; a declined edit is asked again. The agent is told that a declined edit means the requirement stands. In a `claude -p` run with nobody to answer, Claude Code refuses the edit and the agent reads the reason. Set `KIBI_CONFIRM_FIRM_EDITS=0` to turn the prompt off.
9
+ - **Stated rules are captured.** A new `UserPromptSubmit` hook tells the agent, when a prompt reads as a rule ("must", "never", "from now on"), that recording it with `kb_upsert` is part of the change. At `Stop`, a session that changed source files and wrote nothing to the knowledge base is asked once to record the decision or say there was none.
10
+ - **An unlinked file is no longer an all-clear.** The discovery note for a file no symbol links ranks candidate requirements by the latest prompt's words as well as the path's, and, when nothing matches, says that nothing recorded is known to cover the file and that a decision made for it is recorded before the work is reported done (Codex shows the same note).
11
+
12
+ `kibi-agent-core`: `requestTopicWords`; `rankRequirementCandidates` and `requirementCandidatesForPath` take optional request words (a requirement sharing only request words needs two of them).
13
+
14
+ ## 0.6.0
15
+
16
+ ### Minor Changes
17
+
18
+ - d960810: A file with no linked symbols is no longer read as a file no rule covers. Before such a file is edited, the Claude Code and Codex hooks name the current requirements whose titles share the file's path words, and a `kb_check` scoped to the file reports the same candidates, so an agent changing `coach-dashboard.html` is pointed at the coach inbox requirement instead of concluding there is no knowledge-base impact. Agents are also told that recording the behavior a change adds is part of finishing it and needs no permission.
19
+ - `kibi-agent-core`: new `requirement-candidates` module (`pathTopicWords`, `rankRequirementCandidates`, `requirementCandidatesForPath`); `unownedSourceNote` takes the candidates, says that no link does not mean no rule, and drops candidates rather than exceed the snippet budget.
20
+ - `kibi-cli`: an impact-enabled `kb_check` emits `source_file_unowned` (`info`, non-blocking) for each file in `sourceFiles` that no manifest symbol links to a requirement, including files Kibi does not extract symbols from (HTML templates), with `evidence.candidates`. The unlinked-symbol `symbol_semantic_review_needed` suggestion now says recording the behavior needs no permission.
21
+ - `kibi-claude`, `kibi-codex`: the unlinked-file edit note names candidate requirements. The Claude Code session-start context no longer says unlinked files get no context, and says recording new behavior needs no permission; ZCode's session-start context says the same.
22
+ - `kibi-usage` skill 2.19.0: record new behavior without asking; confirm the candidates an unlinked-file note names.
23
+
3
24
  ## 0.5.0
4
25
 
5
26
  ### Minor Changes
package/dist/index.d.ts CHANGED
@@ -3,3 +3,4 @@ export * from "./kb-mcp-tools.js";
3
3
  export * from "./knowledge-index.js";
4
4
  export * from "./path-policy.js";
5
5
  export * from "./snippets.js";
6
+ export * from "./requirement-candidates.js";
package/dist/index.js CHANGED
@@ -3,3 +3,4 @@ export * from "./kb-mcp-tools.js";
3
3
  export * from "./knowledge-index.js";
4
4
  export * from "./path-policy.js";
5
5
  export * from "./snippets.js";
6
+ export * from "./requirement-candidates.js";
@@ -0,0 +1,33 @@
1
+ export declare const MAX_REQUIREMENT_CANDIDATES = 3;
2
+ /**
3
+ * The topic words of a workspace path: its directory and file-name words,
4
+ * without layout, language and role words (`src/app/pages/coach-dashboard/
5
+ * coach-dashboard.html` -> `coach`, `dashboard`).
6
+ */
7
+ export declare function pathTopicWords(relativePath: string): string[];
8
+ export type RequirementHead = {
9
+ id: string;
10
+ title: string;
11
+ status?: string;
12
+ };
13
+ /**
14
+ * Topic words of the person's request (`Users must not delete the last
15
+ * owner` -> `user`, `delete`, `last`, `owner`), for ranking requirements an
16
+ * unlinked file's path alone does not reach.
17
+ */
18
+ export declare function requestTopicWords(text: string, limit?: number): string[];
19
+ /**
20
+ * Current requirements ranked by how many of the path's topic words their
21
+ * title shares, then by how many of the request's topic words it shares, then
22
+ * by how often the title uses them, then by id. A requirement sharing a path
23
+ * word is returned; one sharing only request words needs two of them, since a
24
+ * request names many words and one in common is chance.
25
+ */
26
+ export declare function rankRequirementCandidates(relativePath: string, requirements: readonly RequirementHead[], limit?: number, requestWords?: readonly string[]): RequirementHead[];
27
+ /**
28
+ * Candidate requirements for a file no symbol links, read from the canonical
29
+ * requirement documents (`.kb/requirements/*.md`), ranked by the path's words
30
+ * and, when given, the request's. Empty when the KB has none or is too large
31
+ * to scan from a hook.
32
+ */
33
+ export declare function requirementCandidatesForPath(workspaceRoot: string, relativePath: string, limit?: number, requestWords?: readonly string[]): RequirementHead[];
@@ -0,0 +1,311 @@
1
+ // implements REQ-kb-unowned-file-candidates
2
+ import fs from "node:fs";
3
+ import path from "node:path";
4
+ /**
5
+ * Requirements that may govern a file no symbol links yet, found by the words
6
+ * its path shares with their titles. A file without links is not a file
7
+ * without rules: `coach-dashboard.html` may implement "The coach inbox must
8
+ * list the analysis requests assigned to the coach." The match is lexical and
9
+ * cheap enough for a hook; it names candidates to confirm, never owners.
10
+ */
11
+ const REQUIREMENTS_DIR = path.join(".kb", "requirements");
12
+ /** Bytes of each requirement read for its front matter. */
13
+ const HEAD_BYTES = 4096;
14
+ /** A KB larger than this is not scanned from a hook. */
15
+ const MAX_SCANNED = 2000;
16
+ export const MAX_REQUIREMENT_CANDIDATES = 3;
17
+ /** Path words that name a layout, a language or a role, not a behavior. */
18
+ const LAYOUT_WORDS = new Set([
19
+ "src",
20
+ "app",
21
+ "apps",
22
+ "lib",
23
+ "libs",
24
+ "source",
25
+ "page",
26
+ "pages",
27
+ "component",
28
+ "components",
29
+ "view",
30
+ "views",
31
+ "screen",
32
+ "screens",
33
+ "package",
34
+ "packages",
35
+ "main",
36
+ "index",
37
+ "shared",
38
+ "common",
39
+ "core",
40
+ "util",
41
+ "utils",
42
+ "helper",
43
+ "helpers",
44
+ "public",
45
+ "static",
46
+ "asset",
47
+ "assets",
48
+ "web",
49
+ "client",
50
+ "server",
51
+ "frontend",
52
+ "backend",
53
+ "module",
54
+ "modules",
55
+ "feature",
56
+ "features",
57
+ "internal",
58
+ "service",
59
+ "services",
60
+ "test",
61
+ "tests",
62
+ "spec",
63
+ "specs",
64
+ "dist",
65
+ "build",
66
+ "html",
67
+ "htm",
68
+ "tsx",
69
+ "jsx",
70
+ "mjs",
71
+ "cjs",
72
+ "vue",
73
+ "svelte",
74
+ "css",
75
+ "scss",
76
+ "less",
77
+ "json",
78
+ "yaml",
79
+ "yml",
80
+ "java",
81
+ "swift",
82
+ "php",
83
+ ]);
84
+ /** Words of a title that carry no topic. */
85
+ const FILLER_WORDS = new Set([
86
+ "the",
87
+ "and",
88
+ "for",
89
+ "with",
90
+ "must",
91
+ "should",
92
+ "shall",
93
+ "may",
94
+ "can",
95
+ "are",
96
+ "is",
97
+ "its",
98
+ "their",
99
+ "that",
100
+ "this",
101
+ "from",
102
+ "into",
103
+ "when",
104
+ "each",
105
+ "every",
106
+ "all",
107
+ "not",
108
+ "req",
109
+ "auto",
110
+ ]);
111
+ /** Lowercase word stems of an identifier, path segment or title. */
112
+ function words(text) {
113
+ return text
114
+ .replace(/([a-z0-9])([A-Z])/g, "$1 $2")
115
+ .toLowerCase()
116
+ .split(/[^a-z0-9]+/)
117
+ .filter((word) => word.length >= 3 && !/\d/.test(word))
118
+ .map((word) => word.length > 4 && word.endsWith("s") && !word.endsWith("ss")
119
+ ? word.slice(0, -1)
120
+ : word);
121
+ }
122
+ // implements REQ-kb-unowned-file-candidates
123
+ /**
124
+ * The topic words of a workspace path: its directory and file-name words,
125
+ * without layout, language and role words (`src/app/pages/coach-dashboard/
126
+ * coach-dashboard.html` -> `coach`, `dashboard`).
127
+ */
128
+ export function pathTopicWords(relativePath) {
129
+ const segments = relativePath.split(/[\\/]/).filter(Boolean);
130
+ const last = segments.pop() ?? "";
131
+ const stem = last.includes(".") ? last.slice(0, last.indexOf(".")) : last;
132
+ const roles = last.includes(".") ? last.slice(last.indexOf(".") + 1) : "";
133
+ const found = [...segments, stem, roles]
134
+ .flatMap(words)
135
+ .filter((word) => !LAYOUT_WORDS.has(word) && !FILLER_WORDS.has(word));
136
+ return [...new Set(found)];
137
+ }
138
+ function unquote(value) {
139
+ const trimmed = value.trim();
140
+ return /^(["']).*\1$/.test(trimmed) ? trimmed.slice(1, -1) : trimmed;
141
+ }
142
+ function readHead(file) {
143
+ let head;
144
+ try {
145
+ const descriptor = fs.openSync(file, "r");
146
+ try {
147
+ const buffer = Buffer.alloc(HEAD_BYTES);
148
+ const bytes = fs.readSync(descriptor, buffer, 0, buffer.length, 0);
149
+ head = buffer.subarray(0, bytes).toString("utf8");
150
+ }
151
+ finally {
152
+ fs.closeSync(descriptor);
153
+ }
154
+ }
155
+ catch {
156
+ return undefined;
157
+ }
158
+ const lines = head.split(/\r?\n/);
159
+ if (lines[0]?.trim() !== "---")
160
+ return undefined;
161
+ const fields = {};
162
+ for (const line of lines.slice(1)) {
163
+ if (line.trim() === "---")
164
+ break;
165
+ const match = /^(id|title|status):\s*(.+)$/.exec(line);
166
+ if (match?.[1] && match[2])
167
+ fields[match[1]] = unquote(match[2]);
168
+ }
169
+ if (!fields.id || !fields.title)
170
+ return undefined;
171
+ return {
172
+ id: fields.id,
173
+ title: fields.title,
174
+ ...(fields.status ? { status: fields.status } : {}),
175
+ };
176
+ }
177
+ const RETIRED_STATUS = /supersed|deprecat|reject|obsolete|retired|archived|removed/i;
178
+ // implements REQ-claude-request-topic-candidates
179
+ /**
180
+ * Topic words of the person's request (`Users must not delete the last
181
+ * owner` -> `user`, `delete`, `last`, `owner`), for ranking requirements an
182
+ * unlinked file's path alone does not reach.
183
+ */
184
+ export function requestTopicWords(text, limit = 40) {
185
+ const found = words(text).filter((word) => !FILLER_WORDS.has(word) && !REQUEST_FILLER_WORDS.has(word));
186
+ return [...new Set(found)].slice(0, limit);
187
+ }
188
+ /** Request words that carry no topic of their own. */
189
+ const REQUEST_FILLER_WORDS = new Set([
190
+ "please",
191
+ "could",
192
+ "would",
193
+ "you",
194
+ "your",
195
+ "our",
196
+ "have",
197
+ "has",
198
+ "make",
199
+ "sure",
200
+ "change",
201
+ "update",
202
+ "add",
203
+ "fix",
204
+ "now",
205
+ "need",
206
+ "want",
207
+ "like",
208
+ "also",
209
+ "just",
210
+ "then",
211
+ "than",
212
+ "there",
213
+ "here",
214
+ "what",
215
+ "which",
216
+ "why",
217
+ "how",
218
+ "will",
219
+ "use",
220
+ "using",
221
+ "new",
222
+ "file",
223
+ "code",
224
+ "never",
225
+ "always",
226
+ "only",
227
+ "any",
228
+ "but",
229
+ "they",
230
+ "them",
231
+ "was",
232
+ "were",
233
+ "been",
234
+ "being",
235
+ "does",
236
+ "don",
237
+ "doesn",
238
+ "let",
239
+ "get",
240
+ "set",
241
+ "one",
242
+ "out",
243
+ "about",
244
+ "over",
245
+ "same",
246
+ "still",
247
+ "too",
248
+ "very",
249
+ ]);
250
+ // implements REQ-kb-unowned-file-candidates, REQ-claude-request-topic-candidates
251
+ /**
252
+ * Current requirements ranked by how many of the path's topic words their
253
+ * title shares, then by how many of the request's topic words it shares, then
254
+ * by how often the title uses them, then by id. A requirement sharing a path
255
+ * word is returned; one sharing only request words needs two of them, since a
256
+ * request names many words and one in common is chance.
257
+ */
258
+ export function rankRequirementCandidates(relativePath, requirements, limit = MAX_REQUIREMENT_CANDIDATES, requestWords = []) {
259
+ const topic = new Set(pathTopicWords(relativePath));
260
+ const request = new Set(requestWords.filter((word) => !topic.has(word)));
261
+ if (topic.size === 0 && request.size === 0)
262
+ return [];
263
+ const scored = [];
264
+ for (const head of requirements) {
265
+ if (head.status !== undefined && RETIRED_STATUS.test(head.status))
266
+ continue;
267
+ const titleWords = words(head.title);
268
+ const pathHits = titleWords.filter((word) => topic.has(word));
269
+ const requestHits = titleWords.filter((word) => request.has(word));
270
+ const shared = new Set(pathHits).size;
271
+ const asked = new Set(requestHits).size;
272
+ if (shared > 0 || asked >= 2) {
273
+ scored.push({
274
+ head,
275
+ shared,
276
+ asked,
277
+ uses: pathHits.length + requestHits.length,
278
+ });
279
+ }
280
+ }
281
+ scored.sort((a, b) => b.shared - a.shared ||
282
+ b.asked - a.asked ||
283
+ b.uses - a.uses ||
284
+ a.head.id.localeCompare(b.head.id));
285
+ return scored.slice(0, limit).map((entry) => entry.head);
286
+ }
287
+ // implements REQ-kb-unowned-file-candidates, REQ-claude-request-topic-candidates
288
+ /**
289
+ * Candidate requirements for a file no symbol links, read from the canonical
290
+ * requirement documents (`.kb/requirements/*.md`), ranked by the path's words
291
+ * and, when given, the request's. Empty when the KB has none or is too large
292
+ * to scan from a hook.
293
+ */
294
+ export function requirementCandidatesForPath(workspaceRoot, relativePath, limit = MAX_REQUIREMENT_CANDIDATES, requestWords = []) {
295
+ if (pathTopicWords(relativePath).length === 0 && requestWords.length < 2)
296
+ return [];
297
+ const directory = path.join(workspaceRoot, REQUIREMENTS_DIR);
298
+ let names;
299
+ try {
300
+ names = fs.readdirSync(directory).filter((name) => name.endsWith(".md"));
301
+ }
302
+ catch {
303
+ return [];
304
+ }
305
+ if (names.length > MAX_SCANNED)
306
+ return [];
307
+ const heads = names
308
+ .map((name) => readHead(path.join(directory, name)))
309
+ .filter((head) => head !== undefined);
310
+ return rankRequirementCandidates(relativePath, heads, limit, requestWords);
311
+ }
@@ -1,4 +1,5 @@
1
1
  import { type EntitySummary, type IndexedSymbol } from "./knowledge-index.js";
2
+ import type { RequirementHead } from "./requirement-candidates.js";
2
3
  export declare const MAX_SNIPPET_CHARS = 1200;
3
4
  export type LineRange = {
4
5
  start: number;
@@ -32,8 +33,12 @@ export declare function isRetiredStatus(status: string | undefined): boolean;
32
33
  * an agent's choice.
33
34
  */
34
35
  export declare function describeEntity(summary: EntitySummary): string;
35
- /** Discovery note for a source file none of whose symbols implement a requirement. */
36
- export declare function unownedSourceNote(relativePath: string): string;
36
+ /**
37
+ * Discovery note for a source file none of whose symbols implement a
38
+ * requirement. No link is not no rule, so the note names the current
39
+ * requirements whose titles share the path's words, when there are any.
40
+ */
41
+ export declare function unownedSourceNote(relativePath: string, candidates?: readonly RequirementHead[]): string;
37
42
  /** Shown instead of the discovery note when the manifest itself could not be read. */
38
43
  export declare function unreadableManifestNote(relativePath: string): string;
39
44
  /** Link types through which a requirement is grounded in semantic facts. */
package/dist/snippets.js CHANGED
@@ -125,12 +125,37 @@ export function describeEntity(summary) {
125
125
  function jsonString(value) {
126
126
  return JSON.stringify(value);
127
127
  }
128
- // implements REQ-claude-code-kibi-plugin-v1, REQ-codex-kibi-plugin-v1
129
- /** Discovery note for a source file none of whose symbols implement a requirement. */
130
- export function unownedSourceNote(relativePath) {
128
+ // implements REQ-claude-code-kibi-plugin-v1, REQ-codex-kibi-plugin-v1, REQ-kb-unowned-file-candidates, REQ-claude-request-topic-candidates
129
+ /**
130
+ * Discovery note for a source file none of whose symbols implement a
131
+ * requirement. No link is not no rule, so the note names the current
132
+ * requirements whose titles share the path's words, when there are any.
133
+ */
134
+ export function unownedSourceNote(relativePath, candidates = []) {
135
+ const head = `Kibi: no symbol in ${relativePath} is linked to a requirement yet; that does not mean no recorded rule covers the behavior it implements.`;
136
+ const search = `kb_search({query:"<behavior being changed>", sourceLocations:[{path:${jsonString(relativePath)}}]}) surfaces requirements that may already describe it; new behavior is recorded with kb_upsert (requirement + scenario + symbol implements link) as part of the change.`;
137
+ // Fewer candidates rather than a note over the snippet budget.
138
+ for (let shown = candidates.length; shown > 0; shown--) {
139
+ const named = candidates
140
+ .slice(0, shown)
141
+ .map((candidate) => `${candidate.id}: ${truncate(candidate.title, MAX_TITLE)}`);
142
+ const note = [
143
+ head,
144
+ `Requirements whose titles share words with this path or the request: ${named.join("; ")}. kb_query({id:${jsonString(candidates[0]?.id ?? "")}}) shows whether one describes the behavior being changed; one that does is traced by an implements link from the symbol that implements it (for markup, the component that renders it).`,
145
+ search,
146
+ ].join("\n");
147
+ if (note.length <= MAX_SNIPPET_CHARS)
148
+ return note;
149
+ }
150
+ if (candidates.length > 0)
151
+ return [head, search].join("\n");
152
+ // implements REQ-claude-request-topic-candidates
153
+ // Nothing found is not an all-clear: say that nothing recorded is known to
154
+ // cover the file, and that what this change decides for it is recorded now.
131
155
  return [
132
- `Kibi: no symbol in ${relativePath} is linked to a requirement yet.`,
133
- `kb_search({query:"<behavior being changed>", sourceLocations:[{path:${jsonString(relativePath)}}]}) surfaces requirements that may already describe it; new behavior is recorded with kb_upsert (requirement + symbol implements link).`,
156
+ head,
157
+ "No requirement title matches this file, so nothing recorded is known to cover it.",
158
+ `kb_search({query:"<behavior being changed>", sourceLocations:[{path:${jsonString(relativePath)}}]}) finds a rule recorded under other words. A decision this change makes for this code (a rule the person states, a limit, who may do what) is recorded with kb_upsert (requirement + scenario + symbol implements link) before the work is reported done.`,
134
159
  ].join("\n");
135
160
  }
136
161
  // implements REQ-kibi-hooks-flow-manifest, REQ-claude-code-kibi-plugin-v1, REQ-codex-kibi-plugin-v1
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "kibi-agent-core",
3
- "version": "0.5.0",
3
+ "version": "0.7.0",
4
4
  "description": "Shared low-latency hook helpers for Kibi agent adapters",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",
@@ -26,6 +26,10 @@
26
26
  "types": "./dist/path-policy.d.ts",
27
27
  "default": "./dist/path-policy.js"
28
28
  },
29
+ "./requirement-candidates": {
30
+ "types": "./dist/requirement-candidates.d.ts",
31
+ "default": "./dist/requirement-candidates.js"
32
+ },
29
33
  "./snippets": {
30
34
  "types": "./dist/snippets.d.ts",
31
35
  "default": "./dist/snippets.js"