kibi-opencode 0.5.3 → 0.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -18,6 +18,20 @@ Or via OpenCode's plugin system in `opencode.json`:
18
18
 
19
19
  ## Features
20
20
 
21
+ ### Smart Enforcement
22
+
23
+ The plugin now uses a posture-aware, low-token smart-enforcement model before emitting any guidance:
24
+
25
+ - **Repo posture detection**: distinguishes `root_active`, `root_partial`, `root_uninitialized`, `vendored_only`, and `hybrid_root_plus_vendored`
26
+ - **Risk classification**: separates `safe_docs_only`, `safe_test_only`, `kb_doc_structural`, `req_policy_candidate`, `behavior_candidate`, `traceability_candidate`, and `manual_kb_edit`
27
+ - **Source-linked micro-briefs**: risky code edits (`behavior_candidate`, `traceability_candidate`) prepend a concise list of existing Kibi links (e.g., `- Existing Kibi links: REQ-001, REQ-002`) when 1-3 concrete source-linked KB hits are found in `documentation/symbols.yaml`. Skip on cache hit.
28
+ - **Effective mode gating**: `strict` is only possible for `root_active` and `hybrid_root_plus_vendored` when `requireRootKbForStrict` is enabled; `maintenanceDegraded` overrides everything back to `advisory`
29
+ - **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)
30
+ - **Completion reminder**: when `completionReminder` is enabled, risky code edits append a single prompt-visible `kb_check` reminder exactly once per cached context
31
+ - **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
32
+ - **Advisory in editor, hard in hooks**: OpenCode guidance remains non-blocking; git hooks and KB validation checks stay the durable enforcement boundary
33
+ - **Structured observability**: posture, risk, cache, degraded-mode, targeted-check, and guidance events flow through structured plugin logs
34
+
21
35
  ### Dynamic Contextual Guidance
22
36
 
23
37
  The plugin provides context-aware prompt guidance based on recent edits and workspace state:
@@ -31,8 +45,10 @@ The plugin provides context-aware prompt guidance based on recent edits and work
31
45
 
32
46
  After KB-document edits, the plugin queues targeted validation rules to run via background sync operations:
33
47
 
34
- - **Must-priority requirement edits**: elevated validation including coverage checks
35
- - **Other requirement/scenario/test/ADR/fact edits**: standard validation for required fields and dangling references
48
+ - **Must-priority requirement edits**: elevated validation including coverage checks (`must-priority-coverage`)
49
+ - **Traceability candidate code edits**: schedules `symbol-traceability` via reason `smart-enforcement.traceability`
50
+ - **Fact KB doc edits**: includes `strict-fact-shape` validation alongside standard structural checks
51
+ - **Other requirement/scenario/test/ADR/fact edits**: standard validation for `required-fields` and `no-dangling-refs`
36
52
 
