kibi-opencode 0.15.4 → 0.18.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 +22 -3
- package/dist/auto-update.d.ts +31 -0
- package/dist/auto-update.js +396 -0
- package/dist/config.d.ts +1 -0
- package/dist/config.js +3 -0
- package/dist/enforcement-policy.js +8 -0
- package/dist/plugin.js +36 -5
- package/dist/scheduler.d.ts +3 -1
- package/dist/scheduler.js +76 -2
- package/dist/version-metadata.json +1 -1
- package/package.json +2 -2
package/README.md
CHANGED
|
@@ -22,6 +22,23 @@ Or via OpenCode's plugin system in `opencode.json`:
|
|
|
22
22
|
}
|
|
23
23
|
```
|
|
24
24
|
|
|
25
|
+
By default, the plugin checks npm on OpenCode startup and refreshes only the
|
|
26
|
+
cached `kibi-opencode` plugin package when a newer version is available. The
|
|
27
|
+
base `kibi-cli`, `kibi-mcp`, and `kibi-core` project dependencies are never
|
|
28
|
+
updated by this plugin updater.
|
|
29
|
+
|
|
30
|
+
Use an exact semver entry when you want a fixed plugin version:
|
|
31
|
+
|
|
32
|
+
```json
|
|
33
|
+
{
|
|
34
|
+
"plugin": ["kibi-opencode@0.15.0"]
|
|
35
|
+
}
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
Exact semver pins are respected: the updater will notify that a newer plugin is
|
|
39
|
+
available, but it will not replace the pinned version until you change the
|
|
40
|
+
plugin array. Dist-tags such as `kibi-opencode@latest` remain auto-updatable.
|
|
41
|
+
|
|
25
42
|
Configure the MCP server separately with the project-local `kibi-mcp` binary.
|
|
26
43
|
The plugin may run internal maintenance commands such as `kibi sync` and
|
|
27
44
|
`kibi check`, so the project-local `kibi` CLI should resolve from the OpenCode
|
|
@@ -39,7 +56,7 @@ The plugin now uses a posture-aware, low-token smart-enforcement model before em
|
|
|
39
56
|
- **Start-task risky cue**: authoritative risky edits also add a compact Kibi context cue so agents can start with explicit source-linked guidance before acting, while staying inside the same single prompt block and token budget.
|
|
40
57
|
- **Effective mode gating**: `advisory` (default) never blocks; `strict` escalates checks and reminders for `root_active` and `hybrid_root_plus_vendored` postures when `requireRootKbForStrict` is enabled; `hard` fails closed for authoritative roots and linked git worktrees, injecting a stop-state with MCP recovery steps until the Kibi checkpoint passes. `maintenanceDegraded` overrides everything back to `advisory`.
|
|
41
58
|
- **Low-token prompt policy**: docs-only and test-only edits avoid unnecessary discovery prompts; vendored-only repos suppress operational bootstrap nudges; at most one contextual block is injected per prompt (≤120 words, ≤5 bullets)
|
|
42
|
-
- **Completion reminder**: when `completionReminder` is enabled, risky code edits append a single prompt-visible `kb_check` reminder exactly once per cached context
|
|
59
|
+
- **Completion reminder**: when `completionReminder` is enabled, risky code edits append a single prompt-visible impact-enabled `kb_check` reminder exactly once per cached context
|
|
43
60
|
- **Runtime maintenance overlay**: static `maintenanceDegraded` from posture is merged with a latched runtime overlay (sync disabled/unavailable/failing) so degraded state is consistently reflected in prompts, logs, and mode decisions
|
|
44
61
|
- **Three-mode enforcement**: `advisory` by default keeps guidance non-blocking; opt-in `strict` adds elevated validation cues; `hard` blocks the prompt for authoritative workspaces until the checkpoint cycle succeeds. Git hooks and KB validation checks remain the durable enforcement boundary for all modes.
|
|
45
62
|
- **Structured observability**: posture, risk, cache, degraded-mode, targeted-check, and guidance events flow through structured plugin logs
|
|
@@ -58,11 +75,13 @@ modified, the plugin evaluates KB freshness evidence and surfaces a visible
|
|
|
58
75
|
discovery via `kb_search` or `kb_query(sourceFile=...)` and `kb_check`.
|
|
59
76
|
- **Deferred/Failed**: Do not claim task completion.
|
|
60
77
|
|
|
78
|
+
For meaningful source edits, the first gate is MCP `kb_check` with impact options, for example `kb_check({sourceFiles:["src/feature.ts"], includeImpactDiagnostics:true, includeWorkingTreeDiff:true})`. These diagnostics guide symbol granularity, requirement ownership, and semantic review of linked requirements/tests; they do not prove source behavior matches prose.
|
|
79
|
+
|
|
61
80
|
### Dynamic Contextual Guidance
|
|
62
81
|
|
|
63
82
|
The plugin provides context-aware prompt guidance based on recent edits and workspace state:
|
|
64
83
|
|
|
65
|
-
- **Code edits**: Guidance for querying Kibi by sourceFile, preferring Kibi over comments, and adding
|
|
84
|
+
- **Code edits**: Guidance for running impact-enabled `kb_check`, querying Kibi by sourceFile, preferring Kibi over comments, and adding or updating symbol traceability
|
|
66
85
|
- **Requirement edits**: Guidance for maintaining separate REQ/SCEN/TEST artifacts and avoiding embedded scenarios
|
|
67
86
|
- **KB doc edits**: Guidance for proper entity relationships and validation
|
|
68
87
|
- **Bootstrap needed**: Detection and nudges for uninitialized repos
|
|
@@ -177,6 +196,7 @@ Config files (project overrides global):
|
|
|
177
196
|
| Key | Type | Default | Description |
|
|
178
197
|
|-----|------|---------|-------------|
|
|
179
198
|
| `enabled` | boolean | `true` | Enable/disable all plugin features |
|
|
199
|
+
| `autoUpdate` | boolean | `true` | Automatically refresh the cached `kibi-opencode` plugin package on OpenCode startup unless the plugin array uses an exact semver pin |
|
|
180
200
|
| `prompt.enabled` | boolean | `true` | Enable prompt guidance injection |
|
|
181
201
|
| `prompt.hookMode` | string | `"auto"` | Hook mode: `auto`, `chat-params`, `system-transform`, `compat` |
|
|
182
202
|
| `sync.enabled` | boolean | `true` | Enable automatic sync |
|
|
@@ -187,7 +207,6 @@ Config files (project overrides global):
|
|
|
187
207
|
| `ux.toastFailures` | boolean | `true` | Show failure toasts for sync/check issues |
|
|
188
208
|
| `ux.toastSuccesses` | boolean | `false` | Show success toasts for sync/check completion |
|
|
189
209
|
| `ux.toastCooldownMs` | number | `10000` | Cooldown between repeated UX toasts |
|
|
190
|
-
PP|| `guidance.dynamic` | boolean | `true` | Enable dynamic contextual guidance |
|
|
191
210
|
| `guidance.dynamic` | boolean | `true` | Enable dynamic contextual guidance |
|
|
192
211
|
| `guidance.warnOnKbEdits` | boolean | `true` | Enable loud warnings for .kb/** edits |
|
|
193
212
|
| `guidance.factFirstDomainRouting` | boolean | `true` | Enable FACT-first domain routing suggestions |
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
export interface KibiOpencodePluginEntry {
|
|
2
|
+
entry: string;
|
|
3
|
+
isPinned: boolean;
|
|
4
|
+
requestedVersion: string | null;
|
|
5
|
+
configPath: string;
|
|
6
|
+
}
|
|
7
|
+
export type AutoUpdateStatus = "disabled" | "plugin-not-found" | "current-version-unknown" | "latest-version-unknown" | "up-to-date" | "pinned" | "updated" | "install-failed";
|
|
8
|
+
export interface AutoUpdateResult {
|
|
9
|
+
status: AutoUpdateStatus;
|
|
10
|
+
currentVersion?: string;
|
|
11
|
+
latestVersion?: string;
|
|
12
|
+
}
|
|
13
|
+
export interface AutoUpdateInput {
|
|
14
|
+
directory: string;
|
|
15
|
+
enabled: boolean;
|
|
16
|
+
}
|
|
17
|
+
export interface AutoUpdateRunnerDeps {
|
|
18
|
+
getCurrentVersion: () => string | null;
|
|
19
|
+
getLatestVersion: (channel?: string) => Promise<string | null>;
|
|
20
|
+
invalidatePackage: () => boolean;
|
|
21
|
+
runInstall: () => Promise<boolean>;
|
|
22
|
+
notify: (message: string) => Promise<void>;
|
|
23
|
+
log: (message: string, metadata?: Record<string, unknown>) => void;
|
|
24
|
+
}
|
|
25
|
+
export declare function findKibiOpencodePluginEntry(directory: string): KibiOpencodePluginEntry | null;
|
|
26
|
+
export declare function getCachedPluginVersion(): string | null;
|
|
27
|
+
export declare function getLatestPluginVersion(channel?: string): Promise<string | null>;
|
|
28
|
+
export declare function invalidateKibiOpencodePackage(): boolean;
|
|
29
|
+
export declare function runBunInstallForOpenCodePlugin(): Promise<boolean>;
|
|
30
|
+
export declare function createAutoUpdateRunner(deps: AutoUpdateRunnerDeps): (input: AutoUpdateInput) => Promise<AutoUpdateResult>;
|
|
31
|
+
export declare const runKibiOpencodeAutoUpdate: (input: AutoUpdateInput) => Promise<AutoUpdateResult>;
|
|
@@ -0,0 +1,396 @@
|
|
|
1
|
+
import { execFile } from "node:child_process";
|
|
2
|
+
import * as fs from "node:fs";
|
|
3
|
+
import * as os from "node:os";
|
|
4
|
+
import * as path from "node:path";
|
|
5
|
+
import { fileURLToPath } from "node:url";
|
|
6
|
+
import * as logger from "./logger.js";
|
|
7
|
+
const PACKAGE_NAME = "kibi-opencode";
|
|
8
|
+
const NPM_DIST_TAGS_URL = `https://registry.npmjs.org/-/package/${PACKAGE_NAME}/dist-tags`;
|
|
9
|
+
const NPM_FETCH_TIMEOUT_MS = 5000;
|
|
10
|
+
const EXACT_SEMVER_REGEX = /^\d+\.\d+\.\d+(?:-[0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*)?(?:\+[0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*)?$/;
|
|
11
|
+
function parseJsonObject(raw) {
|
|
12
|
+
try {
|
|
13
|
+
const parsed = JSON.parse(raw);
|
|
14
|
+
if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) {
|
|
15
|
+
return parsed;
|
|
16
|
+
}
|
|
17
|
+
return null;
|
|
18
|
+
}
|
|
19
|
+
catch (err) {
|
|
20
|
+
logger.info("auto-update: failed to parse JSON", {
|
|
21
|
+
error: err instanceof Error ? err.message : String(err),
|
|
22
|
+
});
|
|
23
|
+
return null;
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
function stripJsonComments(raw) {
|
|
27
|
+
return raw.replace(/\/\*[\s\S]*?\*\//g, "").replace(/(^|\s)\/\/.*$/gm, "$1");
|
|
28
|
+
}
|
|
29
|
+
function configPaths(directory) {
|
|
30
|
+
const home = os.homedir();
|
|
31
|
+
return [
|
|
32
|
+
path.join(directory, "opencode.json"),
|
|
33
|
+
path.join(directory, "opencode.jsonc"),
|
|
34
|
+
path.join(directory, ".opencode", "opencode.json"),
|
|
35
|
+
path.join(directory, ".opencode", "opencode.jsonc"),
|
|
36
|
+
path.join(home, ".config", "opencode", "opencode.json"),
|
|
37
|
+
path.join(home, ".config", "opencode", "opencode.jsonc"),
|
|
38
|
+
];
|
|
39
|
+
}
|
|
40
|
+
export function findKibiOpencodePluginEntry(directory) {
|
|
41
|
+
for (const configPath of configPaths(directory)) {
|
|
42
|
+
if (!fs.existsSync(configPath)) {
|
|
43
|
+
continue;
|
|
44
|
+
}
|
|
45
|
+
const parsed = parseJsonObject(stripJsonComments(fs.readFileSync(configPath, "utf8")));
|
|
46
|
+
const plugins = parsed?.plugin;
|
|
47
|
+
if (!Array.isArray(plugins)) {
|
|
48
|
+
continue;
|
|
49
|
+
}
|
|
50
|
+
for (const candidate of plugins) {
|
|
51
|
+
if (typeof candidate !== "string") {
|
|
52
|
+
continue;
|
|
53
|
+
}
|
|
54
|
+
if (candidate === PACKAGE_NAME) {
|
|
55
|
+
return {
|
|
56
|
+
entry: candidate,
|
|
57
|
+
isPinned: false,
|
|
58
|
+
requestedVersion: null,
|
|
59
|
+
configPath,
|
|
60
|
+
};
|
|
61
|
+
}
|
|
62
|
+
if (candidate.startsWith(`${PACKAGE_NAME}@`)) {
|
|
63
|
+
const requestedVersion = candidate
|
|
64
|
+
.slice(PACKAGE_NAME.length + 1)
|
|
65
|
+
.trim();
|
|
66
|
+
return {
|
|
67
|
+
entry: candidate,
|
|
68
|
+
isPinned: EXACT_SEMVER_REGEX.test(requestedVersion),
|
|
69
|
+
requestedVersion,
|
|
70
|
+
configPath,
|
|
71
|
+
};
|
|
72
|
+
}
|
|
73
|
+
if (candidate.startsWith("file://") && candidate.includes(PACKAGE_NAME)) {
|
|
74
|
+
return {
|
|
75
|
+
entry: candidate,
|
|
76
|
+
isPinned: true,
|
|
77
|
+
requestedVersion: null,
|
|
78
|
+
configPath,
|
|
79
|
+
};
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
return null;
|
|
84
|
+
}
|
|
85
|
+
function getOpenCodeConfigDir() {
|
|
86
|
+
if (process.platform === "win32") {
|
|
87
|
+
const appData = process.env.APPDATA ?? path.join(os.homedir(), "AppData", "Roaming");
|
|
88
|
+
return path.join(appData, "opencode");
|
|
89
|
+
}
|
|
90
|
+
return path.join(os.homedir(), ".config", "opencode");
|
|
91
|
+
}
|
|
92
|
+
function getOpenCodeCacheRoot() {
|
|
93
|
+
if (process.platform === "win32") {
|
|
94
|
+
const localAppData = process.env.LOCALAPPDATA ?? path.join(os.homedir(), "AppData", "Local");
|
|
95
|
+
return path.join(localAppData, "opencode");
|
|
96
|
+
}
|
|
97
|
+
if (process.platform === "darwin") {
|
|
98
|
+
return path.join(os.homedir(), "Library", "Caches", "opencode");
|
|
99
|
+
}
|
|
100
|
+
return path.join(process.env.XDG_CACHE_HOME ?? path.join(os.homedir(), ".cache"), "opencode");
|
|
101
|
+
}
|
|
102
|
+
function getOpenCodePackagesCacheDir() {
|
|
103
|
+
return path.join(getOpenCodeCacheRoot(), "packages");
|
|
104
|
+
}
|
|
105
|
+
function parseSemver(version) {
|
|
106
|
+
const match = version.match(/^(\d+)\.(\d+)\.(\d+)(?:-([0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*))?(?:\+[0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*)?$/);
|
|
107
|
+
if (!match) {
|
|
108
|
+
return null;
|
|
109
|
+
}
|
|
110
|
+
const major = Number(match[1]);
|
|
111
|
+
const minor = Number(match[2]);
|
|
112
|
+
const patch = Number(match[3]);
|
|
113
|
+
if (!Number.isInteger(major) ||
|
|
114
|
+
!Number.isInteger(minor) ||
|
|
115
|
+
!Number.isInteger(patch)) {
|
|
116
|
+
return null;
|
|
117
|
+
}
|
|
118
|
+
return {
|
|
119
|
+
core: [major, minor, patch],
|
|
120
|
+
prerelease: match[4]?.split(".") ?? [],
|
|
121
|
+
};
|
|
122
|
+
}
|
|
123
|
+
function isNumericPrereleaseIdentifier(value) {
|
|
124
|
+
return /^(0|[1-9]\d*)$/.test(value);
|
|
125
|
+
}
|
|
126
|
+
function comparePrereleaseIdentifiers(left, right) {
|
|
127
|
+
const leftIsNumeric = isNumericPrereleaseIdentifier(left);
|
|
128
|
+
const rightIsNumeric = isNumericPrereleaseIdentifier(right);
|
|
129
|
+
if (leftIsNumeric && rightIsNumeric) {
|
|
130
|
+
return Number(left) - Number(right);
|
|
131
|
+
}
|
|
132
|
+
if (leftIsNumeric) {
|
|
133
|
+
return -1;
|
|
134
|
+
}
|
|
135
|
+
if (rightIsNumeric) {
|
|
136
|
+
return 1;
|
|
137
|
+
}
|
|
138
|
+
return left.localeCompare(right, "en", { sensitivity: "variant" });
|
|
139
|
+
}
|
|
140
|
+
function comparePrerelease(left, right) {
|
|
141
|
+
if (left.length === 0 && right.length === 0) {
|
|
142
|
+
return 0;
|
|
143
|
+
}
|
|
144
|
+
if (left.length === 0) {
|
|
145
|
+
return 1;
|
|
146
|
+
}
|
|
147
|
+
if (right.length === 0) {
|
|
148
|
+
return -1;
|
|
149
|
+
}
|
|
150
|
+
const maxLength = Math.max(left.length, right.length);
|
|
151
|
+
for (let index = 0; index < maxLength; index += 1) {
|
|
152
|
+
const leftPart = left[index];
|
|
153
|
+
const rightPart = right[index];
|
|
154
|
+
if (leftPart === undefined) {
|
|
155
|
+
return -1;
|
|
156
|
+
}
|
|
157
|
+
if (rightPart === undefined) {
|
|
158
|
+
return 1;
|
|
159
|
+
}
|
|
160
|
+
const comparison = comparePrereleaseIdentifiers(leftPart, rightPart);
|
|
161
|
+
if (comparison !== 0) {
|
|
162
|
+
return comparison;
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
return 0;
|
|
166
|
+
}
|
|
167
|
+
function isSemverGreaterThan(candidate, current) {
|
|
168
|
+
const candidateVersion = parseSemver(candidate);
|
|
169
|
+
const currentVersion = parseSemver(current);
|
|
170
|
+
if (!candidateVersion || !currentVersion) {
|
|
171
|
+
return false;
|
|
172
|
+
}
|
|
173
|
+
for (const index of [0, 1, 2]) {
|
|
174
|
+
if (candidateVersion.core[index] > currentVersion.core[index]) {
|
|
175
|
+
return true;
|
|
176
|
+
}
|
|
177
|
+
if (candidateVersion.core[index] < currentVersion.core[index]) {
|
|
178
|
+
return false;
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
return (comparePrerelease(candidateVersion.prerelease, currentVersion.prerelease) >
|
|
182
|
+
0);
|
|
183
|
+
}
|
|
184
|
+
function readVersionFromPackageJson(packageJsonPath) {
|
|
185
|
+
if (!fs.existsSync(packageJsonPath)) {
|
|
186
|
+
return null;
|
|
187
|
+
}
|
|
188
|
+
const parsed = parseJsonObject(fs.readFileSync(packageJsonPath, "utf8"));
|
|
189
|
+
return typeof parsed?.version === "string" ? parsed.version : null;
|
|
190
|
+
}
|
|
191
|
+
function findPackageJsonUp(startUrl) {
|
|
192
|
+
let current = path.dirname(fileURLToPath(startUrl));
|
|
193
|
+
while (true) {
|
|
194
|
+
const packageJsonPath = path.join(current, "package.json");
|
|
195
|
+
if (fs.existsSync(packageJsonPath)) {
|
|
196
|
+
const parsed = parseJsonObject(fs.readFileSync(packageJsonPath, "utf8"));
|
|
197
|
+
if (parsed?.name === PACKAGE_NAME) {
|
|
198
|
+
return packageJsonPath;
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
const parent = path.dirname(current);
|
|
202
|
+
if (parent === current) {
|
|
203
|
+
return null;
|
|
204
|
+
}
|
|
205
|
+
current = parent;
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
export function getCachedPluginVersion() {
|
|
209
|
+
const packageJsonCandidates = [
|
|
210
|
+
path.join(getOpenCodeConfigDir(), "node_modules", PACKAGE_NAME, "package.json"),
|
|
211
|
+
path.join(getOpenCodeCacheRoot(), "node_modules", PACKAGE_NAME, "package.json"),
|
|
212
|
+
path.join(getOpenCodePackagesCacheDir(), "node_modules", PACKAGE_NAME, "package.json"),
|
|
213
|
+
];
|
|
214
|
+
for (const candidate of packageJsonCandidates) {
|
|
215
|
+
const version = readVersionFromPackageJson(candidate);
|
|
216
|
+
if (version) {
|
|
217
|
+
return version;
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
const runningPackageJson = findPackageJsonUp(import.meta.url);
|
|
221
|
+
return runningPackageJson
|
|
222
|
+
? readVersionFromPackageJson(runningPackageJson)
|
|
223
|
+
: null;
|
|
224
|
+
}
|
|
225
|
+
export async function getLatestPluginVersion(channel = "latest") {
|
|
226
|
+
const controller = new AbortController();
|
|
227
|
+
const timeout = setTimeout(() => controller.abort(), NPM_FETCH_TIMEOUT_MS);
|
|
228
|
+
try {
|
|
229
|
+
const response = await fetch(NPM_DIST_TAGS_URL, {
|
|
230
|
+
signal: controller.signal,
|
|
231
|
+
headers: { Accept: "application/json" },
|
|
232
|
+
});
|
|
233
|
+
if (!response.ok) {
|
|
234
|
+
return null;
|
|
235
|
+
}
|
|
236
|
+
const tags = (await response.json());
|
|
237
|
+
const selected = tags[channel] ?? tags.latest;
|
|
238
|
+
return typeof selected === "string" ? selected : null;
|
|
239
|
+
}
|
|
240
|
+
catch (err) {
|
|
241
|
+
logger.info("auto-update: failed to fetch latest version", {
|
|
242
|
+
error: err instanceof Error ? err.message : String(err),
|
|
243
|
+
});
|
|
244
|
+
return null;
|
|
245
|
+
}
|
|
246
|
+
finally {
|
|
247
|
+
clearTimeout(timeout);
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
function removePackageDir(dir) {
|
|
251
|
+
if (!fs.existsSync(dir)) {
|
|
252
|
+
return false;
|
|
253
|
+
}
|
|
254
|
+
fs.rmSync(dir, { recursive: true, force: true });
|
|
255
|
+
return true;
|
|
256
|
+
}
|
|
257
|
+
function removeSpecifierCacheDirs(cacheDir) {
|
|
258
|
+
if (!fs.existsSync(cacheDir)) {
|
|
259
|
+
return false;
|
|
260
|
+
}
|
|
261
|
+
let removed = false;
|
|
262
|
+
for (const entry of fs.readdirSync(cacheDir, { withFileTypes: true })) {
|
|
263
|
+
if (entry.isDirectory() && entry.name.startsWith(`${PACKAGE_NAME}@`)) {
|
|
264
|
+
fs.rmSync(path.join(cacheDir, entry.name), {
|
|
265
|
+
recursive: true,
|
|
266
|
+
force: true,
|
|
267
|
+
});
|
|
268
|
+
removed = true;
|
|
269
|
+
}
|
|
270
|
+
}
|
|
271
|
+
return removed;
|
|
272
|
+
}
|
|
273
|
+
function removeEveryPath(paths) {
|
|
274
|
+
let removed = false;
|
|
275
|
+
for (const targetPath of paths) {
|
|
276
|
+
if (removePackageDir(targetPath)) {
|
|
277
|
+
removed = true;
|
|
278
|
+
}
|
|
279
|
+
}
|
|
280
|
+
return removed;
|
|
281
|
+
}
|
|
282
|
+
function removeSpecifiersFromEveryDir(dirs) {
|
|
283
|
+
let removed = false;
|
|
284
|
+
for (const dir of dirs) {
|
|
285
|
+
if (removeSpecifierCacheDirs(dir)) {
|
|
286
|
+
removed = true;
|
|
287
|
+
}
|
|
288
|
+
}
|
|
289
|
+
return removed;
|
|
290
|
+
}
|
|
291
|
+
export function invalidateKibiOpencodePackage() {
|
|
292
|
+
const configDir = getOpenCodeConfigDir();
|
|
293
|
+
const cacheRoot = getOpenCodeCacheRoot();
|
|
294
|
+
const packagesCacheDir = getOpenCodePackagesCacheDir();
|
|
295
|
+
const removedPackageDirs = removeEveryPath([
|
|
296
|
+
path.join(configDir, "node_modules", PACKAGE_NAME),
|
|
297
|
+
path.join(cacheRoot, "node_modules", PACKAGE_NAME),
|
|
298
|
+
path.join(packagesCacheDir, "node_modules", PACKAGE_NAME),
|
|
299
|
+
]);
|
|
300
|
+
const removedSpecifierDirs = removeSpecifiersFromEveryDir([
|
|
301
|
+
cacheRoot,
|
|
302
|
+
packagesCacheDir,
|
|
303
|
+
]);
|
|
304
|
+
const removedLock = removeEveryPath([
|
|
305
|
+
path.join(cacheRoot, "bun.lock"),
|
|
306
|
+
path.join(cacheRoot, "bun.lockb"),
|
|
307
|
+
path.join(packagesCacheDir, "bun.lock"),
|
|
308
|
+
path.join(packagesCacheDir, "bun.lockb"),
|
|
309
|
+
]);
|
|
310
|
+
return removedPackageDirs || removedSpecifierDirs || removedLock;
|
|
311
|
+
}
|
|
312
|
+
function activeInstallWorkspace() {
|
|
313
|
+
const configDir = getOpenCodeConfigDir();
|
|
314
|
+
const cacheRoot = getOpenCodeCacheRoot();
|
|
315
|
+
const packagesCacheDir = getOpenCodePackagesCacheDir();
|
|
316
|
+
if (fs.existsSync(path.join(configDir, "node_modules", PACKAGE_NAME, "package.json"))) {
|
|
317
|
+
return configDir;
|
|
318
|
+
}
|
|
319
|
+
if (fs.existsSync(path.join(cacheRoot, "node_modules", PACKAGE_NAME, "package.json"))) {
|
|
320
|
+
return cacheRoot;
|
|
321
|
+
}
|
|
322
|
+
return packagesCacheDir;
|
|
323
|
+
}
|
|
324
|
+
export function runBunInstallForOpenCodePlugin() {
|
|
325
|
+
return new Promise((resolve) => {
|
|
326
|
+
const workspace = activeInstallWorkspace();
|
|
327
|
+
fs.mkdirSync(workspace, { recursive: true });
|
|
328
|
+
execFile("bun", ["install"], { cwd: workspace }, (error) => {
|
|
329
|
+
resolve(error === null);
|
|
330
|
+
});
|
|
331
|
+
});
|
|
332
|
+
}
|
|
333
|
+
function channelForEntry(entry) {
|
|
334
|
+
if (!entry.requestedVersion || entry.isPinned) {
|
|
335
|
+
return "latest";
|
|
336
|
+
}
|
|
337
|
+
return entry.requestedVersion;
|
|
338
|
+
}
|
|
339
|
+
export function createAutoUpdateRunner(deps) {
|
|
340
|
+
return async function runAutoUpdate(input) {
|
|
341
|
+
if (!input.enabled) {
|
|
342
|
+
deps.log("auto-update: disabled");
|
|
343
|
+
return { status: "disabled" };
|
|
344
|
+
}
|
|
345
|
+
const pluginEntry = findKibiOpencodePluginEntry(input.directory);
|
|
346
|
+
if (!pluginEntry) {
|
|
347
|
+
deps.log("auto-update: kibi-opencode plugin entry not found");
|
|
348
|
+
return { status: "plugin-not-found" };
|
|
349
|
+
}
|
|
350
|
+
const currentVersion = deps.getCurrentVersion() ?? pluginEntry.requestedVersion;
|
|
351
|
+
if (!currentVersion) {
|
|
352
|
+
deps.log("auto-update: current version unknown");
|
|
353
|
+
return { status: "current-version-unknown" };
|
|
354
|
+
}
|
|
355
|
+
const latestVersion = await deps.getLatestVersion(channelForEntry(pluginEntry));
|
|
356
|
+
if (!latestVersion) {
|
|
357
|
+
deps.log("auto-update: latest version unknown");
|
|
358
|
+
return { status: "latest-version-unknown", currentVersion };
|
|
359
|
+
}
|
|
360
|
+
if (currentVersion === latestVersion ||
|
|
361
|
+
!isSemverGreaterThan(latestVersion, currentVersion)) {
|
|
362
|
+
deps.log("auto-update: plugin already up to date", {
|
|
363
|
+
currentVersion,
|
|
364
|
+
latestVersion,
|
|
365
|
+
});
|
|
366
|
+
return { status: "up-to-date", currentVersion, latestVersion };
|
|
367
|
+
}
|
|
368
|
+
if (pluginEntry.isPinned) {
|
|
369
|
+
await deps.notify(`${PACKAGE_NAME} ${latestVersion} is available; current plugin entry is pinned to ${currentVersion}.`);
|
|
370
|
+
deps.log("auto-update: exact semver pin detected, skipping install", {
|
|
371
|
+
currentVersion,
|
|
372
|
+
latestVersion,
|
|
373
|
+
entry: pluginEntry.entry,
|
|
374
|
+
});
|
|
375
|
+
return { status: "pinned", currentVersion, latestVersion };
|
|
376
|
+
}
|
|
377
|
+
deps.invalidatePackage();
|
|
378
|
+
const installed = await deps.runInstall();
|
|
379
|
+
if (!installed) {
|
|
380
|
+
await deps.notify(`${PACKAGE_NAME} ${latestVersion} is available, but automatic install failed.`);
|
|
381
|
+
return { status: "install-failed", currentVersion, latestVersion };
|
|
382
|
+
}
|
|
383
|
+
await deps.notify(`${PACKAGE_NAME} updated from ${currentVersion} to ${latestVersion}. Restart OpenCode to apply.`);
|
|
384
|
+
return { status: "updated", currentVersion, latestVersion };
|
|
385
|
+
};
|
|
386
|
+
}
|
|
387
|
+
export const runKibiOpencodeAutoUpdate = createAutoUpdateRunner({
|
|
388
|
+
getCurrentVersion: getCachedPluginVersion,
|
|
389
|
+
getLatestVersion: getLatestPluginVersion,
|
|
390
|
+
invalidatePackage: invalidateKibiOpencodePackage,
|
|
391
|
+
runInstall: runBunInstallForOpenCodePlugin,
|
|
392
|
+
notify: async (message) => {
|
|
393
|
+
logger.info("auto-update.notification", { message });
|
|
394
|
+
},
|
|
395
|
+
log: (message, metadata) => logger.info(message, metadata),
|
|
396
|
+
});
|
package/dist/config.d.ts
CHANGED
package/dist/config.js
CHANGED
|
@@ -4,6 +4,7 @@ import path from "node:path";
|
|
|
4
4
|
import * as logger from "./logger.js";
|
|
5
5
|
const DEFAULTS = {
|
|
6
6
|
enabled: true,
|
|
7
|
+
autoUpdate: true,
|
|
7
8
|
prompt: { enabled: true, hookMode: "auto" },
|
|
8
9
|
sync: { enabled: true, debounceMs: 2000, ignore: [], relevant: [] },
|
|
9
10
|
ux: {
|
|
@@ -64,6 +65,8 @@ export function validateAndMerge(obj) {
|
|
|
64
65
|
const out = { ...DEFAULTS };
|
|
65
66
|
if (typeof src.enabled === "boolean")
|
|
66
67
|
out.enabled = src.enabled;
|
|
68
|
+
if (typeof src.autoUpdate === "boolean")
|
|
69
|
+
out.autoUpdate = src.autoUpdate;
|
|
67
70
|
if (src.prompt && typeof src.prompt === "object") {
|
|
68
71
|
const p = src.prompt;
|
|
69
72
|
out.prompt = { ...DEFAULTS.prompt };
|
|
@@ -25,6 +25,7 @@ const ADVISORY_EDIT_KINDS = new Set([
|
|
|
25
25
|
"symbol",
|
|
26
26
|
"kb",
|
|
27
27
|
]);
|
|
28
|
+
const SOURCE_IMPACT_KINDS = new Set(["code"]);
|
|
28
29
|
function normalizeCheckpointEvidence(evidence) {
|
|
29
30
|
if (typeof evidence === "boolean") {
|
|
30
31
|
return evidence;
|
|
@@ -126,6 +127,13 @@ function advisoryText(event) {
|
|
|
126
127
|
return "- New file detected. Add or update the necessary Kibi entities and traceability before completing this task.";
|
|
127
128
|
}
|
|
128
129
|
if (event.lifecycle === "edited") {
|
|
130
|
+
if (SOURCE_IMPACT_KINDS.has(event.pathKind)) {
|
|
131
|
+
const impactCheckCall = `kb_check({sourceFiles:${JSON.stringify([event.normalizedPath])}, includeImpactDiagnostics:true, includeWorkingTreeDiff:true})`;
|
|
132
|
+
return [
|
|
133
|
+
`- Edited source file detected. Run \`${impactCheckCall}\` while the edit context is fresh.`,
|
|
134
|
+
" Use the impact diagnostics to review symbol granularity, requirement ownership, and semantic review of linked requirements/tests before completing this task.",
|
|
135
|
+
].join("\n");
|
|
136
|
+
}
|
|
129
137
|
return `- Edited file detected. Review Kibi traceability for ${event.normalizedPath} before completing this task.`;
|
|
130
138
|
}
|
|
131
139
|
const ids = event.linkedEntityResult.ids;
|
package/dist/plugin.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import * as path from "node:path";
|
|
2
|
+
import { createAutoUpdateRunner, getLatestPluginVersion, invalidateKibiOpencodePackage, runBunInstallForOpenCodePlugin, } from "./auto-update.js";
|
|
2
3
|
import { analyzeCodeFile, } from "./comment-analysis.js";
|
|
3
4
|
import { getE2eCoverageSignal } from "./e2e-coverage-signals.js"; // implements REQ-opencode-file-context-guidance-v1
|
|
4
5
|
import { buildDirtyRelevantFingerprint, buildEnforcementScopeKey, } from "./enforcement-scope.js";
|
|
@@ -20,6 +21,7 @@ import { createSyncScheduler } from "./scheduler.js";
|
|
|
20
21
|
import { createSessionEditState, } from "./session-edit-state.js";
|
|
21
22
|
import { getSessionTracker } from "./session-tracker.js";
|
|
22
23
|
import { notifyStartup, } from "./startup-notifier.js";
|
|
24
|
+
import { sendToast, } from "./toast.js";
|
|
23
25
|
import { readKibiPackageVersions } from "./version-metadata.js";
|
|
24
26
|
import { resolveWorkContext, } from "./work-context-resolver.js";
|
|
25
27
|
import * as fs from "node:fs";
|
|
@@ -1044,7 +1046,7 @@ const kibiOpencodePlugin = async (input) => {
|
|
|
1044
1046
|
}
|
|
1045
1047
|
}
|
|
1046
1048
|
logger.info("kibi-opencode: setup complete");
|
|
1047
|
-
if (input.client
|
|
1049
|
+
if (input.client) {
|
|
1048
1050
|
const client = input.client;
|
|
1049
1051
|
const scheduleStartupNotify = startupNotifyGlobals.__kibi_test_schedule_startup_notify ??
|
|
1050
1052
|
((callback, delayMs) => {
|
|
@@ -1057,11 +1059,40 @@ const kibiOpencodePlugin = async (input) => {
|
|
|
1057
1059
|
if (meta[key] !== "unknown")
|
|
1058
1060
|
versions[key] = meta[key];
|
|
1059
1061
|
}
|
|
1060
|
-
|
|
1061
|
-
|
|
1062
|
+
if (!maintenanceDegraded) {
|
|
1063
|
+
notifyStartup(makeStartupClient(client), {
|
|
1064
|
+
suppressToast: cfg.ux.toastStartup === false,
|
|
1065
|
+
directory: input.directory,
|
|
1066
|
+
...(Object.keys(versions).length > 0 ? { versions } : {}),
|
|
1067
|
+
versionMetadataSource: meta.source,
|
|
1068
|
+
});
|
|
1069
|
+
}
|
|
1070
|
+
const autoUpdateRunner = createAutoUpdateRunner({
|
|
1071
|
+
getCurrentVersion: () => meta.opencode === "unknown" ? null : meta.opencode,
|
|
1072
|
+
getLatestVersion: getLatestPluginVersion,
|
|
1073
|
+
invalidatePackage: invalidateKibiOpencodePackage,
|
|
1074
|
+
runInstall: runBunInstallForOpenCodePlugin,
|
|
1075
|
+
notify: async (message) => {
|
|
1076
|
+
if (cfg.ux.toastStartup !== false) {
|
|
1077
|
+
await sendToast(makeToastClient(client), {
|
|
1078
|
+
variant: "info",
|
|
1079
|
+
title: "Kibi OpenCode",
|
|
1080
|
+
message,
|
|
1081
|
+
duration: 8000,
|
|
1082
|
+
});
|
|
1083
|
+
}
|
|
1084
|
+
logger.info("auto-update.notification", { message });
|
|
1085
|
+
},
|
|
1086
|
+
log: (message, metadata) => logger.info(message, metadata),
|
|
1087
|
+
});
|
|
1088
|
+
const runAutoUpdate = startupNotifyGlobals.__kibi_test_auto_update_runner ?? autoUpdateRunner;
|
|
1089
|
+
void runAutoUpdate({
|
|
1062
1090
|
directory: input.directory,
|
|
1063
|
-
|
|
1064
|
-
|
|
1091
|
+
enabled: cfg.autoUpdate,
|
|
1092
|
+
}).catch((err) => {
|
|
1093
|
+
logger.info("auto-update: background update check failed", {
|
|
1094
|
+
error: err instanceof Error ? err.message : String(err),
|
|
1095
|
+
});
|
|
1065
1096
|
});
|
|
1066
1097
|
}, 2000);
|
|
1067
1098
|
}
|
package/dist/scheduler.d.ts
CHANGED
|
@@ -24,7 +24,9 @@ export type SyncRunnerResult = {
|
|
|
24
24
|
};
|
|
25
25
|
export type SyncRunner = (worktree: string) => Promise<SyncRunnerResult>;
|
|
26
26
|
export type CheckRunner = (worktree: string, rules: string[]) => Promise<{
|
|
27
|
-
exitCode: number;
|
|
27
|
+
readonly exitCode: number;
|
|
28
|
+
readonly stdout?: string;
|
|
29
|
+
readonly stderr?: string;
|
|
28
30
|
}>;
|
|
29
31
|
export interface SchedulerOptions {
|
|
30
32
|
worktree: string;
|
package/dist/scheduler.js
CHANGED
|
@@ -154,6 +154,7 @@ class WorktreeSyncScheduler {
|
|
|
154
154
|
}
|
|
155
155
|
else {
|
|
156
156
|
logger.info(`check.succeeded ${JSON.stringify({ rules: checkRules })}`);
|
|
157
|
+
logAdvisoryQualityDiagnostics(checkRules, checkResult.stdout, checkResult.stderr);
|
|
157
158
|
}
|
|
158
159
|
}
|
|
159
160
|
}
|
|
@@ -280,11 +281,84 @@ async function runKibiSync(worktree) {
|
|
|
280
281
|
async function runKibiCheck(worktree, rules) {
|
|
281
282
|
return new Promise((resolve) => {
|
|
282
283
|
const rulesArg = rules.join(",");
|
|
283
|
-
exec(`kibi check --rules ${rulesArg}`, { cwd: worktree }, (error) => {
|
|
284
|
-
|
|
284
|
+
exec(`kibi check --rules ${rulesArg} --format json`, { cwd: worktree }, (error, stdout, stderr) => {
|
|
285
|
+
const truncatedOut = truncateSyncOutput(stdout || undefined);
|
|
286
|
+
const truncatedErr = truncateSyncOutput(stderr || undefined);
|
|
287
|
+
resolve({
|
|
288
|
+
exitCode: error ? (error.code ?? 1) : 0,
|
|
289
|
+
...(truncatedOut !== undefined ? { stdout: truncatedOut } : {}),
|
|
290
|
+
...(truncatedErr !== undefined ? { stderr: truncatedErr } : {}),
|
|
291
|
+
});
|
|
285
292
|
});
|
|
286
293
|
});
|
|
287
294
|
}
|
|
295
|
+
function logAdvisoryQualityDiagnostics(rules, stdout, stderr) {
|
|
296
|
+
const diagnostics = parseQualityDiagnostics(stdout, stderr).filter((diagnostic) => !diagnostic.blocking);
|
|
297
|
+
if (diagnostics.length === 0)
|
|
298
|
+
return;
|
|
299
|
+
const severityCounts = new Map();
|
|
300
|
+
for (const diagnostic of diagnostics) {
|
|
301
|
+
severityCounts.set(diagnostic.severity, (severityCounts.get(diagnostic.severity) ?? 0) + 1);
|
|
302
|
+
}
|
|
303
|
+
const first = diagnostics[0];
|
|
304
|
+
if (first === undefined)
|
|
305
|
+
return;
|
|
306
|
+
logger.warn(`check.advisory_quality ${JSON.stringify({
|
|
307
|
+
rules,
|
|
308
|
+
count: diagnostics.length,
|
|
309
|
+
...Object.fromEntries(severityCounts),
|
|
310
|
+
firstId: first.id,
|
|
311
|
+
firstMessage: first.message,
|
|
312
|
+
})}`);
|
|
313
|
+
}
|
|
314
|
+
function parseQualityDiagnostics(stdout, stderr) {
|
|
315
|
+
for (const output of [stdout, stderr]) {
|
|
316
|
+
if (output === undefined || output.trim().length === 0)
|
|
317
|
+
continue;
|
|
318
|
+
try {
|
|
319
|
+
const parsed = JSON.parse(output);
|
|
320
|
+
const candidates = qualityDiagnosticCandidates(parsed);
|
|
321
|
+
const diagnostics = candidates.flatMap((candidate) => parseQualityDiagnostic(candidate) ?? []);
|
|
322
|
+
if (diagnostics.length > 0)
|
|
323
|
+
return diagnostics;
|
|
324
|
+
}
|
|
325
|
+
catch (error) {
|
|
326
|
+
if (error instanceof SyntaxError)
|
|
327
|
+
continue;
|
|
328
|
+
throw error;
|
|
329
|
+
}
|
|
330
|
+
}
|
|
331
|
+
return [];
|
|
332
|
+
}
|
|
333
|
+
function qualityDiagnosticCandidates(value) {
|
|
334
|
+
if (!isRecord(value))
|
|
335
|
+
return [];
|
|
336
|
+
const structuredContent = value.structuredContent;
|
|
337
|
+
if (isRecord(structuredContent)) {
|
|
338
|
+
const diagnostics = structuredContent.qualityDiagnostics;
|
|
339
|
+
return Array.isArray(diagnostics) ? diagnostics : [];
|
|
340
|
+
}
|
|
341
|
+
const diagnostics = value.qualityDiagnostics;
|
|
342
|
+
return Array.isArray(diagnostics) ? diagnostics : [];
|
|
343
|
+
}
|
|
344
|
+
function parseQualityDiagnostic(value) {
|
|
345
|
+
if (!isRecord(value))
|
|
346
|
+
return null;
|
|
347
|
+
const id = value.id;
|
|
348
|
+
const severity = value.severity;
|
|
349
|
+
const blocking = value.blocking;
|
|
350
|
+
const message = value.message;
|
|
351
|
+
if (typeof id !== "string" ||
|
|
352
|
+
typeof severity !== "string" ||
|
|
353
|
+
typeof blocking !== "boolean" ||
|
|
354
|
+
typeof message !== "string") {
|
|
355
|
+
return null;
|
|
356
|
+
}
|
|
357
|
+
return { id, severity, blocking, message };
|
|
358
|
+
}
|
|
359
|
+
function isRecord(value) {
|
|
360
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
361
|
+
}
|
|
288
362
|
// implements REQ-opencode-kibi-plugin-v1
|
|
289
363
|
export function createSyncScheduler(opts) {
|
|
290
364
|
return new WorktreeSyncScheduler(opts);
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"opencode":"0.
|
|
1
|
+
{"opencode":"0.18.0","mcp":"0.19.0","cli":"0.14.0","core":"0.7.0"}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "kibi-opencode",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.18.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",
|
|
@@ -60,7 +60,7 @@
|
|
|
60
60
|
"@opencode-ai/plugin": "^1.4.7",
|
|
61
61
|
"@opentui/core": "^0.1.99",
|
|
62
62
|
"@opentui/solid": "^0.1.99",
|
|
63
|
-
"kibi-cli": "^0.
|
|
63
|
+
"kibi-cli": "^0.14.0"
|
|
64
64
|
},
|
|
65
65
|
"devDependencies": {
|
|
66
66
|
"@types/node": "latest",
|