kibi-opencode 0.4.2 → 0.5.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/README.md CHANGED
@@ -29,11 +29,12 @@ The plugin provides context-aware prompt guidance based on recent edits and work
29
29
 
30
30
  ### Targeted Validation Checks
31
31
 
32
- After KB-document edits, the plugin queues targeted `kibi check` rules to run after sync:
32
+ After KB-document edits, the plugin queues targeted validation rules to run via background sync operations:
33
33
 
34
- - **Requirement/scenario/test/ADR/fact edits**: `kibi check --rules required-fields,no-dangling-refs`
34
+ - **Must-priority requirement edits**: elevated validation including coverage checks
35
+ - **Other requirement/scenario/test/ADR/fact edits**: standard validation for required fields and dangling references
35
36
 
36
- Runs in background after sync completes, non-blocking. Can be disabled via `guidance.targetedChecks.enabled: false`.
37
+ The plugin inspects requirement frontmatter to detect `priority: must` and schedules elevated validation for critical requirements. Runs in background after sync completes, non-blocking. Can be disabled via `guidance.targetedChecks.enabled: false`.
37
38
 
38
39
  ### Loud `.kb/**` Edit Warnings
39
40
 
@@ -41,7 +42,7 @@ When `guidance.warnOnKbEdits` is enabled (default: `true`), manual edits to file
41
42
 
42
43
  - Logs warning immediately
43
44
  - Injects prompt guidance discouraging manual `.kb` edits
44
- - Directs agents toward MCP/CLI tools (`kb_upsert`, `kb_query`, etc.)
45
+ - Directs agents toward public MCP tools (`kb_upsert`, `kb_query`, etc.)
45
46
 
46
47
  ### Session Tracking and Pattern Detection
47
48
 
@@ -64,6 +65,29 @@ session.patterns: Repeated anti-patterns detected:
64
65
  missing-traceability: 5 occurrences
65
66
  ```
66
67
 
68
+ ### Durable Knowledge Comment Detection
69
+
70
+ When editing code files, the plugin analyzes long comments and docstrings for durable knowledge that should be routed to Kibi instead of inline comments:
71
+
72
+ - **Supported languages**: JavaScript/TypeScript (`//`, `/* */`, `/** */`) and Python (`#` blocks, true docstrings)
73
+ - **Smart filtering**: Only analyzes comments above `guidance.commentDetection.minLines` threshold
74
+ - **Classification**: Automatically categorizes as FACT (invariants/limits), ADR (decisions/tradeoffs), REQ (behavior), SCEN (flows), or TEST (verification)
75
+ - **Specific routing guidance**: Injects targeted prompts based on classification:
76
+ - FACT: "This looks like a domain invariant; route to a FACT via Kibi"
77
+ - ADR: "This looks like decision rationale; route to an ADR"
78
+ - REQ: "This looks like behavior intent; route to a REQ"
79
+ - **Deduplication**: Tracks seen comments by fingerprint to avoid repeated guidance
80
+ - **Non-blocking**: Analysis runs without blocking sync or other operations
81
+
82
+ Example Python file triggering FACT guidance:
83
+ ```python
84
+ """
85
+ User accounts must have unique email addresses.
86
+ Each user can have at most 5 active sessions.
87
+ Sessions expire after 30 minutes of inactivity.
88
+ """
89
+ ```
90
+
67
91
  ### Prompt Guidance Injection
68
92
 
69
93
  The plugin injects guidance into OpenCode sessions to improve agent grounding. Uses `<!-- kibi-opencode -->` sentinel to prevent duplicate injections and respects `prompt.enabled` and overall `enabled` config flags.
@@ -72,9 +96,9 @@ The plugin injects guidance into OpenCode sessions to improve agent grounding. U
72
96
 
73
97
  OpenCode exposes Kibi MCP prompts as slash commands. The `/init-kibi` command runs the retroactive bootstrap workflow using only public MCP tools.
74
98
 
75
- ### Debounced Sync
99
+ ### Background Sync Operations
76
100
 
77
- Automatically runs `kibi sync` after relevant file edits:
101
+ Internal maintenance automatically syncs the knowledge base after relevant file edits:
78
102
 
79
103
  - Single-flight scheduler (no overlapping syncs)
80
104
  - Debounce window (default: 2000ms)
@@ -159,10 +183,10 @@ This repository's OpenCode setup dogfoods local built artifacts. `opencode.json`
159
183
 
160
184
  ## Architecture
161
185
 
162
- This is a thin bridge layer:
186
+ This is a thin bridge layer per ADR-016:
163
187
 
164
- - Reuses `kibi` CLI for sync operations
165
- - Reuses existing MCP tools (`kb_query`, `kb_check`, etc.)
188
+ - **Agent-visible guidance**: Public MCP tools (`kb_query`, `kb_upsert`, `kb_check`, etc.) and sanctioned slash commands (`/init-kibi`)
189
+ - **Internal maintenance**: Background sync operations handle KB synchronization; agents do NOT run sync commands directly
166
190
  - Does NOT own KB storage, parsing, or validation
167
191
 
168
192
  ### Future: File-Context Virtual Injection
