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 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
@@ -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,2 @@
1
+ export declare function shouldHandleFile(filePath: string, cwd?: string): boolean;
2
+ export default shouldHandleFile;
@@ -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;
@@ -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 };
@@ -0,0 +1,3 @@
1
+ export declare function info(msg: string): void;
2
+ export declare function warn(msg: string): void;
3
+ export declare function error(msg: string): void;
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
+ }
@@ -0,0 +1,5 @@
1
+ import type { KibiConfig } from "./config";
2
+ declare const SENTINEL = "<!-- kibi-opencode -->";
3
+ export declare function buildPrompt(): string;
4
+ export declare function injectPrompt(current: string, config: KibiConfig): string;
5
+ export { SENTINEL };
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
+ }