kibi-agent-core 0.6.0 → 0.8.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,27 @@
1
1
  # kibi-agent-core
2
2
 
3
+ ## 0.8.0
4
+
5
+ ### Minor Changes
6
+
7
+ - 00d2871: The Codex, Cursor and ZCode hooks now act on recorded decisions while the work happens, as the Claude Code hooks do.
8
+ - **Firm requirements before the edit.** When an edit changes code that implements a current firm requirement, the pre-edit hook tells the agent to ask the person before an edit that breaks that rule, naming the rule; once per requirement per session. These hosts cannot put the question to the person from a hook (Codex has no "ask", Cursor does not enforce one, and the ZCode adapter stays advisory), so this is a notice to the agent. Set `KIBI_CONFIRM_FIRM_EDITS=0` to turn it off.
9
+ - **Stated rules are captured.** A prompt that reads as a rule ("must", "never", "from now on") gets the note that recording it with `kb_upsert` is part of the change: through a new `UserPromptSubmit` hook in Codex and ZCode, and in Cursor through a new `beforeSubmitPrompt` hook whose note arrives with the next pre-edit message. When a session changed source files and wrote nothing with `kb_upsert`, the stop hook asks once for the decision to be recorded (Codex continues the turn with it; Cursor adds it to the stop follow-up; ZCode adds it to the Stop context).
10
+ - **Codex unlinked-file candidates** are ranked by the latest prompt's words as well as the path's.
11
+
12
+ `kibi-agent-core`: new `kibi-agent-core/decision-guard` entry with the host-neutral pieces (`firmRequirementsTouched`, `firmEditNotice`, `confirmFirmEdits`, the prompt and stop notes, and per-session markers). `kibi-claude` now imports its firm-edit and capture texts from it; behavior is unchanged.
13
+
14
+ ## 0.7.0
15
+
16
+ ### Minor Changes
17
+
18
+ - b9ec91d: The Claude Code hooks now act on recorded decisions while the work happens, not only after it.
19
+ - **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.
20
+ - **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.
21
+ - **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).
22
+
23
+ `kibi-agent-core`: `requestTopicWords`; `rankRequirementCandidates` and `requirementCandidatesForPath` take optional request words (a requirement sharing only request words needs two of them).
24
+
3
25
  ## 0.6.0
4
26
 
5
27
  ### Minor Changes