37
53
  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`.
38
54
 
@@ -144,12 +160,21 @@ Config files (project overrides global):
144
160
  | `guidance.targetedChecks.enabled` | boolean | `true` | Enable post-sync targeted validation checks |
145
161
  | `guidance.sessionSummary.enabled` | boolean | `true` | Enable periodic session summary logs |
146
162
  | `guidance.sessionSummary.logIntervalMs` | number | `1800000` | Session summary interval (30 min) |
163
+ | `guidance.smartEnforcement.enabled` | boolean | `true` | Enable posture-aware, risk-aware guidance routing |
164
+ | `guidance.smartEnforcement.mode` | string | `"advisory"` | Smart-enforcement mode: `advisory` or `strict` |
165
+ | `guidance.smartEnforcement.preflightTtlMs` | number | `600000` | Guidance/cache TTL for repeated prompt suppression |
166
+ | `guidance.smartEnforcement.idleResetMs` | number | `1800000` | Idle window before smart-enforcement state resets |
167
+ | `guidance.smartEnforcement.degradedMode` | string | `"warn-once"` | Degraded-mode logging policy: `warn-once` or `structured-only` |
168
+ | `guidance.smartEnforcement.requireRootKbForStrict` | boolean | `true` | Only allow strict behavior for authoritative root KB postures |
169
+ | `guidance.smartEnforcement.completionReminder` | boolean | `true` | Emit a single completion-time KB check reminder for risky edits |
147
170
  | `logLevel` | string | `"info"` | Log level: `debug`, `info`, `warn`, `error` |
148
171
 
149
172
  ### Hook Policy
150
173
 
151
174
  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.
152
175
 
176
+ Smart enforcement keeps the plugin advisory-only: prompt injection can warn, explain, or remind, but it does not block the editor. Hard failures still belong to CLI hooks (`pre-commit`) and explicit check commands.
177
+
153
178
  ### Logging Policy
154
179
 
155
180
  The plugin follows a **silent-except-errors** policy for terminal output:
@@ -203,6 +228,10 @@ Disable specific features while keeping others:
203
228
 
204
229
  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.
205
230
 
231
+ ## Troubleshooting
232
+
233
+ If you see a false "workspace needs Kibi bootstrap" warning even though your workspace is already initialized with `.kb/config.json` pointing at relocated `kibi-docs/*` paths, this indicates a stale plugin cache. See [the main troubleshooting docs](../../docs/troubleshooting.md#opencode-shows-workspace-needs-kibi-bootstrap-before-the-tui) for recovery steps.
234
+
206
235
  ## Architecture
207
236
 
208
237
  This is a thin bridge layer per ADR-016:
package/dist/config.d.ts CHANGED
@@ -30,6 +30,15 @@ export interface KibiConfig {
30
30
  enabled: boolean;
31
31
  logIntervalMs: number;
32
32
  };
33
+ smartEnforcement: {
34
+ enabled: boolean;
35
+ mode: "advisory" | "strict";
36
+ preflightTtlMs: number;
37
+ idleResetMs: number;
38
+ degradedMode: "warn-once" | "structured-only";
39
+ requireRootKbForStrict: boolean;
40
+ completionReminder: boolean;
41
+ };
33
42
  };
34
43
  logLevel: string;
35
44
  }
package/dist/config.js CHANGED
@@ -22,6 +22,15 @@ const DEFAULTS = {
22
22
  enabled: true,
23
23
  logIntervalMs: 30 * 60 * 1000, // 30 minutes
24
24
  },
25
+ smartEnforcement: {
26
+ enabled: true,
27
+ mode: "advisory",
28
+ preflightTtlMs: 600000, // 10 minutes
29
+ idleResetMs: 1800000, // 30 minutes
30
+ degradedMode: "warn-once",
31
+ requireRootKbForStrict: true,
32
+ completionReminder: true,
33
+ },
25
34
  },
26
35
  logLevel: "info",
27
36
  };
@@ -117,6 +126,28 @@ function validateAndMerge(obj) {
117
126
  if (typeof ss.logIntervalMs === "number")
118
127
  out.guidance.sessionSummary.logIntervalMs = ss.logIntervalMs;
119
128
  }
129
+ if (g.smartEnforcement && typeof g.smartEnforcement === "object") {
130
+ const se = g.smartEnforcement;
131
+ out.guidance.smartEnforcement = {
132
+ ...DEFAULTS.guidance.smartEnforcement,
133
+ };
134
+ if (typeof se.enabled === "boolean")
135
+ out.guidance.smartEnforcement.enabled = se.enabled;
136
+ if (se.mode === "advisory" || se.mode === "strict")
137
+ out.guidance.smartEnforcement.mode = se.mode;
138
+ if (typeof se.preflightTtlMs === "number")
139
+ out.guidance.smartEnforcement.preflightTtlMs = se.preflightTtlMs;
140
+ if (typeof se.idleResetMs === "number")
141
+ out.guidance.smartEnforcement.idleResetMs = se.idleResetMs;
142
+ if (se.degradedMode === "warn-once" || se.degradedMode === "structured-only")
143
+ out.guidance.smartEnforcement.degradedMode = se.degradedMode;
144
+ if (typeof se.requireRootKbForStrict === "boolean")
145
+ out.guidance.smartEnforcement.requireRootKbForStrict =
146
+ se.requireRootKbForStrict;
147
+ if (typeof se.completionReminder === "boolean")
148
+ out.guidance.smartEnforcement.completionReminder =
149
+ se.completionReminder;
150
+ }
120
151
  }
121
152
  return out;
122
153
  }
@@ -8,5 +8,15 @@ export declare function loadKbSyncPaths(cwd?: string): {
8
8
  facts: string;
9
9
  symbols: string;
10
10
  };
11
+ export interface KbExistenceTarget {
12
+ key: string;
13
+ relativePath: string;
14
+ kind: "dir" | "file";
15
+ }
16
+ export declare function getKbExistenceTargets(cwd?: string): KbExistenceTarget[];
17
+ /** Strip the first path segment containing a glob character and everything
18
+ * after it, returning the directory root to check with existsSync.
19
+ */
20
+ export declare function stripToRoot(p: string): string;
11
21
  export declare function shouldHandleFile(filePath: string, cwd?: string): boolean;