@@ -0,0 +1,16 @@
1
+ export interface CommentAnalysisResult {
2
+ filePath: string;
3
+ suggestionType: "fact" | "adr" | "req" | "scenario" | "test";
4
+ confidence: "medium" | "high";
5
+ reasoning: string;
6
+ fingerprint: string;
7
+ sourceKind: "block-comment" | "docstring";
8
+ }
9
+ export interface CommentAnalyzerOptions {
10
+ minLines: number;
11
+ }
12
+ /**
13
+ * Analyze a code file for durable knowledge comments.
14
+ * Returns the best suggestion or null if none found.
15
+ */
16
+ export declare function analyzeCodeFile(filePath: string, options: CommentAnalyzerOptions): CommentAnalysisResult | null;
@@ -0,0 +1,327 @@
1
+ // implements REQ-opencode-comment-routing
2
+ import * as fs from "node:fs";
3
+ import { classifyKnowledge } from "./knowledge-classifier.js";
4
+ /**
5
+ * Detect language from file extension.
6
+ */
7
+ function detectLanguage(filePath) {
8
+ const ext = filePath.toLowerCase().split(".").pop();
9
+ if (ext === "py")
10
+ return "python";
11
+ if (["js", "jsx"].includes(ext || ""))
12
+ return "javascript";
13
+ if (["ts", "tsx"].includes(ext || ""))
14
+ return "typescript";
15
+ return null;
16
+ }
17
+ /**
18
+ * Create a simple fingerprint for deduplication.
19
+ */
20
+ function createFingerprint(text) {
21
+ // Normalize and hash the first 200 chars for dedupe
22
+ const normalized = text
23
+ .slice(0, 200)
24
+ .toLowerCase()
25
+ .replace(/\s+/g, " ")
26
+ .trim();
27
+ let hash = 0;
28
+ for (let i = 0; i < normalized.length; i++) {
29
+ const char = normalized.charCodeAt(i);
30
+ hash = (hash << 5) - hash + char;
31
+ hash = hash & hash; // Convert to 32bit integer
32
+ }
33
+ return hash.toString(16);
34
+ }
35
+ /**
36
+ * Extract JS/TS comment blocks (line and block comments).
37
+ */
38
+ function extractJsTsComments(content) {
39
+ const comments = [];
40
+ const lines = content.split("\n");
41
+ let i = 0;
42
+ while (i < lines.length) {
43
+ const line = lines[i];
44
+ if (line.trim().startsWith("/*")) {
45
+ const blockLines = [];
46
+ let j = i;
47
+ while (j < lines.length) {
48
+ const blockLine = lines[j];
49
+ blockLines.push(blockLine);
50
+ if (blockLine.includes("*/"))
51
+ break;
52
+ j++;
53
+ }
54
+ if (blockLines.length > 0) {
55
+ const text = blockLines
56
+ .join("\n")
57
+ .replace(/^\/\*\*?\s*/, "")
58
+ .replace(/\*\/$/, "")
59
+ .replace(/^\s*\*\s?/gm, "")
60
+ .trim();
61
+ if (text.length > 0) {
62
+ comments.push({ text, kind: "block-comment" });
63
+ }
64
+ }
65
+ i = j + 1;
66
+ continue;
67
+ }
68
+ if (line.trim().startsWith("//")) {
69
+ const commentLines = [];
70
+ let j = i;
71
+ while (j < lines.length && lines[j].trim().startsWith("//")) {
72
+ commentLines.push(lines[j].trim().replace(/^\/\/\s?/, ""));
73
+ j++;
74
+ }
75
+ if (commentLines.length > 0) {
76
+ const text = commentLines.join("\n").trim();
77
+ if (text.length > 0) {
78
+ comments.push({ text, kind: "block-comment" });
79
+ }
80
+ }
81
+ i = j;
82
+ continue;
83
+ }
84
+ i++;
85
+ }
86
+ return comments;
87
+ }
88
+ /**
89
+ * Check if a line is an assignment (x = y), which would disqualify a triple-quoted string from being a docstring.
90
+ */
91
+ function isAssignment(line) {
92
+ return /^\s*\w+\s*=\s*["']/.test(line);
93
+ }
94
+ /**
95
+ * Check if a line starts a class or function definition.
96
+ */
97
+ function isClassOrDef(line) {
98
+ return /^\s*(class|def)\s+\w+/.test(line);
99
+ }
100
+ /**
101
+ * Extract Python comment blocks (# and true docstrings only).
102
+ * See REQ-opencode-comment-routing and SCEN-opencode-python-comment-routing for docstring detection rules.
103
+ */
104
+ function extractPythonComments(content) {
105
+ const comments = [];
106
+ const lines = content.split("\n");
107
+ let foundModuleDocstring = false;
108
+ let insideClassOrDef = false;
109
+ let classOrDefIndent = 0;
110
+ let foundClassDocstring = false;
111
+ function getIndent(line) {
112
+ return line.match(/^(\s*)/)?.[1].length || 0;
113
+ }
114
+ function isSignificantLine(line) {
115
+ const trimmed = line.trim();
116
+ return trimmed.length > 0 && !trimmed.startsWith("#");
117
+ }
118
+ function extractDocstring(startIdx, quote, indent) {
119
+ const docstringLines = [];
120
+ let j = startIdx;
121
+ const startLine = lines[j].trim();
122
+ // Extract content from opening line
123
+ const openingMatch = startLine.match(new RegExp(`^\\s*${quote}(.*)$`));
124
+ if (openingMatch?.[1]) {
125
+ docstringLines.push(openingMatch[1].trim());
126
+ }
127
+ j++;
128
+ while (j < lines.length) {
129
+ const docLine = lines[j];
130
+ if (docLine.includes(quote)) {
131
+ // Closing line
132
+ const closingMatch = docLine.match(new RegExp(`^(.*?)${quote}`));
133
+ if (closingMatch?.[1]?.trim()) {
134
+ docstringLines.push(closingMatch[1].trim());
135
+ }
136
+ break;
137
+ }
138
+ // Only include lines at same or greater indent, or empty lines
139
+ if (docLine.trim() === "" || getIndent(docLine) >= indent) {
140
+ docstringLines.push(docLine.trim());
141
+ }
142
+ j++;
143
+ }
144
+ const text = docstringLines.join("\n").trim();
145
+ if (text.length > 0) {
146
+ return { text, endIdx: j };
147
+ }
148
+ return null;
149
+ }
150
+ let i = 0;
151
+ while (i < lines.length) {
152
+ const line = lines[i];
153
+ const trimmed = line.trim();
154
+ const indent = getIndent(line);
155
+ // Process # line comments FIRST (before skipping)
156
+ if (trimmed.startsWith("#")) {
157
+ const commentLines = [];
158
+ let j = i;
159
+ const currentIndent = getIndent(line);
160
+ // Collect contiguous # comments at same indent level
161
+ while (j < lines.length) {
162
+ const commentLine = lines[j];
163
+ const lineHashMatch = commentLine.match(/^(\s*)#(.*)$/);
164
+ if (!lineHashMatch)
165
+ break;
166
+ if (getIndent(commentLine) !== currentIndent)
167
+ break;
168
+ commentLines.push(lineHashMatch[2].trim());
169
+ j++;
170
+ }
171
+ if (commentLines.length > 0) {
172
+ const text = commentLines.join("\n").trim();
173
+ if (text.length > 0) {
174
+ comments.push({ text, kind: "block-comment" });
175
+ }
176
+ }
177
+ i = j;
178
+ continue;
179
+ }
180
+ // Skip empty lines for docstring detection
181
+ if (trimmed === "") {
182
+ i++;
183
+ continue;
184
+ }
185
+ // Check for class/def definitions
186
+ if (isClassOrDef(line)) {
187
+ insideClassOrDef = true;
188
+ classOrDefIndent = indent;
189
+ foundClassDocstring = false;
190
+ i++;
191
+ continue;
192
+ }
193
+ // Check if we've exited the class/def body
194
+ if (insideClassOrDef &&
195
+ indent <= classOrDefIndent &&
196
+ isSignificantLine(line)) {
197
+ insideClassOrDef = false;
198
+ }
199
+ // Check for triple-quoted strings
200
+ const isTripleQuote = trimmed.startsWith('"""') || trimmed.startsWith("'''");
201
+ if (isTripleQuote) {
202
+ // Skip if it's an assignment (x = """...""")
203
+ if (isAssignment(line)) {
204
+ // Track that we've seen a significant non-docstring statement
205
+ if (!foundModuleDocstring && !insideClassOrDef) {
206
+ foundModuleDocstring = true;
207
+ }
208
+ if (insideClassOrDef && !foundClassDocstring) {
209
+ foundClassDocstring = true;
210
+ }
211
+ // Skip to end of string
212
+ const quote = trimmed.startsWith('"""') ? '"""' : "'''";
213
+ i++;
214
+ while (i < lines.length && !lines[i].includes(quote)) {
215
+ i++;
216
+ }
217
+ i++;
218
+ continue;
219
+ }
220
+ const quote = trimmed.startsWith('"""') ? '"""' : "'''";
221
+ // Check if this is a valid docstring position
222
+ let isDocstring = false;
223
+ if (!foundModuleDocstring && !insideClassOrDef) {
224
+ // Module-level: first significant statement can be docstring
225
+ isDocstring = true;
226
+ foundModuleDocstring = true;
227
+ }
228
+ else if (insideClassOrDef && !foundClassDocstring) {
229
+ // Class/function-level: first significant statement after def can be docstring
230
+ // Check that we're indented more than the class/def
231
+ if (indent > classOrDefIndent) {
232
+ isDocstring = true;
233
+ foundClassDocstring = true;
234
+ }
235
+ }
236
+ if (isDocstring) {
237
+ const result = extractDocstring(i, quote, indent);
238
+ if (result) {
239
+ comments.push({ text: result.text, kind: "docstring" });
240
+ i = result.endIdx + 1;
241
+ continue;
242
+ }
243
+ }
244
+ // Not a docstring - mark as significant and skip it
245
+ if (!foundModuleDocstring && !insideClassOrDef) {
246
+ foundModuleDocstring = true;
247
+ }
248
+ if (insideClassOrDef && !foundClassDocstring) {
249
+ foundClassDocstring = true;
250
+ }
251
+ // Find the closing quote
252
+ i++;
253
+ while (i < lines.length && !lines[i].includes(quote)) {
254
+ i++;
255
+ }
256
+ i++;
257
+ continue;
258
+ }
259
+ // Any other significant line means we've passed the docstring opportunity
260
+ if (!foundModuleDocstring && isSignificantLine(line)) {
261
+ foundModuleDocstring = true;
262
+ }
263
+ if (insideClassOrDef && !foundClassDocstring && isSignificantLine(line)) {
264
+ foundClassDocstring = true;
265
+ }
266
+ i++;
267
+ }
268
+ return comments;
269
+ }
270
+ /**
271
+ * Count content lines (non-empty) in text.
272
+ */
273
+ function countContentLines(text) {
274
+ return text.split("\n").filter((line) => line.trim().length > 0).length;
275
+ }
276
+ /**
277
+ * Analyze a code file for durable knowledge comments.
278
+ * Returns the best suggestion or null if none found.
279
+ */
280
+ export function analyzeCodeFile(
281
+ // implements REQ-opencode-comment-routing
282
+ filePath, options) {
283
+ try {
284
+ const language = detectLanguage(filePath);
285
+ if (!language)
286
+ return null;
287
+ const content = fs.readFileSync(filePath, "utf-8");
288
+ let comments = [];
289
+ if (language === "python") {
290
+ comments = extractPythonComments(content);
291
+ }
292
+ else {
293
+ comments = extractJsTsComments(content);
294
+ }
295
+ // Filter by minLines threshold
296
+ const longComments = comments.filter((c) => countContentLines(c.text) >= options.minLines);
297
+ if (longComments.length === 0)
298
+ return null;
299
+ // Find the best classification (highest confidence, prefer high over medium)
300
+ let bestResult = null;
301
+ for (const comment of longComments) {
302
+ const suggestion = classifyKnowledge(comment.text);
303
+ if (!suggestion)
304
+ continue;
305
+ if (suggestion.confidence === "low")
306
+ continue;
307
+ const result = {
308
+ filePath,
309
+ suggestionType: suggestion.type,
310
+ confidence: suggestion.confidence,
311
+ reasoning: suggestion.reasoning,
312
+ fingerprint: createFingerprint(comment.text),
313
+ sourceKind: comment.kind,
314
+ };
315
+ // Prefer high confidence, then prefer earlier comments
316
+ if (!bestResult ||
317
+ (bestResult.confidence === "medium" && result.confidence === "high")) {
318
+ bestResult = result;
319
+ }
320
+ }
321
+ return bestResult;
322
+ }
323
+ catch {
324
+ // Conservative fallback: return null on any error
325
+ return null;
326
+ }
327
+ }
@@ -1,2 +1,12 @@
1
+ export declare function loadKbSyncPaths(cwd?: string): {
2
+ requirements: string;
3
+ scenarios: string;
4
+ tests: string;
5
+ adr: string;
6
+ flags: string;
7
+ events: string;
8
+ facts: string;
9
+ symbols: string;
10
+ };
1
11
  export declare function shouldHandleFile(filePath: string, cwd?: string): boolean;
2
12
  export default shouldHandleFile;
@@ -1,6 +1,6 @@
1
+ import { existsSync, readFileSync } from "node:fs";
1
2
  // implements REQ-opencode-kibi-plugin-v1
2
3
  import { createRequire } from "node:module";
3
- import { existsSync, readFileSync } from "node:fs";
4
4
  import * as path from "node:path";
5
5
  const _require = createRequire(import.meta.url);
6
6
  // Lightweight fallback matcher if picomatch isn't installed.
@@ -52,7 +52,7 @@ function loadSyncConfigLocal(cwd = process.cwd()) {
52
52
  defaultBranch: userConfig.defaultBranch,
53
53
  };
54
54
  }
55
- function loadKbSyncPaths(cwd = process.cwd()) {
55
+ export function loadKbSyncPaths(cwd = process.cwd()) {
56
56
  const cfg = loadSyncConfigLocal(cwd);
57
57
  return cfg.paths ?? DEFAULT_SYNC_PATHS;
58
58
  }
package/dist/index.js CHANGED
@@ -1,15 +1,19 @@
1
+ import { analyzeCodeFile, } from "./comment-analysis.js";
1
2
  import * as config from "./config.js";
2
3
  import * as fileFilter from "./file-filter.js";
3
4
  import * as logger from "./logger.js";
5
+ import * as path from "node:path";
4
6
  import { analyzePath } from "./path-kind.js";
5
7
  import { injectPrompt } from "./prompt.js";
8
+ import { isMustPriorityRequirement } from "./requirement-doc.js";
6
9
  import { createSyncScheduler } from "./scheduler.js";
7
10
  import { getSessionTracker } from "./session-tracker.js";
8
11
  import { checkWorkspaceHealth } from "./workspace-health.js";
9
12
  import * as fs from "node:fs";
10
13
  /**
11
- * Lint requirement document for anti-patterns.
14
+ * Lint requirement documents for embedded scenarios/tests and oversized content.
12
15
  */
16
+ // implements REQ-opencode-kibi-plugin-v1
13
17
  function lintRequirementDoc(filePath, worktree) {
14
18
  const warnings = [];
15
19
  try {
@@ -17,21 +21,18 @@ function lintRequirementDoc(filePath, worktree) {
17
21
  ? `${worktree}/${filePath}`
18
22
  : filePath;
19
23
  const content = fs.readFileSync(resolvedPath, "utf-8");
20
- // Check for embedded scenarios (Given/When/Then patterns) - implements REQ-opencode-kibi-plugin-v1
21
24
  if (/given\s+[\s\S]*?when\s+[\s\S]*?then/i.test(content)) {
22
25
  warnings.push({
23
26
  category: "embedded-scenario-in-req",
24
27
  message: `Requirement file ${filePath} appears to contain embedded scenario (Given/When/Then). Consider extracting to a separate SCEN entity.`,
25
28
  });
26
29
  }
27
- // Check for embedded tests (assert/verify patterns)
28
30
  if (/\b(assert|verify|expected\s+to|should\s+return)\b/i.test(content)) {
29
31
  warnings.push({
30
32
  category: "embedded-test-in-req",
31
33
  message: `Requirement file ${filePath} appears to contain embedded test assertions. Consider extracting to a separate TEST entity.`,
32
34
  });
33
35
  }
34
- // Check for very long requirement that might need splitting
35
36
  const lines = content.split("\n");
36
37
  const contentLines = lines.filter((l) => l.trim() && !l.startsWith("---") && !l.startsWith("#"));
37
38
  if (contentLines.length > 50) {
@@ -46,17 +47,10 @@ function lintRequirementDoc(filePath, worktree) {
46
47
  }
47
48
  return warnings;
48
49
  }
49
- let scheduler = null;
50
- let cfg;
51
- // Track recent edits for contextual guidance
52
- const MAX_RECENT_EDITS = 5;
53
- let recentEdits = [];
54
- let hasRecentKbEdit = false;
55
50
  // implements REQ-opencode-kibi-plugin-v1
56
51
  const kibiOpencodePlugin = async (input) => {
57
52
  // Load config
58
- const loadedCfg = config.loadConfig(input.directory);
59
- cfg = loadedCfg;
53
+ const cfg = config.loadConfig(input.directory);
60
54
  if (!cfg.enabled) {
61
55
  logger.info("kibi-opencode: disabled via config");
62
56
  return {};
@@ -77,61 +71,103 @@ const kibiOpencodePlugin = async (input) => {
77
71
  }
78
72
  logger.info("kibi-opencode: setting up hooks");
79
73
  const hooks = {};
80
- // Setup file-edit triggered sync via event hook
74
+ // Plugin instance state (not module globals)
75
+ const MAX_RECENT_EDITS = 5;
76
+ let recentEdits = [];
77
+ let hasRecentKbEdit = false;
78
+ let recentCommentSuggestion = null;
79
+ const seenFingerprints = new Set(); // For deduplication
80
+ // Create scheduler only if sync is enabled
81
+ let scheduler = null;
81
82
  if (cfg.sync.enabled) {
82
83
  const schedulerOpts = {
83
84
  worktree: input.worktree,
84
85
  config: cfg,
85
86
  };
86
87
  scheduler = createSyncScheduler(schedulerOpts);
87
- hooks.event = async ({ event }) => {
88
- if (event.type !== "file.edited")
89
- return;
90
- const filePath = event
91
- .properties.file;
92
- if (!filePath)
93
- return;
94
- // Analyze path for tracking and classification
95
- const pathAnalysis = analyzePath(filePath, input.worktree);
96
- // Check for .kb edit (loud warning) — gated on guidance.warnOnKbEdits
97
- if (pathAnalysis.isUnderKb && cfg.guidance.warnOnKbEdits) {
98
- hasRecentKbEdit = true;
99
- logger.warn(`kibi-opencode: .kb edit detected for ${filePath}`);
100
- getSessionTracker().recordWarning("kb-edit", filePath, `Manual .kb edit: ${filePath}`);
88
+ }
89
+ hooks.event = async ({ event }) => {
90
+ if (event.type !== "file.edited")
91
+ return;
92
+ const filePath = event
93
+ .properties.file;
94
+ if (!filePath)
95
+ return;
96
+ const pathAnalysis = analyzePath(filePath, input.worktree);
97
+ if (pathAnalysis.isUnderKb && cfg.guidance.warnOnKbEdits) {
98
+ hasRecentKbEdit = true;
99
+ logger.warn(`kibi-opencode: .kb edit detected for ${filePath}`);
100
+ getSessionTracker().recordWarning("kb-edit", filePath, `Manual .kb edit: ${filePath}`);
101
+ }
102
+ if (pathAnalysis.kind === "requirement") {
103
+ const lintWarnings = lintRequirementDoc(filePath, input.worktree);
104
+ for (const warning of lintWarnings) {
105
+ getSessionTracker().recordWarning(warning.category, filePath, warning.message);
101
106
  }
102
- // Lint requirement docs for anti-patterns
103
- if (pathAnalysis.kind === "requirement") {
104
- const lintWarnings = lintRequirementDoc(filePath, input.worktree);
105
- for (const warning of lintWarnings) {
106
- getSessionTracker().recordWarning(warning.category, filePath, warning.message);
107
+ }
108
+ const now = Date.now();
109
+ recentEdits.push({
110
+ path: filePath,
111
+ kind: pathAnalysis.kind,
112
+ timestamp: now,
113
+ });
114
+ if (recentEdits.length > MAX_RECENT_EDITS) {
115
+ recentEdits = recentEdits.slice(-MAX_RECENT_EDITS);
116
+ }
117
+ if (pathAnalysis.kind === "code" && cfg.guidance.commentDetection.enabled) {
118
+ const resolvedPath = input.worktree && !path.isAbsolute(filePath)
119
+ ? path.join(input.worktree, filePath)
120
+ : filePath;
121
+ const suggestion = analyzeCodeFile(resolvedPath, {
122
+ minLines: cfg.guidance.commentDetection.minLines,
123
+ });
124
+ if (suggestion) {
125
+ recentCommentSuggestion = suggestion;
126
+ const dedupeKey = `${filePath}:${suggestion.suggestionType}:${suggestion.fingerprint}`;
127
+ if (!seenFingerprints.has(dedupeKey)) {
128
+ seenFingerprints.add(dedupeKey);
129
+ const warningCategory = suggestion.suggestionType === "fact"
130
+ ? "long-comment-missed-fact"
131
+ : suggestion.suggestionType === "adr"
132
+ ? "long-comment-missed-adr"
133
+ : "missing-traceability";
134
+ logger.info(`kibi-opencode: detected durable ${suggestion.suggestionType} knowledge in ${filePath}`);
135
+ getSessionTracker().recordWarning(warningCategory, filePath, `Consider routing this ${suggestion.suggestionType} knowledge to Kibi instead of inline comments: ${suggestion.reasoning}`);
107
136
  }
108
137
  }
109
- // Track recent edits
110
- const now = Date.now();
111
- recentEdits.push({
112
- path: filePath,
113
- kind: pathAnalysis.kind,
114
- timestamp: now,
115
- });
116
- // Keep only recent edits
117
- if (recentEdits.length > MAX_RECENT_EDITS) {
118
- recentEdits = recentEdits.slice(-MAX_RECENT_EDITS);
138
+ else {
139
+ recentCommentSuggestion = null;
119
140
  }
120
- // Only schedule sync for relevant files (not .kb)
121
- if (!fileFilter.shouldHandleFile(filePath, input.worktree))
122
- return;
123
- // Determine targeted checks based on edit type (gated on guidance.targetedChecks.enabled)
124
- let checkRules;
125
- if (cfg.guidance.targetedChecks.enabled) {
126
- if (["requirement", "scenario", "test", "adr", "fact"].includes(pathAnalysis.kind)) {
141
+ }
142
+ else {
143
+ recentCommentSuggestion = null;
144
+ }
145
+ if (!cfg.sync.enabled)
146
+ return;
147
+ if (!fileFilter.shouldHandleFile(filePath, input.worktree))
148
+ return;
149
+ let checkRules;
150
+ if (cfg.guidance.targetedChecks.enabled) {
151
+ if (pathAnalysis.kind === "requirement") {
152
+ if (isMustPriorityRequirement(filePath, input.worktree)) {
153
+ checkRules = [
154
+ "required-fields",
155
+ "no-dangling-refs",
156
+ "must-priority-coverage",
157
+ ];
158
+ logger.info(`kibi-opencode: must-priority requirement detected, scheduling elevated checks for ${filePath}`);
159
+ }
160
+ else {
127
161
  checkRules = ["required-fields", "no-dangling-refs"];
128
162
  }
129
163
  }
130
- logger.info(`kibi-opencode: scheduling sync for ${filePath}`);
131
- scheduler?.scheduleSync("file.edited", filePath, checkRules);
132
- };
133
- }
134
- // Setup prompt injection hook
164
+ else if (["scenario", "test", "adr", "fact"].includes(pathAnalysis.kind)) {
165
+ checkRules = ["required-fields", "no-dangling-refs"];
166
+ }
167
+ }
168
+ logger.info(`kibi-opencode: scheduling sync for ${filePath}`);
169
+ scheduler?.scheduleSync("file.edited", filePath, checkRules);
170
+ };
135
171
  if (cfg.prompt.enabled) {
136
172
  const hookMode = cfg.prompt.hookMode;
137
173
  if (hookMode === "system-transform" || hookMode === "auto") {
@@ -141,6 +177,7 @@ const kibiOpencodePlugin = async (input) => {
141
177
  recentEdits,
142
178
  workspaceHealth,
143
179
  hasRecentKbEdit,
180
+ recentCommentSuggestion,
144
181
  });
145
182
  output.system.length = 0;
146
183
  output.system.push(injected);
package/dist/path-kind.js CHANGED
@@ -1,6 +1,6 @@
1
1
  // implements REQ-opencode-kibi-plugin-v1
2
2
  import path from "node:path";
3
- const CODE_EXTENSIONS = [".ts", ".tsx", ".js", ".jsx"];
3
+ const CODE_EXTENSIONS = [".ts", ".tsx", ".js", ".jsx", ".py"];
4
4
  const KB_PREFIX = ".kb";
5
5
  const KIBI_DOC_PATTERNS = [
6
6
  "requirements/**",
@@ -12,7 +12,9 @@ const KIBI_DOC_PATTERNS = [
12
12
  "facts/**",
13
13
  "symbols.yaml",
14
14
  ];
15
- export function analyzePath(filePath, cwd) {
15
+ export function analyzePath(
16
+ // implements REQ-opencode-kibi-plugin-v1
17
+ filePath, cwd) {
16
18
  const rel = path.isAbsolute(filePath)
17
19
  ? path.relative(cwd, filePath).split(path.sep).join("/")
18
20
  : filePath.split(path.sep).join("/");
package/dist/prompt.d.ts CHANGED
@@ -1,3 +1,4 @@
1
+ import type { CommentAnalysisResult } from "./comment-analysis.js";
1
2
  import type { KibiConfig } from "./config.js";
2
3
  import type { PathKind } from "./path-kind.js";
3
4
  import type { WorkspaceHealth } from "./workspace-health.js";
@@ -9,6 +10,7 @@ export interface PromptContext {
9
10
  }>;
10
11
  workspaceHealth?: WorkspaceHealth;
11
12
  hasRecentKbEdit?: boolean;
13
+ recentCommentSuggestion?: CommentAnalysisResult | null;
12
14
  }
13
15
  /**
14
16
  * Build prompt with contextual guidance based on recent edits and workspace state.
package/dist/prompt.js CHANGED
@@ -3,15 +3,14 @@ const SENTINEL = "<!-- kibi-opencode -->";
3
3
  /**
4
4
  * Build prompt guidance block based on path kind.
5
5
  */
6
- // implements REQ-opencode-kibi-plugin-v1
6
+ // implements REQ-opencode-kibi-plugin-v1, REQ-opencode-agent-mcp-only
7
7
  function buildContextualGuidance(context) {
8
8
  const parts = [SENTINEL];
9
- // 1. Check for recent .kb edits (loud warning)
10
9
  if (context.hasRecentKbEdit) {
11
10
  parts.push(`
12
11
  ⚠️ **WARNING: Do not edit .kb/** files manually.**
13
12
 
14
- The Kibi knowledge base is managed through MCP and CLI tools. Direct manual edits to files under .kb/** can cause inconsistencies and should be avoided.
13
+ The Kibi knowledge base is managed through public MCP tools and internal maintenance flows. Direct manual edits to files under .kb/** can cause inconsistencies and should be avoided.
15
14
 
16
15
  Instead:
17
16
  - Use kb_upsert to create/update entities
@@ -19,30 +18,79 @@ Instead:
19
18
  - Use kb_check to validate consistency
20
19
  `);
21
20
  }
22
- // 2. Check for bootstrap/health issues
23
21
  if (context.workspaceHealth?.needsBootstrap) {
24
22
  parts.push(`
25
23
  🔧 **Bootstrap required**
26
24
 
27
- This repository does not appear to have Kibi initialized. Consider running:
28
- - \`/init-kibi\` for retroactive bootstrap of existing repos
29
- - \`kibi init\` for new repos
30
- - \`kibi doctor\` to verify your environment
25
+ This repository does not appear to have Kibi initialized. Agents should:
26
+ - Use \`/init-kibi\` for retroactive bootstrap of existing repos (preferred MCP command)
27
+ - Ask the user/operator to run setup or repair outside this session if \`/init-kibi\` is insufficient
28
+
29
+ Do not run \`kibi\` CLI commands directly; use the MCP tools (kb_query, kb_upsert, kb_delete, kb_check).
31
30
  `);
32
31
  }
33
- // 3. Analyze recent edits and provide targeted guidance
34
32
  const codeEdits = context.recentEdits.filter((e) => e.kind === "code");
35
33
  const reqEdits = context.recentEdits.filter((e) => e.kind === "requirement");
36
34
  const kbDocEdits = context.recentEdits.filter((e) => ["requirement", "scenario", "test", "adr", "fact"].includes(e.kind));
37
- // Code edit guidance
38
35
  if (codeEdits.length > 0) {
39
- parts.push(`
36
+ const suggestion = context.recentCommentSuggestion;
37
+ if (suggestion) {
38
+ let routingMessage = "";
39
+ switch (suggestion.suggestionType) {
40
+ case "fact":
41
+ routingMessage = `🎯 **Durable knowledge detected: FACT**
42
+
43
+ Your recent code edit contains a comment that looks like a **domain invariant** (properties, limits, defaults, or cardinality constraints).
44
+
45
+ **Action**: Instead of inline comments, route this to a FACT entity:
46
+ - Create \`documentation/facts/FACT-xxx.md\` with the invariant
47
+ - Link it to relevant requirements using \`constrains\` or \`requires_property\` relationships
48
+ - Reference the FACT in code with a comment (e.g., \`// constrained by FACT-xxx\` in JS/TS or a docstring comment in Python)
49
+
50
+ This keeps domain truths centralized and searchable.`;
51
+ break;
52
+ case "adr":
53
+ routingMessage = `🎯 **Durable knowledge detected: ADR**
54
+
55
+ Your recent code edit contains a comment that looks like a **technical decision** (tradeoffs, rationale, or architecture choices).
56
+
57
+ **Action**: Instead of inline comments, route this to an ADR entity:
58
+ - Create \`documentation/adr/ADR-xxx.md\` documenting the decision
59
+ - Include context, options considered, and the chosen approach
60
+ - Link to constrained code symbols using \`constrained_by\` relationships
61
+
62
+ This preserves decision context for future maintainers.`;
63
+ break;
64
+ case "req":
65
+ routingMessage = `🎯 **Durable knowledge detected: REQ**
66
+
67
+ Your recent code edit contains a comment that looks like **behavior intent** (system capabilities or user-facing requirements).
68
+
69
+ **Action**: Instead of inline comments, route this to a REQ entity:
70
+ - Create \`documentation/requirements/REQ-xxx.md\` with the behavior description
71
+ - Add SCEN and TEST entities for specification and verification
72
+ - Link code to requirements using traceability comments (e.g., \`// implements REQ-xxx\` in JS/TS or docstring references in Python)
73
+
74
+ This ensures behavior is documented and traceable.`;
75
+ break;
76
+ default:
77
+ routingMessage = `📝 **Code changes detected**
78
+
79
+ Before implementing or explaining code:
80
+ 1. **Query Kibi first** - Run kb_query by sourceFile to find related requirements, ADRs, tests, and symbols.
81
+ 2. **Prefer Kibi over comments** - Store durable knowledge in KB entities instead of inline comments.
82
+ 3. **Add traceability** - Add traceability comments to new or modified functions/classes so the pre-commit hook can verify coverage (e.g., \`// implements REQ-xxx\` in JS/TS or docstring references in Python).`;
83
+ }
84
+ parts.push(routingMessage);
85
+ }
86
+ else {
87
+ parts.push(`
40
88
  📝 **Code changes detected**
41
89
 
42
90
  Before implementing or explaining code:
43
91
  1. **Query Kibi first** - Run kb_query by sourceFile to find related requirements, ADRs, tests, and symbols.
44
92
  2. **Prefer Kibi over comments** - Store durable knowledge in KB entities instead of inline comments.
45
- 3. **Add traceability** - Add \`// implements REQ-xxx\` to every new or modified function/class so the pre-commit hook can verify coverage.
93
+ 3. **Add traceability** - Add traceability comments to new or modified functions/classes (e.g., \`// implements REQ-xxx\` in JS/TS or docstring references in Python) so the pre-commit hook can verify coverage.
46
94
 
47
95
  If you're adding long explanatory comments, consider routing that knowledge to:
48
96
  - \`FACT\` for domain invariants, properties, limits, cardinalities
@@ -51,8 +99,8 @@ If you're adding long explanatory comments, consider routing that knowledge to:
51
99
  - \`SCEN\` for behavior examples and flows
52
100
  - \`TEST\` for verification intent
53
101
  `);
102
+ }
54
103
  }
55
- // Requirement edit guidance
56
104
  if (reqEdits.length > 0) {
57
105
  parts.push(`
58
106
  📋 **Requirement changes detected**
@@ -68,18 +116,16 @@ Preferred structure:
68
116
  - \`TEST-xxx.md\` verifies the requirement
69
117
  `);
70
118
  }
71
- // KB doc edit guidance (requirement, scenario, test, ADR, fact)
72
119
  if (kbDocEdits.length > 0 && reqEdits.length === 0) {
73
120
  parts.push(`
74
121
  📚 **Kibi documentation changes detected**
75
122
 
76
123
  When editing KB documentation:
77
124
  1. **Maintain traceability** - Link entities using relationships: specified_by (req→scenario), verified_by (req→test), etc.
78
- 2. **Validate** - Run \`kibi check\` after making changes to catch integrity issues.
125
+ 2. **Validate** - Use \`kb_check\` after making changes to catch integrity issues.
79
126
  3. **Follow entity patterns** - Ensure each entity has proper frontmatter with required fields.
80
127
  `);
81
128
  }
82
- // Only include general Kibi workflow if no specific context (beyond the sentinel)
83
129
  if (parts.length === 1) {
84
130
  parts.push(`This project uses Kibi (via MCP). Prefer storing durable knowledge in Kibi over code comments.
85
131
 
@@ -99,6 +145,8 @@ Dogfood note for this repo: OpenCode here uses local built \`kibi-mcp\` and \`ki
99
145
 
100
146
  **Public Kibi tools only:** kb_query, kb_upsert, kb_delete, kb_check.
101
147
 
148
+ Do not invoke Kibi CLI commands directly from the agent.
149
+
102
150
  Bootstrap existing repos: use \`/init-kibi\` to run the retroactive initialization workflow.`);
103
151
  }
104
152
  return parts.join("\n\n").trim();
@@ -125,6 +173,8 @@ Dogfood note for this repo: OpenCode here uses local built \`kibi-mcp\` and \`ki
125
173
 
126
174
  **Public Kibi tools only:** kb_query, kb_upsert, kb_delete, kb_check.
127
175
 
176
+ Do not invoke Kibi CLI commands directly from the agent.
177
+
128
178
  Bootstrap existing repos: use \`/init-kibi\` to run the retroactive initialization workflow.`;
129
179
  /**
130
180
  * Build prompt with contextual guidance based on recent edits and workspace state.
@@ -0,0 +1,12 @@
1
+ /**
2
+ * Read a requirement file and determine if it has priority: must.
3
+ * Returns false on any error (file not found, parse failure, etc.)
4
+ * Handles CRLF line endings, BOM markers, and cross-platform paths.
5
+ */
6
+ export declare function isMustPriorityRequirement(filePath: string, worktree?: string): boolean;
7
+ /**
8
+ * Inspect requirement file frontmatter.
9
+ * Returns the priority value or null if not found/unparseable.
10
+ * Handles CRLF line endings, BOM markers, and cross-platform paths.
11
+ */
12
+ export declare function getRequirementPriority(filePath: string, worktree?: string): string | null;
@@ -0,0 +1,128 @@
1
+ // implements REQ-opencode-kibi-plugin-v1
2
+ import * as fs from "node:fs";
3
+ import * as path from "node:path";
4
+ /**
5
+ * Normalize line endings to LF and strip BOM if present.
6
+ */
7
+ function normalizeContent(content) {
8
+ const normalized = content.charCodeAt(0) === 0xfeff ? content.slice(1) : content;
9
+ return normalized.replace(/\r\n/g, "\n");
10
+ }
11
+ /**
12
+ * Check if a position is inside quotes in a string.
13
+ * Simple check: odd number of unescaped quotes before position.
14
+ */
15
+ function isInsideQuotes(str, pos) {
16
+ let count = 0;
17
+ let escaped = false;
18
+ for (let i = 0; i < pos; i++) {
19
+ const char = str[i];
20
+ if (escaped) {
21
+ escaped = false;
22
+ continue;
23
+ }
24
+ if (char === "\\") {
25
+ escaped = true;
26
+ continue;
27
+ }
28
+ if (char === '"' || char === "'") {
29
+ count++;
30
+ }
31
+ }
32
+ return count % 2 === 1;
33
+ }
34
+ /**
35
+ * Parse frontmatter from markdown content.
36
+ * Returns null if no valid frontmatter found.
37
+ * Handles CRLF line endings and BOM markers.
38
+ */
39
+ function parseFrontmatter(content) {
40
+ const normalized = normalizeContent(content);
41
+ const match = normalized.match(/^---\s*\n([\s\S]*?)\n---/);
42
+ if (!match)
43
+ return null;
44
+ const frontmatterText = match[1];
45
+ const result = {};
46
+ // Simple YAML-like parsing for top-level scalar values only
47
+ // Handles inline comments by ignoring everything after # (unless quoted)
48
+ for (const rawLine of frontmatterText.split("\n")) {
49
+ const line = rawLine.trim();
50
+ if (!line || line.startsWith("#"))
51
+ continue;
52
+ const colonIndex = line.indexOf(":");
53
+ if (colonIndex === -1)
54
+ continue;
55
+ const key = line.slice(0, colonIndex).trim();
56
+ let value = line.slice(colonIndex + 1).trim();
57
+ // Strip inline comments (simple heuristic: unquoted #)
58
+ const commentMatch = value.match(/^(.*?)\s+#\s/);
59
+ if (commentMatch && !isInsideQuotes(value, commentMatch[1].length)) {
60
+ value = commentMatch[1].trim();
61
+ }
62
+ if (key && value) {
63
+ if ((value.startsWith('"') && value.endsWith('"')) ||
64
+ (value.startsWith("'") && value.endsWith("'"))) {
65
+ result[key] = value.slice(1, -1);
66
+ }
67
+ else if (value === "true") {
68
+ result[key] = true;
69
+ }
70
+ else if (value === "false") {
71
+ result[key] = false;
72
+ }
73
+ else if (/^-?\d+$/.test(value)) {
74
+ result[key] = Number(value);
75
+ }
76
+ else {
77
+ result[key] = value;
78
+ }
79
+ }
80
+ }
81
+ return result;
82
+ }
83
+ /**
84
+ * Read a requirement file and determine if it has priority: must.
85
+ * Returns false on any error (file not found, parse failure, etc.)
86
+ * Handles CRLF line endings, BOM markers, and cross-platform paths.
87
+ */
88
+ export function isMustPriorityRequirement(
89
+ // implements REQ-opencode-kibi-plugin-v1
90
+ filePath, worktree) {
91
+ try {
92
+ const resolvedPath = worktree && !path.isAbsolute(filePath)
93
+ ? path.join(worktree, filePath)
94
+ : filePath;
95
+ const content = fs.readFileSync(resolvedPath, "utf-8");
96
+ const frontmatter = parseFrontmatter(content);
97
+ if (!frontmatter)
98
+ return false;
99
+ return frontmatter.priority === "must";
100
+ }
101
+ catch {
102
+ // Conservative fallback: treat as non-must on any error
103
+ return false;
104
+ }
105
+ }
106
+ /**
107
+ * Inspect requirement file frontmatter.
108
+ * Returns the priority value or null if not found/unparseable.
109
+ * Handles CRLF line endings, BOM markers, and cross-platform paths.
110
+ */
111
+ export function getRequirementPriority(
112
+ // implements REQ-opencode-kibi-plugin-v1
113
+ filePath, worktree) {
114
+ try {
115
+ const resolvedPath = worktree && !path.isAbsolute(filePath)
116
+ ? path.join(worktree, filePath)
117
+ : filePath;
118
+ const content = fs.readFileSync(resolvedPath, "utf-8");
119
+ const frontmatter = parseFrontmatter(content);
120
+ if (!frontmatter)
121
+ return null;
122
+ const priority = frontmatter.priority;
123
+ return typeof priority === "string" ? priority : null;
124
+ }
125
+ catch {
126
+ return null;
127
+ }
128
+ }
@@ -118,7 +118,6 @@ class SessionTracker {
118
118
  }
119
119
  }
120
120
  }
121
- // Singleton instance
122
121
  let globalTracker = null;
123
122
  export function getSessionTracker() {
124
123
  if (!globalTracker) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "kibi-opencode",
3
- "version": "0.4.2",
3
+ "version": "0.5.1",
4
4
  "description": "Kibi OpenCode plugin - thin adapter to integrate Kibi with OpenCode sessions",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",
@@ -56,6 +56,7 @@
56
56
  "@opencode-ai/plugin": "^1.2.26"
57
57
  },
58
58
  "devDependencies": {
59
+ "@types/node": "latest",
59
60
  "typescript": "^5.0.0"
60
61
  }
61
62
  }