@@ -0,0 +1,90 @@
1
+ import type { EntitySummary, IndexedSymbol } from "./knowledge-index.js";
2
+ import { type LineRange, type Summarize } from "./snippets.js";
3
+ /**
4
+ * Host-neutral pieces of the hooks that act on recorded decisions while the
5
+ * work happens: which firm requirements an edit changes, the confirmation and
6
+ * agent texts for it, and the prompt-time and stop-time capture notes. Each
7
+ * host adapter decides how to put the confirmation to the person.
8
+ */
9
+ /** Set to `0`, `false` or `off` to stop asking before edits of firm requirements. */
10
+ export declare const CONFIRM_FIRM_EDITS_ENV = "KIBI_CONFIRM_FIRM_EDITS";
11
+ /** False when the operator turned the firm-edit confirmation off. */
12
+ export declare function confirmFirmEdits(env: NodeJS.ProcessEnv): boolean;
13
+ /**
14
+ * The current firm requirements an edit changes that are not in `confirmed`:
15
+ * those implemented by the symbols the edit lands in, or by every symbol of
16
+ * the file for a whole-file write. An edit whose location is unknown, or
17
+ * outside every symbol, changes none.
18
+ */
19
+ export declare function firmRequirementsTouched(input: {
20
+ symbols: readonly IndexedSymbol[];
21
+ focus: readonly LineRange[] | undefined;
22
+ wholeFile: boolean;
23
+ summarize: Summarize;
24
+ confirmed?: ReadonlySet<string>;
25
+ }): {
26
+ ids: string[];
27
+ symbol: IndexedSymbol | undefined;
28
+ };
29
+ /**
30
+ * The permission-prompt text for an edit of a firm requirement's code: what
31
+ * the edit changes, the requirements, and what allowing it means. The person
32
+ * reads it, so it names the rule rather than the mechanism.
33
+ */
34
+ export declare function firmEditConfirmation(where: string, requirements: readonly EntitySummary[]): string;
35
+ /** What the agent is told when its edit was put to the person. */
36
+ export declare function firmEditContext(ids: readonly string[]): string;
37
+ /** The phrase that makes `prompt` read as a rule, or undefined. */
38
+ export declare function statedRule(prompt: string): string | undefined;
39
+ /** Prompt-time note for a request that reads as a rule or decision. */
40
+ export declare function rulePromptNote(phrase: string): string;
41
+ /**
42
+ * Stop-time note for a session that changed source files and wrote nothing
43
+ * to the knowledge base.
44
+ */
45
+ export declare function captureReminder(rulePrompt: boolean): string;
46
+ /**
47
+ * Pre-edit context for hosts whose pre-tool hook cannot put a call to the
48
+ * person: the edit is named as a change to the person's rule, and the agent
49
+ * is told to ask before making it unless it keeps the rule.
50
+ */
51
+ export declare function firmEditNotice(where: string, requirements: readonly EntitySummary[]): string;
52
+ /** Where one host session's decision-guard markers live. */
53
+ export type DecisionSession = {
54
+ stateDir: string | undefined;
55
+ sessionId: string | undefined;
56
+ };
57
+ /** Session markers the decision guard keeps. */
58
+ export type DecisionFlag = "source-edited" | "kb-recorded" | "rule-prompt" | "capture-asked";
59
+ /** Set a session marker; best effort. */
60
+ export declare function markDecisionFlag(session: DecisionSession, flag: DecisionFlag): void;
61
+ export declare function hasDecisionFlag(session: DecisionSession, flag: DecisionFlag): boolean;
62
+ /**
63
+ * True the first time `flag` is claimed in the session. Without a state
64
+ * directory nothing can be remembered, so nothing is claimed.
65
+ */
66
+ export declare function claimDecisionFlag(session: DecisionSession, flag: DecisionFlag): boolean;
67
+ /**
68
+ * Remember a submitted prompt's topic words (for unlinked-file candidates)
69
+ * and whether it read as a rule. Returns the rule phrase, if any.
70
+ */
71
+ export declare function recordRequest(session: DecisionSession, prompt: string): string | undefined;
72
+ /** Topic words of the session's latest prompt, or none. */
73
+ export declare function requestWords(session: DecisionSession): string[];
74
+ /**
75
+ * Whether the stop should ask for the session's decision to be recorded:
76
+ * source changed, nothing was written with kb_upsert, and the session has
77
+ * not been asked yet (claiming the ask).
78
+ */
79
+ export declare function claimCaptureReminder(session: DecisionSession): boolean;
80
+ /** Firm requirements the agent was already told about this session. */
81
+ export declare function settledFirmRequirements(session: DecisionSession): Set<string>;
82
+ /** Remember that the agent was told about these firm requirements. */
83
+ export declare function settleFirmRequirements(session: DecisionSession, ids: readonly string[]): void;
84
+ /**
85
+ * Keep a prompt's rule note for a host whose prompt hook cannot add context,
86
+ * so the next hook that can speak to the agent delivers it.
87
+ */
88
+ export declare function holdRuleNote(session: DecisionSession, phrase: string): void;
89
+ /** The held rule note, removed so it is delivered once; or undefined. */
90
+ export declare function takeRuleNote(session: DecisionSession): string | undefined;
@@ -0,0 +1,274 @@
1
+ // implements REQ-claude-firm-edit-confirmation, REQ-claude-decision-capture-nudges, REQ-agent-hosts-decision-guard
2
+ import { createHash } from "node:crypto";
3
+ import fs from "node:fs";
4
+ import path from "node:path";
5
+ import { requestTopicWords } from "./requirement-candidates.js";
6
+ import { focusedSymbols, implementedRequirementIds, isRetiredStatus, } from "./snippets.js";
7
+ /**
8
+ * Host-neutral pieces of the hooks that act on recorded decisions while the
9
+ * work happens: which firm requirements an edit changes, the confirmation and
10
+ * agent texts for it, and the prompt-time and stop-time capture notes. Each
11
+ * host adapter decides how to put the confirmation to the person.
12
+ */
13
+ /** Set to `0`, `false` or `off` to stop asking before edits of firm requirements. */
14
+ export const CONFIRM_FIRM_EDITS_ENV = "KIBI_CONFIRM_FIRM_EDITS";
15
+ // implements REQ-claude-firm-edit-confirmation, REQ-agent-hosts-decision-guard
16
+ /** False when the operator turned the firm-edit confirmation off. */
17
+ export function confirmFirmEdits(env) {
18
+ const value = env[CONFIRM_FIRM_EDITS_ENV]?.trim().toLowerCase();
19
+ return !(value === "0" || value === "false" || value === "off");
20
+ }
21
+ // implements REQ-claude-firm-edit-confirmation, REQ-agent-hosts-decision-guard
22
+ /**
23
+ * The current firm requirements an edit changes that are not in `confirmed`:
24
+ * those implemented by the symbols the edit lands in, or by every symbol of
25
+ * the file for a whole-file write. An edit whose location is unknown, or
26
+ * outside every symbol, changes none.
27
+ */
28
+ export function firmRequirementsTouched(input) {
29
+ const touched = input.wholeFile
30
+ ? input.symbols
31
+ : focusedSymbols(input.symbols, input.focus);
32
+ const ids = implementedRequirementIds(touched).filter((id) => {
33
+ if (input.confirmed?.has(id))
34
+ return false;
35
+ const summary = input.summarize(id);
36
+ return summary.firmness === "firm" && !isRetiredStatus(summary.status);
37
+ });
38
+ return { ids, symbol: input.wholeFile ? undefined : touched[0] };
39
+ }
40
+ // implements REQ-claude-firm-edit-confirmation
41
+ /**
42
+ * The permission-prompt text for an edit of a firm requirement's code: what
43
+ * the edit changes, the requirements, and what allowing it means. The person
44
+ * reads it, so it names the rule rather than the mechanism.
45
+ */
46
+ export function firmEditConfirmation(where, requirements) {
47
+ // Every one is firm, so each is named by id and title only.
48
+ const shown = requirements
49
+ .slice(0, 2)
50
+ .map((summary) => summary.title ? `${summary.id}: ${summary.title}` : summary.id);
51
+ const more = requirements.length > shown.length
52
+ ? ` (+${requirements.length - shown.length} more)`
53
+ : "";
54
+ const noun = requirements.length === 1 ? "a firm requirement" : "firm requirements";
55
+ return `Kibi: this edit changes ${where}, which implements ${noun} recorded in the knowledge base: ${shown.join("; ")}${more}. Allow it if the change keeps ${requirements.length === 1 ? "that requirement" : "them"}, or if you mean to change ${requirements.length === 1 ? "the requirement" : "them"} too.`;
56
+ }
57
+ // implements REQ-claude-firm-edit-confirmation
58
+ /** What the agent is told when its edit was put to the person. */
59
+ export function firmEditContext(ids) {
60
+ const named = ids.slice(0, 2).join(", ");
61
+ return [
62
+ `Kibi asked the person to confirm this edit because it changes code that implements firm ${ids.length === 1 ? "requirement" : "requirements"} ${named}.`,
63
+ "If the edit was declined or could not be confirmed, the requirement stands: name it and ask the person how to proceed, rather than reaching the same change another way. If it was allowed and the change alters what the requirement says, the requirement is updated with kb_upsert in this change.",
64
+ ].join("\n");
65
+ }
66
+ /**
67
+ * Phrases that make a request read as a rule or decision. Matching is
68
+ * deliberately loose: the note it triggers only asks whether there is
69
+ * something to record.
70
+ */
71
+ const RULE_PHRASES = /\b(?:from now on|going forward|must(?:n['’]t| not)?|should (?:always|never)|never|always|is not allowed|are not allowed|not be allowed|only (?:allow|admins?|owners?)|is required|are required|has to|have to)\b/i;
72
+ // implements REQ-claude-decision-capture-nudges, REQ-agent-hosts-decision-guard
73
+ /** The phrase that makes `prompt` read as a rule, or undefined. */
74
+ export function statedRule(prompt) {
75
+ const match = RULE_PHRASES.exec(prompt);
76
+ return match ? match[0].toLowerCase() : undefined;
77
+ }
78
+ // implements REQ-claude-decision-capture-nudges, REQ-agent-hosts-decision-guard
79
+ /** Prompt-time note for a request that reads as a rule or decision. */
80
+ export function rulePromptNote(phrase) {
81
+ return [
82
+ `Kibi: this request reads as a rule or decision (it says "${phrase}").`,
83
+ "A rule the person states is project knowledge: kb_search shows whether a recorded requirement already says something about it (changing a firm one is a question for the person first), and a new or changed rule is recorded with kb_upsert (a requirement, firm because the person stated it, with the implements link from the code that enforces it) as part of this change, not after it.",
84
+ ].join("\n");
85
+ }
86
+ // implements REQ-claude-decision-capture-nudges, REQ-agent-hosts-decision-guard
87
+ /**
88
+ * Stop-time note for a session that changed source files and wrote nothing
89
+ * to the knowledge base.
90
+ */
91
+ export function captureReminder(rulePrompt) {
92
+ return [
93
+ rulePrompt
94
+ ? "Kibi: a request this session read as a rule or decision, the session changed source files, and nothing was recorded in the knowledge base."
95
+ : "Kibi: this session changed source files and recorded nothing in the knowledge base.",
96
+ "A decision the person stated or this change made (a rule, a limit, a default, who may do what) is recorded with kb_upsert as a requirement linked to the code that implements it before the work is reported done; when there was none, the final report says so in one line.",
97
+ ].join("\n");
98
+ }
99
+ // implements REQ-agent-hosts-decision-guard
100
+ /**
101
+ * Pre-edit context for hosts whose pre-tool hook cannot put a call to the
102
+ * person: the edit is named as a change to the person's rule, and the agent
103
+ * is told to ask before making it unless it keeps the rule.
104
+ */
105
+ export function firmEditNotice(where, requirements) {
106
+ const shown = requirements
107
+ .slice(0, 2)
108
+ .map((summary) => summary.title ? `${summary.id}: ${summary.title}` : summary.id);
109
+ const more = requirements.length > shown.length
110
+ ? ` (+${requirements.length - shown.length} more)`
111
+ : "";
112
+ const one = requirements.length === 1;
113
+ return [
114
+ `Kibi: this edit changes ${where}, which implements ${one ? "a firm requirement" : "firm requirements"}, a rule the person set: ${shown.join("; ")}${more}.`,
115
+ `Unless the edit keeps ${one ? "that rule" : "those rules"}, ask the person before making it and name the rule; change the requirement with kb_upsert in this change only when they say so.`,
116
+ ].join("\n");
117
+ }
118
+ function sessionPath(session, name) {
119
+ if (!session.stateDir)
120
+ return undefined;
121
+ const key = createHash("sha256")
122
+ .update(session.sessionId?.trim() || "unattributed")
123
+ .digest("hex")
124
+ .slice(0, 32);
125
+ return path.join(session.stateDir, "decision-guard", key, name);
126
+ }
127
+ function writeMarker(file, content, flag) {
128
+ fs.mkdirSync(path.dirname(file), { recursive: true });
129
+ fs.writeFileSync(file, content, { flag });
130
+ }
131
+ // implements REQ-agent-hosts-decision-guard
132
+ /** Set a session marker; best effort. */
133
+ export function markDecisionFlag(session, flag) {
134
+ const file = sessionPath(session, flag);
135
+ if (!file)
136
+ return;
137
+ try {
138
+ writeMarker(file, "", "w");
139
+ }
140
+ catch {
141
+ // Markers are best effort; a lost one means a note may repeat or skip.
142
+ }
143
+ }
144
+ // implements REQ-agent-hosts-decision-guard
145
+ export function hasDecisionFlag(session, flag) {
146
+ const file = sessionPath(session, flag);
147
+ return file !== undefined && fs.existsSync(file);
148
+ }
149
+ // implements REQ-agent-hosts-decision-guard
150
+ /**
151
+ * True the first time `flag` is claimed in the session. Without a state
152
+ * directory nothing can be remembered, so nothing is claimed.
153
+ */
154
+ export function claimDecisionFlag(session, flag) {
155
+ const file = sessionPath(session, flag);
156
+ if (!file)
157
+ return false;
158
+ try {
159
+ writeMarker(file, "", "wx");
160
+ return true;
161
+ }
162
+ catch {
163
+ return false;
164
+ }
165
+ }
166
+ // implements REQ-agent-hosts-decision-guard
167
+ /**
168
+ * Remember a submitted prompt's topic words (for unlinked-file candidates)
169
+ * and whether it read as a rule. Returns the rule phrase, if any.
170
+ */
171
+ export function recordRequest(session, prompt) {
172
+ const rule = statedRule(prompt);
173
+ const file = sessionPath(session, "request-words.json");
174
+ if (file) {
175
+ try {
176
+ writeMarker(file, JSON.stringify(requestTopicWords(prompt)), "w");
177
+ }
178
+ catch {
179
+ // Best effort.
180
+ }
181
+ }
182
+ if (rule !== undefined)
183
+ markDecisionFlag(session, "rule-prompt");
184
+ return rule;
185
+ }
186
+ // implements REQ-agent-hosts-decision-guard
187
+ /** Topic words of the session's latest prompt, or none. */
188
+ export function requestWords(session) {
189
+ const file = sessionPath(session, "request-words.json");
190
+ if (!file)
191
+ return [];
192
+ try {
193
+ const parsed = JSON.parse(fs.readFileSync(file, "utf8"));
194
+ return Array.isArray(parsed)
195
+ ? parsed.filter((word) => typeof word === "string")
196
+ : [];
197
+ }
198
+ catch {
199
+ return [];
200
+ }
201
+ }
202
+ // implements REQ-agent-hosts-decision-guard
203
+ /**
204
+ * Whether the stop should ask for the session's decision to be recorded:
205
+ * source changed, nothing was written with kb_upsert, and the session has
206
+ * not been asked yet (claiming the ask).
207
+ */
208
+ export function claimCaptureReminder(session) {
209
+ return (hasDecisionFlag(session, "source-edited") &&
210
+ !hasDecisionFlag(session, "kb-recorded") &&
211
+ claimDecisionFlag(session, "capture-asked"));
212
+ }
213
+ function readSettled(file) {
214
+ if (!file)
215
+ return [];
216
+ try {
217
+ const parsed = JSON.parse(fs.readFileSync(file, "utf8"));
218
+ return Array.isArray(parsed)
219
+ ? parsed.filter((id) => typeof id === "string")
220
+ : [];
221
+ }
222
+ catch {
223
+ return [];
224
+ }
225
+ }
226
+ // implements REQ-agent-hosts-decision-guard
227
+ /** Firm requirements the agent was already told about this session. */
228
+ export function settledFirmRequirements(session) {
229
+ return new Set(readSettled(sessionPath(session, "firm-settled.json")));
230
+ }
231
+ // implements REQ-agent-hosts-decision-guard
232
+ /** Remember that the agent was told about these firm requirements. */
233
+ export function settleFirmRequirements(session, ids) {
234
+ const file = sessionPath(session, "firm-settled.json");
235
+ if (!file)
236
+ return;
237
+ try {
238
+ writeMarker(file, JSON.stringify([...new Set([...readSettled(file), ...ids])]), "w");
239
+ }
240
+ catch {
241
+ // Best effort: a lost write means the notice may repeat.
242
+ }
243
+ }
244
+ // implements REQ-agent-hosts-decision-guard
245
+ /**
246
+ * Keep a prompt's rule note for a host whose prompt hook cannot add context,
247
+ * so the next hook that can speak to the agent delivers it.
248
+ */
249
+ export function holdRuleNote(session, phrase) {
250
+ const file = sessionPath(session, "rule-note");
251
+ if (!file)
252
+ return;
253
+ try {
254
+ writeMarker(file, rulePromptNote(phrase), "w");
255
+ }
256
+ catch {
257
+ // Best effort.
258
+ }
259
+ }
260
+ // implements REQ-agent-hosts-decision-guard
261
+ /** The held rule note, removed so it is delivered once; or undefined. */
262
+ export function takeRuleNote(session) {
263
+ const file = sessionPath(session, "rule-note");
264
+ if (!file)
265
+ return undefined;
266
+ try {
267
+ const note = fs.readFileSync(file, "utf8");
268
+ fs.rmSync(file, { force: true });
269
+ return note || undefined;
270
+ }
271
+ catch {
272
+ return undefined;
273
+ }
274
+ }
package/dist/index.d.ts CHANGED
@@ -1,3 +1,4 @@
1
+ export * from "./decision-guard.js";
1
2
  export * from "./hook-usage-log.js";
2
3
  export * from "./kb-mcp-tools.js";
3
4
  export * from "./knowledge-index.js";
package/dist/index.js CHANGED
@@ -1,3 +1,4 @@
1
+ export * from "./decision-guard.js";
1
2
  export * from "./hook-usage-log.js";
2
3
  export * from "./kb-mcp-tools.js";
3
4
  export * from "./knowledge-index.js";
@@ -10,15 +10,24 @@ export type RequirementHead = {
10
10
  title: string;
11
11
  status?: string;
12
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[];
13
19
  /**
14
20
  * Current requirements ranked by how many of the path's topic words their
15
- * title shares, then by how often the title uses them, then by id. Only
16
- * requirements sharing at least one word are returned.
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.
17
25
  */
18
- export declare function rankRequirementCandidates(relativePath: string, requirements: readonly RequirementHead[], limit?: number): RequirementHead[];
26
+ export declare function rankRequirementCandidates(relativePath: string, requirements: readonly RequirementHead[], limit?: number, requestWords?: readonly string[]): RequirementHead[];
19
27
  /**
20
28
  * Candidate requirements for a file no symbol links, read from the canonical
21
- * requirement documents (`.kb/requirements/*.md`). Empty when the KB has none
22
- * or is too large to scan from a hook.
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.
23
32
  */
24
- export declare function requirementCandidatesForPath(workspaceRoot: string, relativePath: string, limit?: number): RequirementHead[];
33
+ export declare function requirementCandidatesForPath(workspaceRoot: string, relativePath: string, limit?: number, requestWords?: readonly string[]): RequirementHead[];
@@ -175,38 +175,124 @@ function readHead(file) {
175
175
  };
176
176
  }
177
177
  const RETIRED_STATUS = /supersed|deprecat|reject|obsolete|retired|archived|removed/i;
178
- // implements REQ-kb-unowned-file-candidates
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
179
251
  /**
180
252
  * Current requirements ranked by how many of the path's topic words their
181
- * title shares, then by how often the title uses them, then by id. Only
182
- * requirements sharing at least one word are returned.
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.
183
257
  */
184
- export function rankRequirementCandidates(relativePath, requirements, limit = MAX_REQUIREMENT_CANDIDATES) {
258
+ export function rankRequirementCandidates(relativePath, requirements, limit = MAX_REQUIREMENT_CANDIDATES, requestWords = []) {
185
259
  const topic = new Set(pathTopicWords(relativePath));
186
- if (topic.size === 0)
260
+ const request = new Set(requestWords.filter((word) => !topic.has(word)));
261
+ if (topic.size === 0 && request.size === 0)
187
262
  return [];
188
263
  const scored = [];
189
264
  for (const head of requirements) {
190
265
  if (head.status !== undefined && RETIRED_STATUS.test(head.status))
191
266
  continue;
192
- const titleWords = words(head.title).filter((word) => topic.has(word));
193
- const shared = new Set(titleWords).size;
194
- if (shared > 0)
195
- scored.push({ head, shared, uses: titleWords.length });
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
+ }
196
280
  }
197
281
  scored.sort((a, b) => b.shared - a.shared ||
282
+ b.asked - a.asked ||
198
283
  b.uses - a.uses ||
199
284
  a.head.id.localeCompare(b.head.id));
200
285
  return scored.slice(0, limit).map((entry) => entry.head);
201
286
  }
202
- // implements REQ-kb-unowned-file-candidates
287
+ // implements REQ-kb-unowned-file-candidates, REQ-claude-request-topic-candidates
203
288
  /**
204
289
  * Candidate requirements for a file no symbol links, read from the canonical
205
- * requirement documents (`.kb/requirements/*.md`). Empty when the KB has none
206
- * or is too large to scan from a hook.
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.
207
293
  */
208
- export function requirementCandidatesForPath(workspaceRoot, relativePath, limit = MAX_REQUIREMENT_CANDIDATES) {
209
- if (pathTopicWords(relativePath).length === 0)
294
+ export function requirementCandidatesForPath(workspaceRoot, relativePath, limit = MAX_REQUIREMENT_CANDIDATES, requestWords = []) {
295
+ if (pathTopicWords(relativePath).length === 0 && requestWords.length < 2)
210
296
  return [];
211
297
  const directory = path.join(workspaceRoot, REQUIREMENTS_DIR);
212
298
  let names;
@@ -221,5 +307,5 @@ export function requirementCandidatesForPath(workspaceRoot, relativePath, limit
221
307
  const heads = names
222
308
  .map((name) => readHead(path.join(directory, name)))
223
309
  .filter((head) => head !== undefined);
224
- return rankRequirementCandidates(relativePath, heads, limit);
310
+ return rankRequirementCandidates(relativePath, heads, limit, requestWords);
225
311
  }
package/dist/snippets.js CHANGED
@@ -125,7 +125,7 @@ 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, REQ-kb-unowned-file-candidates
128
+ // implements REQ-claude-code-kibi-plugin-v1, REQ-codex-kibi-plugin-v1, REQ-kb-unowned-file-candidates, REQ-claude-request-topic-candidates
129
129
  /**
130
130
  * Discovery note for a source file none of whose symbols implement a
131
131
  * requirement. No link is not no rule, so the note names the current
@@ -141,13 +141,22 @@ export function unownedSourceNote(relativePath, candidates = []) {
141
141
  .map((candidate) => `${candidate.id}: ${truncate(candidate.title, MAX_TITLE)}`);
142
142
  const note = [
143
143
  head,
144
- `Requirements whose titles share this path's words: ${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).`,
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
145
  search,
146
146
  ].join("\n");
147
147
  if (note.length <= MAX_SNIPPET_CHARS)
148
148
  return note;
149
149
  }
150
- return [head, search].join("\n");
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.
155
+ return [
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.`,
159
+ ].join("\n");
151
160
  }
152
161
  // implements REQ-kibi-hooks-flow-manifest, REQ-claude-code-kibi-plugin-v1, REQ-codex-kibi-plugin-v1
153
162
  /** Shown instead of the discovery note when the manifest itself could not be read. */
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "kibi-agent-core",
3
- "version": "0.6.0",
3
+ "version": "0.8.0",
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
+ "./decision-guard": {
14
+ "types": "./dist/decision-guard.d.ts",
15
+ "default": "./dist/decision-guard.js"
16
+ },
13
17
  "./hook-usage-log": {
14
18
  "types": "./dist/hook-usage-log.d.ts",
15
19
  "default": "./dist/hook-usage-log.js"