kibi-opencode 0.4.1 → 0.5.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/README.md CHANGED
@@ -31,9 +31,10 @@ The plugin provides context-aware prompt guidance based on recent edits and work
31
31
 
32
32
  After KB-document edits, the plugin queues targeted `kibi check` rules to run after sync:
33
33
 
34
- - **Requirement/scenario/test/ADR/fact edits**: `kibi check --rules required-fields,no-dangling-refs`
34
+ - **Must-priority requirement edits**: `kibi check --rules required-fields,no-dangling-refs,must-priority-coverage`
35
+ - **Other requirement/scenario/test/ADR/fact edits**: `kibi check --rules required-fields,no-dangling-refs`
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
 
@@ -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.
@@ -155,7 +179,7 @@ Disable specific features while keeping others:
155
179
 
156
180
  ## Dogfooding
157
181
 
158
- This repository uses a local shim at `.opencode/plugins/kibi.ts` for development. The npm package (`kibi-opencode`) is the public distribution artifact.
182
+ This repository's OpenCode setup dogfoods local built artifacts. `opencode.json` starts the local `kibi-mcp` server, `.opencode/plugins/kibi.ts` re-exports `packages/opencode/dist/index.js`, and the published npm package (`kibi-opencode`) remains the distribution artifact for external consumers. See [DEV.md](DEV.md) for the repo-local workflow and rebuild rule.
159
183
 
160
184
  ## Architecture
161
185
 
@@ -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
+ }
package/dist/config.js CHANGED
@@ -1,7 +1,7 @@
1
1
  import fs from "node:fs";
2
2
  import os from "node:os";
3
3
  import path from "node:path";
