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 +27 -3
- package/dist/comment-analysis.d.ts +16 -0
- package/dist/comment-analysis.js +327 -0
- package/dist/config.js +1 -1
- package/dist/file-filter.d.ts +10 -0
- package/dist/file-filter.js +2 -2
- package/dist/index.d.ts +20 -2
- package/dist/index.js +100 -63
- package/dist/path-kind.js +4 -2
- package/dist/prompt.d.ts +5 -3
- package/dist/prompt.js +60 -10
- package/dist/requirement-doc.d.ts +12 -0
- package/dist/requirement-doc.js +128 -0
- package/dist/scheduler.d.ts +1 -1
- package/dist/scheduler.js +2 -2
- package/dist/session-tracker.js +1 -2
- package/package.json +2 -1
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
|
-
- **
|
|
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
|
|
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
package/dist/file-filter.d.ts
CHANGED
|
@@ -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;
|
package/dist/file-filter.js
CHANGED
|
@@ -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
|
-
|
|
2
|
-
|
|
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
|
|
2
|
-
import * as
|
|
3
|
-
import * as
|
|
4
|
-
import
|
|
5
|
-
import
|
|
6
|
-
import {
|
|
7
|
-
import {
|
|
8
|
-
import {
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
-
//
|
|
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
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
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
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
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
|
-
|
|
110
|
-
|
|
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
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
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
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
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(
|
|
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 {
|
|
2
|
-
import type {
|
|
3
|
-
import type {
|
|
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
|
-
|
|
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\`
|
|
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
|
+
}
|
package/dist/scheduler.d.ts
CHANGED
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;
|
package/dist/session-tracker.js
CHANGED
|
@@ -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.
|
|
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
|
}
|