12
22
  export default shouldHandleFile;
@@ -22,16 +22,17 @@ catch {
22
22
  },
23
23
  };
24
24
  }
25
- // Local copy of DEFAULT_SYNC_PATHS to avoid cross-package TS rootDir issues
25
+ // Local copy of DEFAULT_CONFIG.paths to avoid cross-package TS rootDir issues.
26
+ // Must stay in sync with DEFAULT_CONFIG.paths in packages/cli/src/utils/config.ts.
26
27
  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",
28
+ requirements: "documentation/requirements/**/*.md",
29
+ scenarios: "documentation/scenarios/**/*.md",
30
+ tests: "documentation/tests/**/*.md",
31
+ adr: "documentation/adr/**/*.md",
32
+ flags: "documentation/flags/**/*.md",
33
+ events: "documentation/events/**/*.md",
34
+ facts: "documentation/facts/**/*.md",
35
+ symbols: "documentation/symbols.yaml",
35
36
  };
36
37
  function loadSyncConfigLocal(cwd = process.cwd()) {
37
38
  const configPath = path.join(cwd, ".kb/config.json");
@@ -56,6 +57,53 @@ export function loadKbSyncPaths(cwd = process.cwd()) {
56
57
  const cfg = loadSyncConfigLocal(cwd);
57
58
  return cfg.paths ?? DEFAULT_SYNC_PATHS;
58
59
  }
60
+ // implements REQ-opencode-kibi-plugin-v1
61
+ export function getKbExistenceTargets(cwd = process.cwd()) {
62
+ const paths = loadKbSyncPaths(cwd);
63
+ const keys = [
64
+ "requirements",
65
+ "scenarios",
66
+ "tests",
67
+ "adr",
68
+ "flags",
69
+ "events",
70
+ "facts",
71
+ "symbols",
72
+ ];
73
+ const targets = [];
74
+ for (const key of keys) {
75
+ const raw = paths[key];
76
+ if (!raw)
77
+ continue;
78
+ const isFile = raw.endsWith(".yaml") || raw.endsWith(".yml");
79
+ if (isFile) {
80
+ targets.push({ key, relativePath: raw, kind: "file" });
81
+ }
82
+ else {
83
+ // Contract: trim trailing slashes → normalizePattern → strip first glob segment
84
+ const trimmed = raw.replace(/\/+$/, "");
85
+ const normalized = normalizePattern(trimmed);
86
+ const relativePath = normalized ? stripToRoot(normalized) : ".";
87
+ targets.push({ key, relativePath, kind: "dir" });
88
+ }
89
+ }
90
+ return targets;
91
+ }
92
+ // implements REQ-opencode-kibi-plugin-v1
93
+ /** Strip the first path segment containing a glob character and everything
94
+ * after it, returning the directory root to check with existsSync.
95
+ */
96
+ export function stripToRoot(p) {
97
+ const segments = p.split("/");
98
+ const rootSegments = [];
99
+ for (const seg of segments) {
100
+ if (seg.includes("*") || seg.includes("?") || seg.includes("["))
101
+ break;
102
+ rootSegments.push(seg);
103
+ }
104
+ const result = rootSegments.join("/");
105
+ return result || ".";
106
+ }
59
107
  function normalizePattern(p) {
60
108
  if (!p)
61
109
  return null;
@@ -0,0 +1,75 @@
1
+ import type { RepoPosture } from "./repo-posture.js";
2
+ import type { RiskClass } from "./risk-classifier.js";
3
+ /**
4
+ * Cache key uniquely identifies a preflight context by combining
5
+ * workspace root, branch, posture, risk class, and file bucket.
6
+ */
7
+ export interface CacheKey {
8
+ workspaceRoot: string;
9
+ branch: string;
10
+ posture: RepoPosture;
11
+ riskClass: RiskClass;
12
+ fileBucket: string;
13
+ }
14
+ /**
15
+ * Cache entry tracking when a preflight was last satisfied.
16
+ */
17
+ export interface CacheEntry {
18
+ satisfiedAt: number;
19
+ preflightType: string;
20
+ }
21
+ /**
22
+ * In-memory cache tracking satisfied preflight checks per unique context.
23
+ *
24
+ * The cache is keyed by (workspaceRoot, branch, posture, riskClass, fileBucket)
25
+ * and tracks when each preflight was last satisfied. Entries expire after
26
+ * a configurable TTL or when context changes (branch switch, posture change,
27
+ * workspace change).
28
+ *
29
+ * v1: in-memory only, no disk persistence.
30
+ */
31
+ export declare class GuidanceCache {
32
+ private entries;
33
+ private ttlMs;
34
+ private idleResetMs;
35
+ private lastTouchedAt;
36
+ constructor(ttlMs?: number, idleResetMs?: number);
37
+ setTtlMs(ttlMs: number): void;
38
+ setIdleResetMs(idleResetMs: number): void;
39
+ /**
40
+ * Check whether a preflight has been satisfied for the given key
41
+ * and is still within the TTL window.
42
+ */
43
+ isSatisfied(key: CacheKey): boolean;
44
+ /**
45
+ * Record that a preflight has been satisfied for the given key.
46
+ */
47
+ recordSatisfied(key: CacheKey, preflightType: string): void;
48
+ /**
49
+ * Invalidate all cache entries.
50
+ */
51
+ invalidate(): void;
52
+ /**
53
+ * Invalidate all entries matching a specific posture.
54
+ */
55
+ invalidateForPosture(posture: RepoPosture): void;
56
+ /**
57
+ * Invalidate all entries matching a specific branch.
58
+ */
59
+ invalidateForBranch(branch: string): void;
60
+ /**
61
+ * Invalidate all entries matching a specific workspace root.
62
+ */
63
+ invalidateForWorkspace(workspaceRoot: string): void;
64
+ /**
65
+ * Get the number of entries currently in the cache (including potentially expired).
66
+ */
67
+ get size(): number;
68
+ /**
69
+ * Check whether a cache entry has expired based on the configured TTL.
70
+ */
71
+ private isExpired;
72
+ private resetIfIdle;
73
+ }
74
+ export declare function getGuidanceCache(ttlMs?: number, idleResetMs?: number): GuidanceCache;
75
+ export declare function resetGuidanceCache(ttlMs?: number, idleResetMs?: number): void;
@@ -0,0 +1,145 @@
1
+ // implements REQ-opencode-smart-enforcement-v1, REQ-opencode-kibi-plugin-v1
2
+ /**
3
+ * Serializes a CacheKey into a deterministic string for use as a Map key.
4
+ */
5
+ function serializeKey(key) {
6
+ return `${key.workspaceRoot}\0${key.branch}\0${key.posture}\0${key.riskClass}\0${key.fileBucket}`;
7
+ }
8
+ /**
9
+ * In-memory cache tracking satisfied preflight checks per unique context.
10
+ *
11
+ * The cache is keyed by (workspaceRoot, branch, posture, riskClass, fileBucket)
12
+ * and tracks when each preflight was last satisfied. Entries expire after
13
+ * a configurable TTL or when context changes (branch switch, posture change,
14
+ * workspace change).
15
+ *
16
+ * v1: in-memory only, no disk persistence.
17
+ */
18
+ export class GuidanceCache {
19
+ // implements REQ-opencode-kibi-plugin-v1
20
+ entries = new Map();
21
+ ttlMs;
22
+ idleResetMs;
23
+ lastTouchedAt;
24
+ constructor(ttlMs = 600000, idleResetMs = Number.POSITIVE_INFINITY) {
25
+ this.ttlMs = ttlMs;
26
+ this.idleResetMs = idleResetMs;
27
+ this.lastTouchedAt = Date.now();
28
+ }
29
+ setTtlMs(ttlMs) {
30
+ this.ttlMs = ttlMs;
31
+ }
32
+ setIdleResetMs(idleResetMs) {
33
+ this.idleResetMs = idleResetMs;
34
+ }
35
+ /**
36
+ * Check whether a preflight has been satisfied for the given key
37
+ * and is still within the TTL window.
38
+ */
39
+ isSatisfied(key) {
40
+ this.resetIfIdle();
41
+ const serialized = serializeKey(key);
42
+ const entry = this.entries.get(serialized);
43
+ if (!entry)
44
+ return false;
45
+ this.lastTouchedAt = Date.now();
46
+ return !this.isExpired(entry);
47
+ }
48
+ /**
49
+ * Record that a preflight has been satisfied for the given key.
50
+ */
51
+ recordSatisfied(key, preflightType) {
52
+ this.resetIfIdle();
53
+ const serialized = serializeKey(key);
54
+ this.entries.set(serialized, {
55
+ satisfiedAt: Date.now(),
56
+ preflightType,
57
+ });
58
+ this.lastTouchedAt = Date.now();
59
+ }
60
+ /**
61
+ * Invalidate all cache entries.
62
+ */
63
+ invalidate() {
64
+ this.entries.clear();
65
+ this.lastTouchedAt = Date.now();
66
+ }
67
+ /**
68
+ * Invalidate all entries matching a specific posture.
69
+ */
70
+ invalidateForPosture(posture) {
71
+ this.resetIfIdle();
72
+ for (const [serialized] of this.entries) {
73
+ // Key format: workspaceRoot\0branch\0posture\0riskClass\0fileBucket
74
+ const parts = serialized.split("\0");
75
+ if (parts[2] === posture) {
76
+ this.entries.delete(serialized);
77
+ }
78
+ }
79
+ this.lastTouchedAt = Date.now();
80
+ }
81
+ /**
82
+ * Invalidate all entries matching a specific branch.
83
+ */
84
+ invalidateForBranch(branch) {
85
+ this.resetIfIdle();
86
+ for (const [serialized] of this.entries) {
87
+ const parts = serialized.split("\0");
88
+ if (parts[1] === branch) {
89
+ this.entries.delete(serialized);
90
+ }
91
+ }
92
+ this.lastTouchedAt = Date.now();
93
+ }
94
+ /**
95
+ * Invalidate all entries matching a specific workspace root.
96
+ */
97
+ invalidateForWorkspace(workspaceRoot) {
98
+ this.resetIfIdle();
99
+ for (const [serialized] of this.entries) {
100
+ const parts = serialized.split("\0");
101
+ if (parts[0] === workspaceRoot) {
102
+ this.entries.delete(serialized);
103
+ }
104
+ }
105
+ this.lastTouchedAt = Date.now();
106
+ }
107
+ /**
108
+ * Get the number of entries currently in the cache (including potentially expired).
109
+ */
110
+ get size() {
111
+ return this.entries.size;
112
+ }
113
+ /**
114
+ * Check whether a cache entry has expired based on the configured TTL.
115
+ */
116
+ isExpired(entry) {
117
+ return Date.now() - entry.satisfiedAt > this.ttlMs;
118
+ }
119
+ resetIfIdle() {
120
+ const now = Date.now();
121
+ if (now - this.lastTouchedAt > this.idleResetMs) {
122
+ this.entries.clear();
123
+ }
124
+ this.lastTouchedAt = now;
125
+ }
126
+ }
127
+ // ── Singleton ───────────────────────────────────────────────────────
128
+ let globalCache = null;
129
+ export function getGuidanceCache(ttlMs, idleResetMs) {
130
+ // implements REQ-opencode-kibi-plugin-v1
131
+ if (!globalCache) {
132
+ globalCache = new GuidanceCache(ttlMs, idleResetMs);
133
+ }
134
+ else {
135
+ if (typeof ttlMs === "number")
136
+ globalCache.setTtlMs(ttlMs);
137
+ if (typeof idleResetMs === "number")
138
+ globalCache.setIdleResetMs(idleResetMs);
139
+ }
140
+ return globalCache;
141
+ }
142
+ export function resetGuidanceCache(ttlMs, idleResetMs) {
143
+ // implements REQ-opencode-kibi-plugin-v1
144
+ globalCache = new GuidanceCache(ttlMs, idleResetMs);
145
+ }
package/dist/index.d.ts CHANGED
@@ -1,6 +1,9 @@
1
1
  export interface PluginInput {
2
2
  worktree: string;
3
3
  directory: string;
4
+ project?: unknown;
5
+ serverUrl?: unknown;
6
+ $?: unknown;
4
7
  client?: {
5
8
  app: {
6
9
  log: (payload: Record<string, unknown>) => Promise<void>;