kibi-opencode 0.3.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 +133 -0
- package/dist/config.d.ts +23 -0
- package/dist/config.js +98 -0
- package/dist/file-filter.d.ts +2 -0
- package/dist/file-filter.js +123 -0
- package/dist/index.d.ts +9 -0
- package/dist/index.js +64 -0
- package/dist/logger.d.ts +3 -0
- package/dist/logger.js +11 -0
- package/dist/prompt.d.ts +5 -0
- package/dist/prompt.js +35 -0
- package/dist/scheduler.d.ts +31 -0
- package/dist/scheduler.js +151 -0
- package/package.json +41 -0
package/README.md
ADDED
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
# kibi-opencode
|
|
2
|
+
|
|
3
|
+
OpenCode plugin for Kibi - repo-local, per-branch, queryable knowledge base.
|
|
4
|
+
|
|
5
|
+
## Installation
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
npm install kibi-opencode
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
Or via OpenCode's plugin system in `opencode.json`:
|
|
12
|
+
|
|
13
|
+
```json
|
|
14
|
+
{
|
|
15
|
+
"plugins": ["kibi-opencode"]
|
|
16
|
+
}
|
|
17
|
+
```
|
|
18
|
+
|
|
19
|
+
## Features
|
|
20
|
+
|
|
21
|
+
### Prompt Guidance Injection
|
|
22
|
+
|
|
23
|
+
The plugin injects guidance into OpenCode sessions to improve agent grounding:
|
|
24
|
+
|
|
25
|
+
```
|
|
26
|
+
Query Kibi before design/implementation work. Prefer kb_query/kb_check for context. Update KB artifacts after relevant changes. Remember symbol traceability requirements.
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
- Uses `<!-- kibi-opencode -->` sentinel to prevent duplicate injections
|
|
30
|
+
- Respects `prompt.enabled` and overall `enabled` config flags
|
|
31
|
+
|
|
32
|
+
### Bootstrap Command
|
|
33
|
+
|
|
34
|
+
OpenCode exposes Kibi MCP prompts as slash commands. The `/init-kibi` command runs the retroactive bootstrap workflow using only public MCP tools.
|
|
35
|
+
|
|
36
|
+
### Debounced Sync
|
|
37
|
+
|
|
38
|
+
Automatically runs `kibi sync` after relevant file edits:
|
|
39
|
+
|
|
40
|
+
- Single-flight scheduler (no overlapping syncs)
|
|
41
|
+
- Debounce window (default: 2000ms)
|
|
42
|
+
- Dirty flag triggers one trailing rerun after active sync completes
|
|
43
|
+
|
|
44
|
+
### Non-Blocking UX
|
|
45
|
+
|
|
46
|
+
- Sync runs in background, never blocks OpenCode
|
|
47
|
+
- Failures reported via console logs only, never as blocking UI elements
|
|
48
|
+
|
|
49
|
+
## Configuration
|
|
50
|
+
|
|
51
|
+
Config files (project overrides global):
|
|
52
|
+
|
|
53
|
+
- Global: `~/.config/opencode/kibi.json`
|
|
54
|
+
- Project: `.opencode/kibi.json`
|
|
55
|
+
|
|
56
|
+
### Config Keys
|
|
57
|
+
|
|
58
|
+
| Key | Type | Default | Description |
|
|
59
|
+
|-----|------|---------|-------------|
|
|
60
|
+
| `enabled` | boolean | `true` | Enable/disable all plugin features |
|
|
61
|
+
| `prompt.enabled` | boolean | `true` | Enable prompt guidance injection |
|
|
62
|
+
| `prompt.hookMode` | string | `"auto"` | Hook mode: `auto`, `chat-params`, `system-transform`, `compat` |
|
|
63
|
+
| `sync.enabled` | boolean | `true` | Enable automatic sync |
|
|
64
|
+
| `sync.debounceMs` | number | `2000` | Debounce window in milliseconds |
|
|
65
|
+
| `sync.ignore` | string[] | `[]` | Additional paths to ignore |
|
|
66
|
+
| `sync.relevant` | string[] | `[]` | Additional relevant paths |
|
|
67
|
+
| `logLevel` | string | `"info"` | Log level: `debug`, `info`, `warn`, `error` |
|
|
68
|
+
|
|
69
|
+
### Hook Policy
|
|
70
|
+
|
|
71
|
+
Per ADR-016, prompt text injection uses only `experimental.chat.system.transform`. The `chat.params` hook is reserved for model option enrichment (temperature, topP, etc.) and never carries prompt text.
|
|
72
|
+
|
|
73
|
+
### Hook Modes
|
|
74
|
+
|
|
75
|
+
- `auto`: Use `experimental.chat.system.transform` (primary); `chat.params` is a no-op registration for host compatibility
|
|
76
|
+
- `chat-params`: Disable prompt injection; `chat.params` hook is registered but does not modify prompt text
|
|
77
|
+
- `system-transform`: Force `experimental.chat.system.transform` for prompt injection
|
|
78
|
+
- `compat`: Disable prompt injection entirely, conservative sync only
|
|
79
|
+
|
|
80
|
+
## Disablement
|
|
81
|
+
|
|
82
|
+
### Project-Level Disablement
|
|
83
|
+
|
|
84
|
+
Create `.opencode/kibi.json`:
|
|
85
|
+
|
|
86
|
+
```json
|
|
87
|
+
{
|
|
88
|
+
"enabled": false
|
|
89
|
+
}
|
|
90
|
+
```
|
|
91
|
+
|
|
92
|
+
This disables all plugin features even if loaded globally.
|
|
93
|
+
|
|
94
|
+
### Feature-Level Disablement
|
|
95
|
+
|
|
96
|
+
Disable specific features while keeping others:
|
|
97
|
+
|
|
98
|
+
```json
|
|
99
|
+
{
|
|
100
|
+
"prompt": {
|
|
101
|
+
"enabled": false
|
|
102
|
+
},
|
|
103
|
+
"sync": {
|
|
104
|
+
"enabled": false
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
```
|
|
108
|
+
|
|
109
|
+
## Dogfooding
|
|
110
|
+
|
|
111
|
+
This repository uses a local shim at `.opencode/plugins/kibi.ts` for development. The npm package (`kibi-opencode`) is the public distribution artifact.
|
|
112
|
+
|
|
113
|
+
## Architecture
|
|
114
|
+
|
|
115
|
+
This is a thin bridge layer:
|
|
116
|
+
|
|
117
|
+
- Reuses `kibi` CLI for sync operations
|
|
118
|
+
- Reuses existing MCP tools (`kb_query`, `kb_check`, etc.)
|
|
119
|
+
- Does NOT own KB storage, parsing, or validation
|
|
120
|
+
|
|
121
|
+
### Future: File-Context Virtual Injection
|
|
122
|
+
|
|
123
|
+
A proposed enhancement would inject Kibi context hints into file-read results (e.g., "This symbol has linked requirements"). This is **deferred** because:
|
|
124
|
+
|
|
125
|
+
1. OpenCode's current plugin surface does not expose file-content interception hooks
|
|
126
|
+
2. The `experimental.chat.system.transform` hook only supports system prompt injection
|
|
127
|
+
3. Symbol metadata from `documentation/symbols.yaml` can inform this feature once host support exists
|
|
128
|
+
|
|
129
|
+
Current workaround: static system prompt guidance directs agents to query Kibi explicitly.
|
|
130
|
+
|
|
131
|
+
## License
|
|
132
|
+
|
|
133
|
+
AGPL-3.0-or-later
|
package/dist/config.d.ts
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
export interface KibiConfig {
|
|
2
|
+
enabled: boolean;
|
|
3
|
+
prompt: {
|
|
4
|
+
enabled: boolean;
|
|
5
|
+
hookMode: "auto" | "chat-params" | "system-transform" | "compat";
|
|
6
|
+
};
|
|
7
|
+
sync: {
|
|
8
|
+
enabled: boolean;
|
|
9
|
+
debounceMs: number;
|
|
10
|
+
ignore: string[];
|
|
11
|
+
relevant: string[];
|
|
12
|
+
};
|
|
13
|
+
ux: {
|
|
14
|
+
toastFailures: boolean;
|
|
15
|
+
toastSuccesses: boolean;
|
|
16
|
+
toastCooldownMs: number;
|
|
17
|
+
};
|
|
18
|
+
logLevel: string;
|
|
19
|
+
}
|
|
20
|
+
declare const DEFAULTS: KibiConfig;
|
|
21
|
+
export declare function loadConfig(projectDir?: string): KibiConfig;
|
|
22
|
+
export declare function isPluginEnabled(cfg?: KibiConfig): boolean;
|
|
23
|
+
export { DEFAULTS };
|
package/dist/config.js
ADDED
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import os from "node:os";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
import * as logger from "./logger";
|
|
5
|
+
const DEFAULTS = {
|
|
6
|
+
enabled: true,
|
|
7
|
+
prompt: { enabled: true, hookMode: "auto" },
|
|
8
|
+
sync: { enabled: true, debounceMs: 2000, ignore: [], relevant: [] },
|
|
9
|
+
ux: { toastFailures: true, toastSuccesses: false, toastCooldownMs: 10000 },
|
|
10
|
+
logLevel: "info",
|
|
11
|
+
};
|
|
12
|
+
function readJsonIfExists(filePath) {
|
|
13
|
+
try {
|
|
14
|
+
if (!fs.existsSync(filePath))
|
|
15
|
+
return null;
|
|
16
|
+
const raw = fs.readFileSync(filePath, "utf8");
|
|
17
|
+
return JSON.parse(raw);
|
|
18
|
+
}
|
|
19
|
+
catch (err) {
|
|
20
|
+
const msg = err && typeof err === "object" && "message" in err
|
|
21
|
+
? err.message
|
|
22
|
+
: String(err);
|
|
23
|
+
logger.warn(`Failed to read/parse config ${filePath}: ${msg}`);
|
|
24
|
+
return null;
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
function validateAndMerge(obj) {
|
|
28
|
+
if (!obj || typeof obj !== "object") {
|
|
29
|
+
logger.warn("Config is not an object, using defaults");
|
|
30
|
+
return DEFAULTS;
|
|
31
|
+
}
|
|
32
|
+
const src = obj;
|
|
33
|
+
const out = { ...DEFAULTS };
|
|
34
|
+
if (typeof src.enabled === "boolean")
|
|
35
|
+
out.enabled = src.enabled;
|
|
36
|
+
if (src.prompt && typeof src.prompt === "object") {
|
|
37
|
+
const p = src.prompt;
|
|
38
|
+
out.prompt = { ...DEFAULTS.prompt };
|
|
39
|
+
if (typeof p.enabled === "boolean")
|
|
40
|
+
out.prompt.enabled = p.enabled;
|
|
41
|
+
if (typeof p.hookMode === "string") {
|
|
42
|
+
const modes = ["auto", "chat-params", "system-transform", "compat"];
|
|
43
|
+
if (modes.includes(p.hookMode))
|
|
44
|
+
out.prompt.hookMode = p.hookMode;
|
|
45
|
+
else
|
|
46
|
+
logger.warn(`Invalid prompt.hookMode '${p.hookMode}', using default`);
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
if (src.sync && typeof src.sync === "object") {
|
|
50
|
+
const s = src.sync;
|
|
51
|
+
out.sync = { ...DEFAULTS.sync };
|
|
52
|
+
if (typeof s.enabled === "boolean")
|
|
53
|
+
out.sync.enabled = s.enabled;
|
|
54
|
+
if (typeof s.debounceMs === "number")
|
|
55
|
+
out.sync.debounceMs = s.debounceMs;
|
|
56
|
+
if (Array.isArray(s.ignore))
|
|
57
|
+
out.sync.ignore = s.ignore.map(String);
|
|
58
|
+
if (Array.isArray(s.relevant))
|
|
59
|
+
out.sync.relevant = s.relevant.map(String);
|
|
60
|
+
}
|
|
61
|
+
if (src.ux && typeof src.ux === "object") {
|
|
62
|
+
const u = src.ux;
|
|
63
|
+
out.ux = { ...DEFAULTS.ux };
|
|
64
|
+
if (typeof u.toastFailures === "boolean")
|
|
65
|
+
out.ux.toastFailures = u.toastFailures;
|
|
66
|
+
if (typeof u.toastSuccesses === "boolean")
|
|
67
|
+
out.ux.toastSuccesses = u.toastSuccesses;
|
|
68
|
+
if (typeof u.toastCooldownMs === "number")
|
|
69
|
+
out.ux.toastCooldownMs = u.toastCooldownMs;
|
|
70
|
+
}
|
|
71
|
+
if (typeof src.logLevel === "string")
|
|
72
|
+
out.logLevel = src.logLevel;
|
|
73
|
+
return out;
|
|
74
|
+
}
|
|
75
|
+
// implements REQ-opencode-kibi-plugin-v1
|
|
76
|
+
export function loadConfig(projectDir = process.cwd()) {
|
|
77
|
+
const homeConfig = path.join(os.homedir(), ".config", "opencode", "kibi.json");
|
|
78
|
+
const projectConfig = path.join(projectDir, ".opencode", "kibi.json");
|
|
79
|
+
const globalObj = readJsonIfExists(homeConfig);
|
|
80
|
+
const projectObj = readJsonIfExists(projectConfig);
|
|
81
|
+
let merged = {};
|
|
82
|
+
if (globalObj)
|
|
83
|
+
merged = { ...merged, ...globalObj };
|
|
84
|
+
if (projectObj)
|
|
85
|
+
merged = { ...merged, ...projectObj };
|
|
86
|
+
const validated = validateAndMerge(merged);
|
|
87
|
+
if (!validated) {
|
|
88
|
+
logger.warn("Configuration invalid, falling back to defaults");
|
|
89
|
+
return DEFAULTS;
|
|
90
|
+
}
|
|
91
|
+
return validated;
|
|
92
|
+
}
|
|
93
|
+
// implements REQ-opencode-kibi-plugin-v1
|
|
94
|
+
export function isPluginEnabled(cfg) {
|
|
95
|
+
const effective = cfg || loadConfig();
|
|
96
|
+
return Boolean(effective.enabled);
|
|
97
|
+
}
|
|
98
|
+
export { DEFAULTS };
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
// implements REQ-opencode-kibi-plugin-v1
|
|
2
|
+
import { createRequire } from "node:module";
|
|
3
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
4
|
+
import * as path from "node:path";
|
|
5
|
+
const _require = createRequire(import.meta.url);
|
|
6
|
+
// Lightweight fallback matcher if picomatch isn't installed.
|
|
7
|
+
let picomatch;
|
|
8
|
+
try {
|
|
9
|
+
picomatch = _require("picomatch");
|
|
10
|
+
}
|
|
11
|
+
catch {
|
|
12
|
+
picomatch = {
|
|
13
|
+
isMatch: (str, pattern) => {
|
|
14
|
+
// very small subset: handle simple **/*.md and exact matches
|
|
15
|
+
if (pattern === "**/*.md")
|
|
16
|
+
return str.endsWith(".md");
|
|
17
|
+
if (pattern.endsWith("/**/*.md")) {
|
|
18
|
+
const base = pattern.replace(/\/\*\*\/.+$/, "");
|
|
19
|
+
return str.startsWith(base) && str.endsWith(".md");
|
|
20
|
+
}
|
|
21
|
+
return str === pattern;
|
|
22
|
+
},
|
|
23
|
+
};
|
|
24
|
+
}
|
|
25
|
+
// Local copy of DEFAULT_SYNC_PATHS to avoid cross-package TS rootDir issues
|
|
26
|
+
const DEFAULT_SYNC_PATHS = {
|
|
27
|
+
requirements: "requirements/**/*.md",
|
|
28
|
+
scenarios: "scenarios/**/*.md",
|
|
29
|
+
tests: "tests/**/*.md",
|
|
30
|
+
adr: "adr/**/*.md",
|
|
31
|
+
flags: "flags/**/*.md",
|
|
32
|
+
events: "events/**/*.md",
|
|
33
|
+
facts: "facts/**/*.md",
|
|
34
|
+
symbols: "symbols.yaml",
|
|
35
|
+
};
|
|
36
|
+
function loadSyncConfigLocal(cwd = process.cwd()) {
|
|
37
|
+
const configPath = path.join(cwd, ".kb/config.json");
|
|
38
|
+
let userConfig = {};
|
|
39
|
+
if (existsSync(configPath)) {
|
|
40
|
+
try {
|
|
41
|
+
userConfig = JSON.parse(readFileSync(configPath, "utf8")) || {};
|
|
42
|
+
}
|
|
43
|
+
catch {
|
|
44
|
+
userConfig = {};
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
return {
|
|
48
|
+
paths: {
|
|
49
|
+
...DEFAULT_SYNC_PATHS,
|
|
50
|
+
...(userConfig.paths ?? {}),
|
|
51
|
+
},
|
|
52
|
+
defaultBranch: userConfig.defaultBranch,
|
|
53
|
+
};
|
|
54
|
+
}
|
|
55
|
+
function loadKbSyncPaths(cwd = process.cwd()) {
|
|
56
|
+
const cfg = loadSyncConfigLocal(cwd);
|
|
57
|
+
return cfg.paths ?? DEFAULT_SYNC_PATHS;
|
|
58
|
+
}
|
|
59
|
+
function normalizePattern(p) {
|
|
60
|
+
if (!p)
|
|
61
|
+
return null;
|
|
62
|
+
// preserve explicit globs containing '*' or '/**'
|
|
63
|
+
if (p.includes("*"))
|
|
64
|
+
return p;
|
|
65
|
+
// symbols manifest is typically a file (yaml) - keep as-is
|
|
66
|
+
if (p.endsWith(".yaml") || p.endsWith(".yml") || path.extname(p))
|
|
67
|
+
return p;
|
|
68
|
+
// otherwise treat directory as markdown collection
|
|
69
|
+
return `${p.replace(/\/+$/, "")}/**/*.md`;
|
|
70
|
+
}
|
|
71
|
+
const DEFAULT_IGNORES = [
|
|
72
|
+
".kb/**",
|
|
73
|
+
".git/**",
|
|
74
|
+
"node_modules/**",
|
|
75
|
+
"dist/**",
|
|
76
|
+
"coverage/**",
|
|
77
|
+
".opencode/**",
|
|
78
|
+
"**/*~",
|
|
79
|
+
"**/~*",
|
|
80
|
+
"**/.#*",
|
|
81
|
+
"**/*.swp",
|
|
82
|
+
"**/*.swo",
|
|
83
|
+
"**/.DS_Store",
|
|
84
|
+
];
|
|
85
|
+
// implements REQ-opencode-kibi-plugin-v1
|
|
86
|
+
export function shouldHandleFile(filePath, cwd = process.cwd()) {
|
|
87
|
+
const rel = path.isAbsolute(filePath)
|
|
88
|
+
? path.relative(cwd, filePath).split(path.sep).join("/")
|
|
89
|
+
: filePath.split(path.sep).join("/");
|
|
90
|
+
const paths = loadKbSyncPaths(cwd);
|
|
91
|
+
// Build include patterns from kibi paths
|
|
92
|
+
const includeCandidates = [
|
|
93
|
+
paths.requirements,
|
|
94
|
+
paths.scenarios,
|
|
95
|
+
paths.tests,
|
|
96
|
+
paths.adr,
|
|
97
|
+
paths.flags,
|
|
98
|
+
paths.events,
|
|
99
|
+
paths.facts,
|
|
100
|
+
paths.symbols,
|
|
101
|
+
];
|
|
102
|
+
const includePatterns = includeCandidates
|
|
103
|
+
.map(normalizePattern)
|
|
104
|
+
.filter((p) => Boolean(p));
|
|
105
|
+
// default ignores then allow extension by .kb/config.json -> sync.ignore (not implemented here)
|
|
106
|
+
const ignorePatterns = DEFAULT_IGNORES;
|
|
107
|
+
// Compile matchers
|
|
108
|
+
const isIgnored = ignorePatterns.some((ig) => picomatch.isMatch(rel, ig));
|
|
109
|
+
if (isIgnored)
|
|
110
|
+
return false;
|
|
111
|
+
// If any include pattern matches, accept
|
|
112
|
+
const included = includePatterns.some((pat) => picomatch.isMatch(rel, pat));
|
|
113
|
+
if (included)
|
|
114
|
+
return true;
|
|
115
|
+
// If symbols path is configured as exact file and matches exactly, accept
|
|
116
|
+
if (paths.symbols) {
|
|
117
|
+
const sym = paths.symbols;
|
|
118
|
+
if (sym === rel || picomatch.isMatch(rel, sym))
|
|
119
|
+
return true;
|
|
120
|
+
}
|
|
121
|
+
return false;
|
|
122
|
+
}
|
|
123
|
+
export default shouldHandleFile;
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import * as config from "./config";
|
|
2
|
+
import * as fileFilter from "./file-filter";
|
|
3
|
+
import { SENTINEL, injectPrompt } from "./prompt";
|
|
4
|
+
import { createSyncScheduler } from "./scheduler";
|
|
5
|
+
import type { Hooks, Plugin, PluginInput } from "@opencode-ai/plugin";
|
|
6
|
+
export type { Plugin, PluginInput, Hooks };
|
|
7
|
+
declare const kibiOpencodePlugin: Plugin;
|
|
8
|
+
export default kibiOpencodePlugin;
|
|
9
|
+
export { config, fileFilter, createSyncScheduler, injectPrompt, SENTINEL };
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
import * as config from "./config";
|
|
2
|
+
import * as fileFilter from "./file-filter";
|
|
3
|
+
import * as logger from "./logger";
|
|
4
|
+
import { SENTINEL, injectPrompt } from "./prompt";
|
|
5
|
+
import { createSyncScheduler } from "./scheduler";
|
|
6
|
+
let scheduler = null;
|
|
7
|
+
let cfg = null;
|
|
8
|
+
// implements REQ-opencode-kibi-plugin-v1
|
|
9
|
+
const kibiOpencodePlugin = async (input) => {
|
|
10
|
+
// Load config
|
|
11
|
+
cfg = config.loadConfig(input.directory);
|
|
12
|
+
if (!cfg.enabled) {
|
|
13
|
+
logger.info("kibi-opencode: disabled via config");
|
|
14
|
+
return {};
|
|
15
|
+
}
|
|
16
|
+
logger.info("kibi-opencode: setting up hooks");
|
|
17
|
+
const hooks = {};
|
|
18
|
+
// Setup file-edit triggered sync via event hook
|
|
19
|
+
if (cfg.sync.enabled) {
|
|
20
|
+
const schedulerOpts = {
|
|
21
|
+
worktree: input.worktree,
|
|
22
|
+
config: cfg,
|
|
23
|
+
};
|
|
24
|
+
scheduler = createSyncScheduler(schedulerOpts);
|
|
25
|
+
hooks.event = async ({ event }) => {
|
|
26
|
+
if (event.type !== "file.edited")
|
|
27
|
+
return;
|
|
28
|
+
const filePath = event.properties.file;
|
|
29
|
+
if (!filePath)
|
|
30
|
+
return;
|
|
31
|
+
if (!fileFilter.shouldHandleFile(filePath, input.worktree))
|
|
32
|
+
return;
|
|
33
|
+
logger.info(`kibi-opencode: scheduling sync for ${filePath}`);
|
|
34
|
+
scheduler.scheduleSync("file.edited", filePath);
|
|
35
|
+
};
|
|
36
|
+
}
|
|
37
|
+
// Setup prompt injection hook
|
|
38
|
+
if (cfg.prompt.enabled) {
|
|
39
|
+
const hookMode = cfg.prompt.hookMode;
|
|
40
|
+
if (hookMode === "system-transform" || hookMode === "auto") {
|
|
41
|
+
hooks["experimental.chat.system.transform"] = async (_input, output) => {
|
|
42
|
+
const currentSystem = output.system.join("\n");
|
|
43
|
+
const injected = injectPrompt(currentSystem, cfg);
|
|
44
|
+
output.system.length = 0;
|
|
45
|
+
output.system.push(injected);
|
|
46
|
+
};
|
|
47
|
+
}
|
|
48
|
+
if (hookMode === "chat-params" || hookMode === "auto") {
|
|
49
|
+
hooks["chat.params"] = async (_input, _output) => {
|
|
50
|
+
// chat.params only exposes model options, not prompt text.
|
|
51
|
+
// In auto mode the system.transform hook handles injection;
|
|
52
|
+
// this hook is a no-op but kept registered so OpenCode knows
|
|
53
|
+
// the plugin is active.
|
|
54
|
+
if (hookMode === "auto") {
|
|
55
|
+
logger.info("kibi-opencode: chat.params hook active (prompt injection via system.transform)");
|
|
56
|
+
}
|
|
57
|
+
};
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
logger.info("kibi-opencode: setup complete");
|
|
61
|
+
return hooks;
|
|
62
|
+
};
|
|
63
|
+
export default kibiOpencodePlugin;
|
|
64
|
+
export { config, fileFilter, createSyncScheduler, injectPrompt, SENTINEL };
|
package/dist/logger.d.ts
ADDED
package/dist/logger.js
ADDED
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
// implements REQ-opencode-kibi-plugin-v1
|
|
2
|
+
export function info(msg) {
|
|
3
|
+
console.log("[kibi-opencode]", msg);
|
|
4
|
+
}
|
|
5
|
+
export function warn(msg) {
|
|
6
|
+
console.warn("[kibi-opencode]", msg);
|
|
7
|
+
}
|
|
8
|
+
// implements REQ-opencode-kibi-plugin-v1
|
|
9
|
+
export function error(msg) {
|
|
10
|
+
console.error("[kibi-opencode]", msg);
|
|
11
|
+
}
|
package/dist/prompt.d.ts
ADDED
package/dist/prompt.js
ADDED
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import { isPluginEnabled } from "./config";
|
|
2
|
+
const SENTINEL = "<!-- kibi-opencode -->";
|
|
3
|
+
const GUIDANCE = `${SENTINEL}
|
|
4
|
+
This project uses Kibi (via MCP). Prefer storing durable knowledge in Kibi over code comments.
|
|
5
|
+
|
|
6
|
+
Before changing behavior: query Kibi by sourceFile, id, type, or tags; do not rely on undocumented tools.
|
|
7
|
+
|
|
8
|
+
Keep changed symbols traceable: add \`// implements REQ-xxx\` to every new or modified function/class so the pre-commit hook can verify coverage.
|
|
9
|
+
|
|
10
|
+
Run kb_check after KB mutations.
|
|
11
|
+
|
|
12
|
+
**Kibi-first workflow:**
|
|
13
|
+
1. **Discover**: Run kb_query with filters (sourceFile, type, tags) to find related requirements, ADRs, tests, and symbols.
|
|
14
|
+
2. **Document intent**: If you are about to explain code, STOP. Route that explanation to kb_upsert instead of inline comments.
|
|
15
|
+
3. **Link during work**: When creating KB entities, include relationship rows: specified_by (req→scenario), verified_by (req→test), implements (symbol→req), covered_by (symbol→test).
|
|
16
|
+
4. **Validate**: Run kb_check after KB mutations to catch violations early.
|
|
17
|
+
|
|
18
|
+
**Public Kibi tools only:** kb_query, kb_upsert, kb_delete, kb_check.
|
|
19
|
+
|
|
20
|
+
Bootstrap existing repos: use \`/init-kibi\` to run the retroactive initialization workflow.`;
|
|
21
|
+
// implements REQ-opencode-kibi-plugin-v1
|
|
22
|
+
export function buildPrompt() {
|
|
23
|
+
return GUIDANCE.trim();
|
|
24
|
+
}
|
|
25
|
+
// implements REQ-opencode-kibi-plugin-v1
|
|
26
|
+
export function injectPrompt(current, config) {
|
|
27
|
+
if (!config.prompt.enabled || !isPluginEnabled(config)) {
|
|
28
|
+
return current;
|
|
29
|
+
}
|
|
30
|
+
if (current.includes(SENTINEL)) {
|
|
31
|
+
return current;
|
|
32
|
+
}
|
|
33
|
+
return `${current}\n\n${buildPrompt()}`;
|
|
34
|
+
}
|
|
35
|
+
export { SENTINEL };
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import type { KibiConfig } from "./config";
|
|
2
|
+
type TimeoutHandle = ReturnType<typeof setTimeout>;
|
|
3
|
+
export interface SyncRunMetadata {
|
|
4
|
+
reason: string;
|
|
5
|
+
worktree: string;
|
|
6
|
+
filePath?: string;
|
|
7
|
+
debounceWindowMs: number;
|
|
8
|
+
durationMs: number;
|
|
9
|
+
exitCode: number;
|
|
10
|
+
}
|
|
11
|
+
type SyncRunner = (worktree: string) => Promise<{
|
|
12
|
+
exitCode: number;
|
|
13
|
+
}>;
|
|
14
|
+
export interface SchedulerOptions {
|
|
15
|
+
worktree: string;
|
|
16
|
+
config: KibiConfig;
|
|
17
|
+
runSync?: SyncRunner;
|
|
18
|
+
now?: () => number;
|
|
19
|
+
setTimeoutFn?: (fn: () => void, ms: number) => TimeoutHandle;
|
|
20
|
+
clearTimeoutFn?: (handle: TimeoutHandle) => void;
|
|
21
|
+
onRunComplete?: (meta: SyncRunMetadata) => void;
|
|
22
|
+
enableToolExecuteAfterHint?: boolean;
|
|
23
|
+
}
|
|
24
|
+
export interface SyncScheduler {
|
|
25
|
+
scheduleSync(reason: string, filePath?: string): void;
|
|
26
|
+
onFileEdited(filePath: string): void;
|
|
27
|
+
onToolExecuteAfter(reason?: string): void;
|
|
28
|
+
dispose(): void;
|
|
29
|
+
}
|
|
30
|
+
export declare function createSyncScheduler(opts: SchedulerOptions): SyncScheduler;
|
|
31
|
+
export {};
|
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
import { exec } from "node:child_process";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { shouldHandleFile } from "./file-filter";
|
|
4
|
+
import * as logger from "./logger";
|
|
5
|
+
class WorktreeSyncScheduler {
|
|
6
|
+
worktree;
|
|
7
|
+
now;
|
|
8
|
+
setTimeoutFn;
|
|
9
|
+
clearTimeoutFn;
|
|
10
|
+
runSync;
|
|
11
|
+
config;
|
|
12
|
+
onRunComplete;
|
|
13
|
+
explicitToolAfterHint;
|
|
14
|
+
timer = null;
|
|
15
|
+
inFlight = false;
|
|
16
|
+
dirty = false;
|
|
17
|
+
pending = null;
|
|
18
|
+
trailing = null;
|
|
19
|
+
lastFileEditedAt = 0;
|
|
20
|
+
constructor(opts) {
|
|
21
|
+
this.worktree = path.resolve(opts.worktree);
|
|
22
|
+
this.config = opts.config;
|
|
23
|
+
this.now = opts.now ?? Date.now;
|
|
24
|
+
this.setTimeoutFn = opts.setTimeoutFn ?? setTimeout;
|
|
25
|
+
this.clearTimeoutFn = opts.clearTimeoutFn ?? clearTimeout;
|
|
26
|
+
this.runSync = opts.runSync ?? runKibiSync;
|
|
27
|
+
this.onRunComplete = opts.onRunComplete;
|
|
28
|
+
this.explicitToolAfterHint = Boolean(opts.enableToolExecuteAfterHint);
|
|
29
|
+
}
|
|
30
|
+
scheduleSync(reason, filePath) {
|
|
31
|
+
if (!this.config.sync.enabled)
|
|
32
|
+
return;
|
|
33
|
+
if (reason === "file.edited") {
|
|
34
|
+
if (!filePath)
|
|
35
|
+
return;
|
|
36
|
+
if (!shouldHandleFile(filePath, this.worktree))
|
|
37
|
+
return;
|
|
38
|
+
this.lastFileEditedAt = this.now();
|
|
39
|
+
}
|
|
40
|
+
this.pending = { reason, filePath };
|
|
41
|
+
if (this.timer)
|
|
42
|
+
this.clearTimeoutFn(this.timer);
|
|
43
|
+
this.timer = this.setTimeoutFn(() => {
|
|
44
|
+
this.timer = null;
|
|
45
|
+
this.flushPending();
|
|
46
|
+
}, this.config.sync.debounceMs);
|
|
47
|
+
}
|
|
48
|
+
onFileEdited(filePath) {
|
|
49
|
+
this.scheduleSync("file.edited", filePath);
|
|
50
|
+
}
|
|
51
|
+
onToolExecuteAfter(reason = "tool.execute.after") {
|
|
52
|
+
// Only proceed if tool.after notifications are enabled
|
|
53
|
+
if (!this.isToolExecuteAfterEnabled())
|
|
54
|
+
return;
|
|
55
|
+
// Reset debounce window by setting lastFileEditedAt to now
|
|
56
|
+
// This ensures the check at lines 97-100 won't allow sync through
|
|
57
|
+
const now = this.now();
|
|
58
|
+
this.lastFileEditedAt = now;
|
|
59
|
+
// Debounce check - if we just reset lastFileEditedAt, it will fail
|
|
60
|
+
if (now - this.lastFileEditedAt <= this.config.sync.debounceMs) {
|
|
61
|
+
return;
|
|
62
|
+
}
|
|
63
|
+
// Tool.after hint takes priority - skip sync scheduling when explicitly set to false
|
|
64
|
+
if (!this.explicitToolAfterHint) {
|
|
65
|
+
this.scheduleSync(reason);
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
dispose() {
|
|
69
|
+
if (this.timer) {
|
|
70
|
+
this.clearTimeoutFn(this.timer);
|
|
71
|
+
this.timer = null;
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
isToolExecuteAfterEnabled() {
|
|
75
|
+
if (this.explicitToolAfterHint)
|
|
76
|
+
return true;
|
|
77
|
+
return this.config.prompt.hookMode === "compat";
|
|
78
|
+
}
|
|
79
|
+
flushPending() {
|
|
80
|
+
if (!this.pending)
|
|
81
|
+
return;
|
|
82
|
+
const trigger = this.pending;
|
|
83
|
+
this.pending = null;
|
|
84
|
+
if (this.inFlight) {
|
|
85
|
+
this.dirty = true;
|
|
86
|
+
this.trailing = trigger;
|
|
87
|
+
return;
|
|
88
|
+
}
|
|
89
|
+
this.startRun(trigger);
|
|
90
|
+
}
|
|
91
|
+
startRun(trigger) {
|
|
92
|
+
this.inFlight = true;
|
|
93
|
+
const startedAt = this.now();
|
|
94
|
+
logger.info(`sync.started ${JSON.stringify({
|
|
95
|
+
reason: trigger.reason,
|
|
96
|
+
worktree: this.worktree,
|
|
97
|
+
filePath: trigger.filePath,
|
|
98
|
+
debounceWindowMs: this.config.sync.debounceMs,
|
|
99
|
+
})}`);
|
|
100
|
+
void this.runSync(this.worktree)
|
|
101
|
+
.then(({ exitCode }) => {
|
|
102
|
+
this.emitCompletion(trigger, startedAt, exitCode);
|
|
103
|
+
})
|
|
104
|
+
.catch((err) => {
|
|
105
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
106
|
+
logger.error(`sync.failed ${message}`);
|
|
107
|
+
this.emitCompletion(trigger, startedAt, 1);
|
|
108
|
+
})
|
|
109
|
+
.finally(() => {
|
|
110
|
+
this.inFlight = false;
|
|
111
|
+
if (!this.dirty)
|
|
112
|
+
return;
|
|
113
|
+
const trailing = this.trailing ?? { reason: "sync.trailing" };
|
|
114
|
+
this.dirty = false;
|
|
115
|
+
this.trailing = null;
|
|
116
|
+
this.startRun({
|
|
117
|
+
reason: `${trailing.reason}.trailing`,
|
|
118
|
+
filePath: trailing.filePath,
|
|
119
|
+
});
|
|
120
|
+
});
|
|
121
|
+
}
|
|
122
|
+
emitCompletion(trigger, startedAt, exitCode) {
|
|
123
|
+
const durationMs = Math.max(0, this.now() - startedAt);
|
|
124
|
+
const meta = {
|
|
125
|
+
reason: trigger.reason,
|
|
126
|
+
worktree: this.worktree,
|
|
127
|
+
filePath: trigger.filePath,
|
|
128
|
+
debounceWindowMs: this.config.sync.debounceMs,
|
|
129
|
+
durationMs,
|
|
130
|
+
exitCode,
|
|
131
|
+
};
|
|
132
|
+
if (exitCode === 0) {
|
|
133
|
+
logger.info(`sync.succeeded ${JSON.stringify(meta)}`);
|
|
134
|
+
}
|
|
135
|
+
else {
|
|
136
|
+
logger.warn(`sync.failed ${JSON.stringify(meta)}`);
|
|
137
|
+
}
|
|
138
|
+
this.onRunComplete?.(meta);
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
async function runKibiSync(worktree) {
|
|
142
|
+
return new Promise((resolve) => {
|
|
143
|
+
exec("kibi sync", { cwd: worktree }, (error) => {
|
|
144
|
+
resolve({ exitCode: error ? (error.code ?? 1) : 0 });
|
|
145
|
+
});
|
|
146
|
+
});
|
|
147
|
+
}
|
|
148
|
+
// implements REQ-opencode-kibi-plugin-v1
|
|
149
|
+
export function createSyncScheduler(opts) {
|
|
150
|
+
return new WorktreeSyncScheduler(opts);
|
|
151
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "kibi-opencode",
|
|
3
|
+
"version": "0.3.1",
|
|
4
|
+
"description": "Kibi OpenCode plugin - thin adapter to integrate Kibi with OpenCode sessions",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"main": "dist/index.js",
|
|
7
|
+
"types": "./dist/index.d.ts",
|
|
8
|
+
"exports": {
|
|
9
|
+
".": {
|
|
10
|
+
"types": "./dist/index.d.ts",
|
|
11
|
+
"import": "./dist/index.js",
|
|
12
|
+
"default": "./dist/index.js"
|
|
13
|
+
}
|
|
14
|
+
},
|
|
15
|
+
"files": [
|
|
16
|
+
"dist"
|
|
17
|
+
],
|
|
18
|
+
"engines": {
|
|
19
|
+
"node": ">=18"
|
|
20
|
+
},
|
|
21
|
+
"license": "AGPL-3.0-or-later",
|
|
22
|
+
"repository": {
|
|
23
|
+
"type": "git",
|
|
24
|
+
"url": "https://github.com/Looted/kibi.git"
|
|
25
|
+
},
|
|
26
|
+
"publishConfig": {
|
|
27
|
+
"access": "public"
|
|
28
|
+
},
|
|
29
|
+
"scripts": {
|
|
30
|
+
"build": "tsc -p tsconfig.json",
|
|
31
|
+
"dev": "tsc -p tsconfig.json --watch",
|
|
32
|
+
"clean": "rm -rf dist",
|
|
33
|
+
"prepack": "npm run build"
|
|
34
|
+
},
|
|
35
|
+
"dependencies": {
|
|
36
|
+
"@opencode-ai/plugin": "^1.2.26"
|
|
37
|
+
},
|
|
38
|
+
"devDependencies": {
|
|
39
|
+
"typescript": "^5.0.0"
|
|
40
|
+
}
|
|
41
|
+
}
|