4
- import * as logger from "./logger";
4
+ import * as logger from "./logger.js";
5
5
  const DEFAULTS = {
6
6
  enabled: true,
7
7
  prompt: { enabled: true, hookMode: "auto" },
@@ -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.d.ts CHANGED
@@ -1,4 +1,22 @@
1
- import type { Hooks, Plugin, PluginInput } from "@opencode-ai/plugin";
2
- export type { Plugin, PluginInput, Hooks };
1
+ export interface PluginInput {
2
+ worktree: string;
3
+ directory: string;
4
+ }
5
+ interface OpencodeEventPayload {
6
+ type: string;
7
+ properties?: Record<string, unknown>;
8
+ }
9
+ interface EventHookInput {
10
+ event: OpencodeEventPayload;
11
+ }
12
+ interface SystemTransformOutput {
13
+ system: string[];
14
+ }
15
+ export interface Hooks {
16
+ event?: (input: EventHookInput) => void | Promise<void>;
17
+ "experimental.chat.system.transform"?: (input: unknown, output: SystemTransformOutput) => void | Promise<void>;
18
+ "chat.params"?: (input: unknown, output: unknown) => void | Promise<void>;
19
+ }
20
+ export type Plugin = (input: PluginInput) => Hooks | Promise<Hooks>;
3
21
  declare const kibiOpencodePlugin: Plugin;
4
22
  export default kibiOpencodePlugin;
package/dist/index.js CHANGED
@@ -1,15 +1,19 @@
1
- import * as config from "./config";
2
- import * as fileFilter from "./file-filter";
3
- import * as logger from "./logger";
4
- import { analyzePath } from "./path-kind";
5
- import { injectPrompt } from "./prompt";
6
- import { createSyncScheduler } from "./scheduler";
7
- import { getSessionTracker } from "./session-tracker";
8
- import { checkWorkspaceHealth } from "./workspace-health";
1
+ import { analyzeCodeFile, } from "./comment-analysis.js";
2
+ import * as config from "./config.js";
3
+ import * as fileFilter from "./file-filter.js";
4
+ import * as logger from "./logger.js";
5
+ import * as path from "node:path";
6
+ import { analyzePath } from "./path-kind.js";
7
+ import { injectPrompt } from "./prompt.js";
8
+ import { isMustPriorityRequirement } from "./requirement-doc.js";
9
+ import { createSyncScheduler } from "./scheduler.js";
10
+ import { getSessionTracker } from "./session-tracker.js";
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)
21
- if (/given\s+.*when\s+.*then/i.test(content)) {
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,6 +1,7 @@
1
- import type { KibiConfig } from "./config";
2
- import type { PathKind } from "./path-kind";
3
- import type { WorkspaceHealth } from "./workspace-health";
1
+ import type { CommentAnalysisResult } from "./comment-analysis.js";
2
+ import type { KibiConfig } from "./config.js";
3
+ import type { PathKind } from "./path-kind.js";
4
+ import type { WorkspaceHealth } from "./workspace-health.js";
4
5
  declare const SENTINEL = "<!-- kibi-opencode -->";
5
6
  export interface PromptContext {
6
7
  recentEdits: Array<{
@@ -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
@@ -1,11 +1,11 @@
1
- import { isPluginEnabled } from "./config";
1
+ import { isPluginEnabled } from "./config.js";
2
2
  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
7
  function buildContextualGuidance(context) {
7
8
  const parts = [SENTINEL];
8
- // 1. Check for recent .kb edits (loud warning)
9
9
  if (context.hasRecentKbEdit) {
10
10
  parts.push(`
11
11
  ⚠️ **WARNING: Do not edit .kb/** files manually.**
@@ -18,7 +18,6 @@ Instead:
18
18
  - Use kb_check to validate consistency
19
19
  `);
20
20
  }
21
- // 2. Check for bootstrap/health issues
22
21
  if (context.workspaceHealth?.needsBootstrap) {
23
22
  parts.push(`
24
23
  🔧 **Bootstrap required**
@@ -29,19 +28,68 @@ This repository does not appear to have Kibi initialized. Consider running:
29
28
  - \`kibi doctor\` to verify your environment
30
29
  `);
31
30
  }
32
- // 3. Analyze recent edits and provide targeted guidance
33
31
  const codeEdits = context.recentEdits.filter((e) => e.kind === "code");
34
32
  const reqEdits = context.recentEdits.filter((e) => e.kind === "requirement");
35
33
  const kbDocEdits = context.recentEdits.filter((e) => ["requirement", "scenario", "test", "adr", "fact"].includes(e.kind));
36
- // Code edit guidance
37
34
  if (codeEdits.length > 0) {
38
- parts.push(`
35
+ const suggestion = context.recentCommentSuggestion;
36
+ if (suggestion) {
37
+ let routingMessage = "";
38
+ switch (suggestion.suggestionType) {
39
+ case "fact":
40
+ routingMessage = `🎯 **Durable knowledge detected: FACT**
41
+
42
+ Your recent code edit contains a comment that looks like a **domain invariant** (properties, limits, defaults, or cardinality constraints).
43
+
44
+ **Action**: Instead of inline comments, route this to a FACT entity:
45
+ - Create \`documentation/facts/FACT-xxx.md\` with the invariant
46
+ - Link it to relevant requirements using \`constrains\` or \`requires_property\` relationships
47
+ - Reference the FACT in code with a comment (e.g., \`// constrained by FACT-xxx\` in JS/TS or a docstring comment in Python)
48
+
49
+ This keeps domain truths centralized and searchable.`;
50
+ break;
51
+ case "adr":
52
+ routingMessage = `🎯 **Durable knowledge detected: ADR**
53
+
54
+ Your recent code edit contains a comment that looks like a **technical decision** (tradeoffs, rationale, or architecture choices).
55
+
56
+ **Action**: Instead of inline comments, route this to an ADR entity:
57
+ - Create \`documentation/adr/ADR-xxx.md\` documenting the decision
58
+ - Include context, options considered, and the chosen approach
59
+ - Link to constrained code symbols using \`constrained_by\` relationships
60
+
61
+ This preserves decision context for future maintainers.`;
62
+ break;
63
+ case "req":
64
+ routingMessage = `🎯 **Durable knowledge detected: REQ**
65
+
66
+ Your recent code edit contains a comment that looks like **behavior intent** (system capabilities or user-facing requirements).
67
+
68
+ **Action**: Instead of inline comments, route this to a REQ entity:
69
+ - Create \`documentation/requirements/REQ-xxx.md\` with the behavior description
70
+ - Add SCEN and TEST entities for specification and verification
71
+ - Link code to requirements using traceability comments (e.g., \`// implements REQ-xxx\` in JS/TS or docstring references in Python)
72
+
73
+ This ensures behavior is documented and traceable.`;
74
+ break;
75
+ default:
76
+ routingMessage = `📝 **Code changes detected**
77
+
78
+ Before implementing or explaining code:
79
+ 1. **Query Kibi first** - Run kb_query by sourceFile to find related requirements, ADRs, tests, and symbols.
80
+ 2. **Prefer Kibi over comments** - Store durable knowledge in KB entities instead of inline comments.
81
+ 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).`;
82
+ }
83
+ parts.push(routingMessage);
84
+ }
85
+ else {
86
+ parts.push(`
39
87
  📝 **Code changes detected**
40
88
 
41
89
  Before implementing or explaining code:
42
90
  1. **Query Kibi first** - Run kb_query by sourceFile to find related requirements, ADRs, tests, and symbols.
43
91
  2. **Prefer Kibi over comments** - Store durable knowledge in KB entities instead of inline comments.
44
- 3. **Add traceability** - Add \`// implements REQ-xxx\` to every new or modified function/class so the pre-commit hook can verify coverage.
92
+ 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.
45
93
 
46
94
  If you're adding long explanatory comments, consider routing that knowledge to:
47
95
  - \`FACT\` for domain invariants, properties, limits, cardinalities
@@ -50,8 +98,8 @@ If you're adding long explanatory comments, consider routing that knowledge to:
50
98
  - \`SCEN\` for behavior examples and flows
51
99
  - \`TEST\` for verification intent
52
100
  `);
101
+ }
53
102
  }
54
- // Requirement edit guidance
55
103
  if (reqEdits.length > 0) {
56
104
  parts.push(`
57
105
  📋 **Requirement changes detected**
@@ -67,7 +115,6 @@ Preferred structure:
67
115
  - \`TEST-xxx.md\` verifies the requirement
68
116
  `);
69
117
  }
70
- // KB doc edit guidance (requirement, scenario, test, ADR, fact)
71
118
  if (kbDocEdits.length > 0 && reqEdits.length === 0) {
72
119
  parts.push(`
73
120
  📚 **Kibi documentation changes detected**
@@ -78,7 +125,6 @@ When editing KB documentation:
78
125
  3. **Follow entity patterns** - Ensure each entity has proper frontmatter with required fields.
79
126
  `);
80
127
  }
81
- // Only include general Kibi workflow if no specific context (beyond the sentinel)
82
128
  if (parts.length === 1) {
83
129
  parts.push(`This project uses Kibi (via MCP). Prefer storing durable knowledge in Kibi over code comments.
84
130
 
@@ -88,6 +134,8 @@ Keep changed symbols traceable: add \`// implements REQ-xxx\` to every new or mo
88
134
 
89
135
  Run kb_check after KB mutations.
90
136
 
137
+ Dogfood note for this repo: OpenCode here uses local built \`kibi-mcp\` and \`kibi-opencode\` artifacts. If you change package versions or local package wiring, run \`bun run build\` before relying on OpenCode in this workspace.
138
+
91
139
  **Kibi-first workflow:**
92
140
  1. **Discover**: Run kb_query with filters (sourceFile, type, tags) to find related requirements, ADRs, tests, and symbols.
93
141
  2. **Document intent**: If you are about to explain code, STOP. Route that explanation to kb_upsert instead of inline comments.
@@ -112,6 +160,8 @@ Keep changed symbols traceable: add \`// implements REQ-xxx\` to every new or mo
112
160
 
113
161
  Run kb_check after KB mutations.
114
162
 
163
+ Dogfood note for this repo: OpenCode here uses local built \`kibi-mcp\` and \`kibi-opencode\` artifacts. If you change package versions or local package wiring, run \`bun run build\` before relying on OpenCode in this workspace.
164
+
115
165
  **Kibi-first workflow:**
116
166
  1. **Discover**: Run kb_query with filters (sourceFile, type, tags) to find related requirements, ADRs, tests, and symbols.
117
167
  2. **Document intent**: If you are about to explain code, STOP. Route that explanation to kb_upsert instead of inline comments.
@@ -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
+ }
@@ -1,4 +1,4 @@
1
- import type { KibiConfig } from "./config";
1
+ import type { KibiConfig } from "./config.js";
2
2
  export type TimeoutHandle = ReturnType<typeof setTimeout>;
3
3
  export interface SyncRunMetadata {
4
4
  reason: string;
package/dist/scheduler.js CHANGED
@@ -1,7 +1,7 @@
1
1
  import { exec } from "node:child_process";
2
2
  import path from "node:path";
3
- import { shouldHandleFile } from "./file-filter";
4
- import * as logger from "./logger";
3
+ import { shouldHandleFile } from "./file-filter.js";
4
+ import * as logger from "./logger.js";
5
5
  class WorktreeSyncScheduler {
6
6
  worktree;
7
7
  now;
@@ -1,5 +1,5 @@
1
1
  // implements REQ-opencode-kibi-plugin-v1
2
- import * as logger from "./logger";
2
+ import * as logger from "./logger.js";
3
3
  const WARNING_THRESHOLD_REPEAT = 3;
4
4
  const SESSION_DURATION_MS = 30 * 60 * 1000; // 30 minutes
5
5
  class SessionTracker {
@@ -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.1",
3
+ "version": "0.5.0",
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
  }