kibi-opencode 0.14.0 → 0.15.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
@@ -33,6 +33,20 @@ The plugin now uses a posture-aware, low-token smart-enforcement model before em
33
33
  - **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.
34
34
  - **Structured observability**: posture, risk, cache, degraded-mode, targeted-check, and guidance events flow through structured plugin logs
35
35
 
36
+ ### KB Freshness Contract
37
+
38
+ Meaningful code, test, requirement, scenario, and symbol changes now require explicit
39
+ KB impact resolution before task completion. When source or documentation files are
40
+ modified, the plugin evaluates KB freshness evidence and surfaces a visible
41
+ `🧠 **Kibi freshness required**` block when KB impact is unresolved.
42
+
43
+ **Allowed resolutions:**
44
+ - **KB updated**: Run source-linked discovery via `kb_search`, create or update entities
45
+ with `kb_upsert` (or remove with `kb_delete`), then run `kb_check`.
46
+ - **No KB impact**: Include a no-impact rationale in the final report after source-linked
47
+ discovery via `kb_search` or `kb_query(sourceFile=...)` and `kb_check`.
48
+ - **Deferred/Failed**: Do not claim task completion.
49
+
36
50
  ### Dynamic Contextual Guidance
37
51
 
38
52
  The plugin provides context-aware prompt guidance based on recent edits and workspace state:
@@ -5,6 +5,7 @@ import type { RepoPosture } from "./repo-posture.js";
5
5
  import type { RiskClass } from "./risk-classifier.js";
6
6
  import type { EffectiveMode } from "./smart-enforcement.js";
7
7
  import type { WorkContext } from "./work-context-resolver.js";
8
+ import type { KbFreshnessEvidence } from "./kb-freshness-state.js";
8
9
  export interface PolicyLinkedEntityResult {
9
10
  ids: string[];
10
11
  source: "symbols" | "doc-path" | "none";
@@ -20,6 +21,7 @@ export type CheckpointEvidence = boolean | {
20
21
  kbStatus?: boolean;
21
22
  kbCheck?: boolean;
22
23
  kbUpsert?: boolean;
24
+ freshness?: KbFreshnessEvidence;
23
25
  };
24
26
  export interface EnforcementPolicyInput {
25
27
  /** Resolved work context for the current file/prompt cycle. */
@@ -1,3 +1,4 @@
1
+ import { evaluateKbFreshness } from "./kb-freshness-state.js";
1
2
  const NO_E2E_SIGNAL = {
2
3
  level: "none",
3
4
  evidence: [],
@@ -31,6 +32,11 @@ function normalizeCheckpointEvidence(evidence) {
31
32
  if (!evidence) {
32
33
  return false;
33
34
  }
35
+ // Freshness evidence is more authoritative than legacy booleans
36
+ if (evidence.freshness) {
37
+ return evaluateKbFreshness(evidence.freshness).allowsCompletion;
38
+ }
39
+ // Legacy boolean check
34
40
  return (evidence.hasCheckpoint === true ||
35
41
  evidence.kbSearch === true ||
36
42
  evidence.sourceFileQuery === true ||
@@ -180,6 +186,10 @@ function hardBlockText(events) {
180
186
  "- Run `kb_status` if branch or snapshot freshness matters.",
181
187
  `- ${deletionCleanup}`,
182
188
  "- Run `kb_check` before completing the task.",
189
+ "KB freshness resolution:",
190
+ "- **KB updated**: run `kb_search` for discovery, then `kb_upsert`/`kb_delete` for mutations, then `kb_check`.",
191
+ "- **No KB impact**: provide a no-impact rationale in your final report after source-linked discovery via `kb_search` or `kb_query(sourceFile=...)` and `kb_check`.",
192
+ "- **Deferred/failed**: do not claim task completion."
183
193
  ].join("\n"),
184
194
  shownPaths,
185
195
  remainingCount,
@@ -0,0 +1,62 @@
1
+ /**
2
+ * KB freshness state machine, evidence model, and in-memory evidence store.
3
+ *
4
+ * This is Wave 1 — the foundation module. No MCP tools, no filesystem or
5
+ * `.kb/` persistence, no entity mutation. Pure in-memory state.
6
+ *
7
+ * @module kb-freshness-state
8
+ */
9
+ export type KbFreshnessDecision = "updated" | "no-impact" | "deferred" | "failed";
10
+ export type KbFreshnessStateKind = "clean" | "unknown-impact" | "evidence-required" | "updated" | "no-impact-accepted" | "deferred" | "check-failed";
11
+ export interface KbFreshnessEvidence {
12
+ sessionId?: string;
13
+ agentIdentity: string;
14
+ worktree: string;
15
+ branch: string;
16
+ fingerprint: string;
17
+ changedFiles: string[];
18
+ kbStatus: boolean;
19
+ sourceLinkedDiscovery: boolean;
20
+ kbMutation: boolean;
21
+ kbCheck: boolean;
22
+ decision?: KbFreshnessDecision;
23
+ rationale?: string;
24
+ checkRules?: string[];
25
+ }
26
+ export interface KbFreshnessEvaluation {
27
+ state: KbFreshnessStateKind;
28
+ requiresEvidence: boolean;
29
+ allowsCompletion: boolean;
30
+ reason: string;
31
+ missingEvidence: string[];
32
+ }
33
+ /**
34
+ * Evaluate KB freshness from collected evidence.
35
+ *
36
+ * Rules (in priority order):
37
+ * 1. No changed files → `clean`, allows completion.
38
+ * 2. Changed files but no decision → `evidence-required`, blocks completion.
39
+ * 3. `updated` → requires `kbMutation` + `kbCheck`.
40
+ * 4. `no-impact` → requires `rationale` + `sourceLinkedDiscovery` + `kbCheck`.
41
+ * 5. `deferred` → blocks completion.
42
+ * 6. `failed` → `check-failed`, blocks completion.
43
+ */
44
+ export declare function evaluateKbFreshness(evidence: KbFreshnessEvidence): KbFreshnessEvaluation;
45
+ export interface KbFreshnessScope {
46
+ sessionId?: string;
47
+ agentIdentity: string;
48
+ worktree: string;
49
+ branch: string;
50
+ fingerprint: string;
51
+ }
52
+ export interface KbFreshnessEvidenceStore {
53
+ recordToolEvidence(scope: KbFreshnessScope, toolName: string): void;
54
+ recordNoImpact(scope: KbFreshnessScope, rationale: string): void;
55
+ getEvidence(scope: KbFreshnessScope, changedFiles: string[]): KbFreshnessEvidence;
56
+ resetScope(scope: KbFreshnessScope): void;
57
+ }
58
+ /**
59
+ * Create an in-memory evidence store scoped by
60
+ * (sessionId | agentIdentity | worktree | branch | fingerprint).
61
+ */
62
+ export declare function createKbFreshnessEvidenceStore(): KbFreshnessEvidenceStore;
@@ -0,0 +1,211 @@
1
+ /**
2
+ * KB freshness state machine, evidence model, and in-memory evidence store.
3
+ *
4
+ * This is Wave 1 — the foundation module. No MCP tools, no filesystem or
5
+ * `.kb/` persistence, no entity mutation. Pure in-memory state.
6
+ *
7
+ * @module kb-freshness-state
8
+ */
9
+ // ---------------------------------------------------------------------------
10
+ // Evaluation
11
+ // ---------------------------------------------------------------------------
12
+ /**
13
+ * Evaluate KB freshness from collected evidence.
14
+ *
15
+ * Rules (in priority order):
16
+ * 1. No changed files → `clean`, allows completion.
17
+ * 2. Changed files but no decision → `evidence-required`, blocks completion.
18
+ * 3. `updated` → requires `kbMutation` + `kbCheck`.
19
+ * 4. `no-impact` → requires `rationale` + `sourceLinkedDiscovery` + `kbCheck`.
20
+ * 5. `deferred` → blocks completion.
21
+ * 6. `failed` → `check-failed`, blocks completion.
22
+ */
23
+ export function evaluateKbFreshness(evidence) {
24
+ // Rule 1: No changed files
25
+ if (evidence.changedFiles.length === 0) {
26
+ return {
27
+ state: "clean",
28
+ requiresEvidence: false,
29
+ allowsCompletion: true,
30
+ reason: "No files changed",
31
+ missingEvidence: [],
32
+ };
33
+ }
34
+ // Rule 2: No decision recorded yet
35
+ if (evidence.decision === undefined) {
36
+ return {
37
+ state: "evidence-required",
38
+ requiresEvidence: true,
39
+ allowsCompletion: false,
40
+ reason: "Session modified files without KB tool evidence",
41
+ missingEvidence: [],
42
+ };
43
+ }
44
+ // Rule 5: Deferred
45
+ if (evidence.decision === "deferred") {
46
+ return {
47
+ state: "deferred",
48
+ requiresEvidence: false,
49
+ allowsCompletion: false,
50
+ reason: "KB freshness check was explicitly deferred",
51
+ missingEvidence: [],
52
+ };
53
+ }
54
+ // Rule 6: Failed
55
+ if (evidence.decision === "failed") {
56
+ return {
57
+ state: "check-failed",
58
+ requiresEvidence: false,
59
+ allowsCompletion: false,
60
+ reason: "KB freshness check failed",
61
+ missingEvidence: [],
62
+ };
63
+ }
64
+ // Rule 3: Updated
65
+ if (evidence.decision === "updated") {
66
+ const missingEvidence = [];
67
+ if (!evidence.kbMutation) {
68
+ missingEvidence.push("kbMutation");
69
+ }
70
+ if (!evidence.kbCheck) {
71
+ missingEvidence.push("kbCheck");
72
+ }
73
+ if (missingEvidence.length > 0) {
74
+ return {
75
+ state: "evidence-required",
76
+ requiresEvidence: true,
77
+ allowsCompletion: false,
78
+ reason: "KB was updated but required evidence (kbMutation, kbCheck) is missing",
79
+ missingEvidence,
80
+ };
81
+ }
82
+ return {
83
+ state: "updated",
84
+ requiresEvidence: false,
85
+ allowsCompletion: true,
86
+ reason: "KB updated and verified via kb_check",
87
+ missingEvidence: [],
88
+ };
89
+ }
90
+ // Rule 4: No-impact
91
+ if (evidence.decision === "no-impact") {
92
+ const missingEvidence = [];
93
+ if (!evidence.rationale || evidence.rationale === "") {
94
+ missingEvidence.push("rationale");
95
+ }
96
+ if (!evidence.sourceLinkedDiscovery) {
97
+ missingEvidence.push("sourceLinkedDiscovery");
98
+ }
99
+ if (!evidence.kbCheck) {
100
+ missingEvidence.push("kbCheck");
101
+ }
102
+ if (missingEvidence.length > 0) {
103
+ return {
104
+ state: "evidence-required",
105
+ requiresEvidence: true,
106
+ allowsCompletion: false,
107
+ reason: "No-impact claim needs rationale, sourceLinkedDiscovery, and kbCheck",
108
+ missingEvidence,
109
+ };
110
+ }
111
+ return {
112
+ state: "no-impact-accepted",
113
+ requiresEvidence: false,
114
+ allowsCompletion: true,
115
+ reason: "No-impact accepted with rationale and KB check passed",
116
+ missingEvidence: [],
117
+ };
118
+ }
119
+ // Exhaustive guard — all decision branches handled above
120
+ const _exhaustive = evidence.decision;
121
+ throw new Error(`Unexpected decision: ${_exhaustive}`);
122
+ }
123
+ /**
124
+ * Map of tool names → evidence side effects.
125
+ * Each entry lists the evidence fields to set when that tool is used.
126
+ */
127
+ const TOOL_EFFECTS = {
128
+ kb_status: ["kbStatus"],
129
+ kb_search: ["sourceLinkedDiscovery"],
130
+ kb_query: ["sourceLinkedDiscovery"],
131
+ kb_graph: ["sourceLinkedDiscovery"],
132
+ kb_find_gaps: ["sourceLinkedDiscovery"],
133
+ kb_coverage: ["sourceLinkedDiscovery"],
134
+ kb_upsert: ["kbMutation"],
135
+ kb_delete: ["kbMutation"],
136
+ kb_check: ["kbCheck"],
137
+ };
138
+ function buildScopeKey(scope) {
139
+ const parts = [
140
+ scope.sessionId ?? "",
141
+ scope.agentIdentity,
142
+ scope.worktree,
143
+ scope.branch,
144
+ scope.fingerprint,
145
+ ];
146
+ return parts.join("|");
147
+ }
148
+ /**
149
+ * Create an in-memory evidence store scoped by
150
+ * (sessionId | agentIdentity | worktree | branch | fingerprint).
151
+ */
152
+ export function createKbFreshnessEvidenceStore() {
153
+ const store = new Map();
154
+ function getOrCreate(scope) {
155
+ const key = buildScopeKey(scope);
156
+ if (!store.has(key)) {
157
+ store.set(key, {
158
+ kbStatus: false,
159
+ sourceLinkedDiscovery: false,
160
+ kbMutation: false,
161
+ kbCheck: false,
162
+ });
163
+ }
164
+ return store.get(key);
165
+ }
166
+ return {
167
+ recordToolEvidence(scope, toolName) {
168
+ const record = getOrCreate(scope);
169
+ const effects = TOOL_EFFECTS[toolName];
170
+ if (!effects) {
171
+ return;
172
+ }
173
+ for (const field of effects) {
174
+ if (field === "kbMutation") {
175
+ record.kbMutation = true;
176
+ record.decision = "updated";
177
+ }
178
+ else {
179
+ record[field] = true;
180
+ }
181
+ }
182
+ },
183
+ recordNoImpact(scope, rationale) {
184
+ const record = getOrCreate(scope);
185
+ record.decision = "no-impact";
186
+ record.rationale = rationale;
187
+ },
188
+ getEvidence(scope, changedFiles) {
189
+ const record = getOrCreate(scope);
190
+ return {
191
+ ...(scope.sessionId ? { sessionId: scope.sessionId } : {}),
192
+ agentIdentity: scope.agentIdentity,
193
+ worktree: scope.worktree,
194
+ branch: scope.branch,
195
+ fingerprint: scope.fingerprint,
196
+ changedFiles,
197
+ kbStatus: record.kbStatus,
198
+ sourceLinkedDiscovery: record.sourceLinkedDiscovery,
199
+ kbMutation: record.kbMutation,
200
+ kbCheck: record.kbCheck,
201
+ ...(record.decision !== undefined ? { decision: record.decision } : {}),
202
+ ...(record.rationale !== undefined ? { rationale: record.rationale } : {}),
203
+ ...(record.checkRules !== undefined ? { checkRules: record.checkRules } : {}),
204
+ };
205
+ },
206
+ resetScope(scope) {
207
+ const key = buildScopeKey(scope);
208
+ store.delete(key);
209
+ },
210
+ };
211
+ }
@@ -0,0 +1,30 @@
1
+ import type { PathKind } from "./path-kind.js";
2
+ import type { FileLifecycle } from "./file-operation-state.js";
3
+ import type { RiskClass } from "./risk-classifier.js";
4
+ /**
5
+ * Classification for whether a file change requires KB freshness evidence.
6
+ *
7
+ * - "requires-kb-evidence": The change is meaningful for KB freshness — the KB
8
+ * snapshot should be considered stale and needs a re-sync.
9
+ * - "advisory": The change is low-risk (lockfiles, docs-only) — advisory only.
10
+ * - "ignored": The change is in a build artifact or third-party path — no KB
11
+ * impact.
12
+ */
13
+ export type MeaningfulChangeClass = "requires-kb-evidence" | "advisory" | "ignored";
14
+ /**
15
+ * Classify whether a file change requires KB freshness evidence.
16
+ *
17
+ * Rules (first match wins):
18
+ * 1. Ignored paths (build artifacts, vendor dirs) → `ignored`
19
+ * 2. Advisory paths (lockfiles) → `advisory`
20
+ * 3. Meaningful pathKinds (code, requirements, KB docs, etc.) → `requires-kb-evidence`
21
+ * 4. Risk-based upgrade (behavior/traceability/req_policy candidates) → `requires-kb-evidence`
22
+ * 5. `safe_docs_only` risk + `unknown` pathKind → `advisory`
23
+ * 6. Default → `requires-kb-evidence` (safety default)
24
+ */
25
+ export declare function classifyMeaningfulChange(params: {
26
+ normalizedPath: string;
27
+ pathKind: PathKind;
28
+ lifecycle: FileLifecycle;
29
+ riskClass?: RiskClass;
30
+ }): MeaningfulChangeClass;
@@ -0,0 +1,104 @@
1
+ // Directories whose contents are always ignored for KB freshness.
2
+ const IGNORED_DIRECTORIES = [
3
+ ".git",
4
+ ".sisyphus",
5
+ "node_modules",
6
+ "vendor",
7
+ "third_party",
8
+ "dist",
9
+ "coverage",
10
+ ];
11
+ // Filenames that are advisory-only (lockfiles).
12
+ const ADVISORY_FILENAMES = new Set([
13
+ "bun.lock",
14
+ "bun.lockb",
15
+ "package-lock.json",
16
+ "pnpm-lock.yaml",
17
+ "yarn.lock",
18
+ ]);
19
+ // PathKind values that always require KB evidence.
20
+ const MEANINGFUL_PATH_KINDS = new Set([
21
+ "code",
22
+ "requirement",
23
+ "scenario",
24
+ "test",
25
+ "adr",
26
+ "fact",
27
+ "flag",
28
+ "event",
29
+ "symbol",
30
+ "kb",
31
+ ]);
32
+ // Risk classes that upgrade the classification to requires-kb-evidence.
33
+ const UPGRADE_RISK_CLASSES = new Set([
34
+ "behavior_candidate",
35
+ "traceability_candidate",
36
+ "req_policy_candidate",
37
+ ]);
38
+ function isIgnoredPath(normalizedPath, pathKind) {
39
+ // Check extension-based ignored patterns.
40
+ if (normalizedPath.endsWith(".tsbuildinfo") ||
41
+ normalizedPath.endsWith(".map")) {
42
+ return true;
43
+ }
44
+ // Check directory-based ignored patterns.
45
+ for (const dir of IGNORED_DIRECTORIES) {
46
+ if (normalizedPath.startsWith(`${dir}/`) ||
47
+ normalizedPath.includes(`/${dir}/`)) {
48
+ return true;
49
+ }
50
+ }
51
+ // Special handling for .kb/ — only ignored when pathKind is NOT "kb".
52
+ // Actual KB entity files (pathKind=kb) should still require evidence.
53
+ if (pathKind !== "kb") {
54
+ if (normalizedPath.startsWith(".kb/") ||
55
+ normalizedPath.includes("/.kb/")) {
56
+ return true;
57
+ }
58
+ }
59
+ return false;
60
+ }
61
+ function getFilename(normalizedPath) {
62
+ const lastSlash = normalizedPath.lastIndexOf("/");
63
+ return lastSlash === -1
64
+ ? normalizedPath
65
+ : normalizedPath.slice(lastSlash + 1);
66
+ }
67
+ /**
68
+ * Classify whether a file change requires KB freshness evidence.
69
+ *
70
+ * Rules (first match wins):
71
+ * 1. Ignored paths (build artifacts, vendor dirs) → `ignored`
72
+ * 2. Advisory paths (lockfiles) → `advisory`
73
+ * 3. Meaningful pathKinds (code, requirements, KB docs, etc.) → `requires-kb-evidence`
74
+ * 4. Risk-based upgrade (behavior/traceability/req_policy candidates) → `requires-kb-evidence`
75
+ * 5. `safe_docs_only` risk + `unknown` pathKind → `advisory`
76
+ * 6. Default → `requires-kb-evidence` (safety default)
77
+ */
78
+ export function classifyMeaningfulChange(params) {
79
+ const { normalizedPath, pathKind, lifecycle: _lifecycle, riskClass } = params;
80
+ // 1. IGNORED paths (for freshness contract)
81
+ if (isIgnoredPath(normalizedPath, pathKind)) {
82
+ return "ignored";
83
+ }
84
+ // 2. ADVISORY paths (low-risk lockfiles)
85
+ const filename = getFilename(normalizedPath);
86
+ if (ADVISORY_FILENAMES.has(filename)) {
87
+ return "advisory";
88
+ }
89
+ // 3. REQUIRES-KB-EVIDENCE by pathKind
90
+ if (MEANINGFUL_PATH_KINDS.has(pathKind)) {
91
+ return "requires-kb-evidence";
92
+ }
93
+ // 4. RISK-BASED UPGRADE
94
+ if (riskClass) {
95
+ if (UPGRADE_RISK_CLASSES.has(riskClass)) {
96
+ return "requires-kb-evidence";
97
+ }
98
+ if (riskClass === "safe_docs_only" && pathKind === "unknown") {
99
+ return "advisory";
100
+ }
101
+ }
102
+ // 5. DEFAULT — safety default for unknown paths
103
+ return "requires-kb-evidence";
104
+ }
package/dist/plugin.js CHANGED
@@ -27,8 +27,11 @@ import { createSessionEditState, } from "./session-edit-state.js";
27
27
  import { syncSessionBaselineState, } from "./session-fingerprint.js";
28
28
  import { getSessionTracker } from "./session-tracker.js";
29
29
  import { notifyStartup, } from "./startup-notifier.js";
30
+ import { readKibiPackageVersions } from "./version-metadata.js";
30
31
  import { sendToast, } from "./toast.js";
31
32
  import { announceBriefTui, } from "./tui-brief-delivery.js";
33
+ import { createKbFreshnessEvidenceStore, evaluateKbFreshness, } from "./kb-freshness-state.js";
34
+ import { classifyMeaningfulChange } from "./meaningful-change-classifier.js";
32
35
  import { deletePendingBriefMarkers, loadPendingBriefMarkers, } from "./utils/brief-marker.js";
33
36
  import { resolveWorkContext, } from "./work-context-resolver.js";
34
37
  import * as fs from "node:fs";
@@ -48,17 +51,6 @@ function resolveIdleBriefDeliveryDelayMs(worktree) {
48
51
  return 0;
49
52
  return Math.min(60_000, Math.trunc(configValue));
50
53
  }
51
- function readKibiOpencodePackageVersion() {
52
- try {
53
- const packageJson = JSON.parse(fs.readFileSync(new URL("../package.json", import.meta.url), "utf-8"));
54
- return typeof packageJson.version === "string"
55
- ? packageJson.version
56
- : undefined;
57
- }
58
- catch {
59
- return undefined;
60
- }
61
- }
62
54
  const startupNotifyGlobals = globalThis;
63
55
  /**
64
56
  * Lint requirement documents for embedded scenarios/tests and oversized content.
@@ -160,6 +152,7 @@ const kibiOpencodePlugin = async (input) => {
160
152
  const fileOperationStateRegistry = new Map();
161
153
  const checkpointRunnerRegistry = new Map();
162
154
  const pathKindCacheRegistry = new Map();
155
+ const freshnessStore = createKbFreshnessEvidenceStore();
163
156
  function resolveScopedWorkContext(filePath) {
164
157
  return resolveWorkContext({
165
158
  inputDirectory: input.directory,
@@ -482,6 +475,37 @@ const kibiOpencodePlugin = async (input) => {
482
475
  hooks.event = async ({ event }) => {
483
476
  const activeBranch = resolveCurrentBranch(input.worktree);
484
477
  syncSessionBaseline(activeBranch);
478
+ // Observe KB tool execution events for freshness evidence (best-effort)
479
+ const TOOL_EVENT_TYPES = new Set([
480
+ "tool.execute.after", "tool.executed", "tool.call.completed",
481
+ "tool.Execute.after", "tool.Call.completed",
482
+ ]);
483
+ if (TOOL_EVENT_TYPES.has(event.type)) {
484
+ const props = event.properties ?? {};
485
+ const toolName = (props.tool ?? props.toolName ?? props.name ??
486
+ props.call?.name ??
487
+ props.input?.tool);
488
+ if (typeof toolName === "string" && toolName.startsWith("kb_")) {
489
+ const scope = {
490
+ ...(input.sessionId !== undefined ? { sessionId: input.sessionId } : {}),
491
+ agentIdentity: input.agentIdentity ?? "unknown",
492
+ worktree: input.worktree,
493
+ branch: activeBranch,
494
+ fingerprint: `${input.sessionId ?? ""}-${activeBranch}`,
495
+ };
496
+ try {
497
+ freshnessStore.recordToolEvidence(scope, toolName);
498
+ logger.info("kb-freshness.tool-evidence", {
499
+ event: "kb_freshness_tool_evidence",
500
+ tool: toolName,
501
+ });
502
+ }
503
+ catch {
504
+ // best-effort, never crash the event handler
505
+ }
506
+ }
507
+ return; // tool events don't need file lifecycle processing
508
+ }
485
509
  // Handle session.idle for idle-brief generation. OpenCode can emit idle
486
510
  // while an assistant is between tool calls, so debounce until the work
487
511
  // burst settles before generating/delivering a brief.
@@ -1195,6 +1219,32 @@ const kibiOpencodePlugin = async (input) => {
1195
1219
  }
1196
1220
  }
1197
1221
  }
1222
+ // Build freshness evidence for the current dirty fingerprint
1223
+ let freshnessEval;
1224
+ const freshnessFingerprint = `${promptWorkContext.sessionId ?? ""}-${promptWorkContext.branch}`;
1225
+ const freshnessScope = {
1226
+ ...(promptWorkContext.sessionId !== undefined ? { sessionId: promptWorkContext.sessionId } : {}),
1227
+ agentIdentity: promptWorkContext.agentIdentity,
1228
+ worktree: promptWorkContext.worktreeRoot,
1229
+ branch: promptWorkContext.branch,
1230
+ fingerprint: freshnessFingerprint,
1231
+ };
1232
+ if (transformFocusFilePath) {
1233
+ const normalizedFocusPathForFreshness = promptFileOperationState.normalizePath(transformFocusFilePath);
1234
+ const focusPathKindForFreshness = promptPathKindCache.get(normalizedFocusPathForFreshness) ?? "unknown";
1235
+ const pendingLifecycleForFreshness = promptFileOperationState.peekPending(normalizedFocusPathForFreshness);
1236
+ const effectiveRiskClassForFreshness = effectiveRiskClass ?? "safe_docs_only";
1237
+ const meaningfulChange = classifyMeaningfulChange({
1238
+ normalizedPath: normalizedFocusPathForFreshness,
1239
+ pathKind: focusPathKindForFreshness,
1240
+ lifecycle: pendingLifecycleForFreshness?.lifecycle ?? "edited",
1241
+ riskClass: effectiveRiskClassForFreshness,
1242
+ });
1243
+ if (meaningfulChange === "requires-kb-evidence") {
1244
+ const freshnessEvidence = freshnessStore.getEvidence(freshnessScope, [normalizedFocusPathForFreshness]);
1245
+ freshnessEval = evaluateKbFreshness(freshnessEvidence);
1246
+ }
1247
+ }
1198
1248
  const guidance = buildPrompt({
1199
1249
  recentEdits: transformRecentEdits,
1200
1250
  focusEdit: transformPromptFocusEdit,
@@ -1217,6 +1267,10 @@ const kibiOpencodePlugin = async (input) => {
1217
1267
  ? { fileOperationReminder }
1218
1268
  : {}),
1219
1269
  ...(hardGateBlock !== undefined ? { hardGateBlock } : {}),
1270
+ ...(freshnessEval ? { kbFreshness: freshnessEval } : {}),
1271
+ ...(freshnessEval && transformSessionEdits.length > 0
1272
+ ? { freshnessChangedPaths: [...new Set(transformSessionEdits.map(e => e.filePath))].slice(0, 5) }
1273
+ : {}),
1220
1274
  });
1221
1275
  logger.info("smart-enforcement.guidance", {
1222
1276
  event: "smart_enforcement_guidance",
@@ -1347,11 +1401,17 @@ const kibiOpencodePlugin = async (input) => {
1347
1401
  setTimeout(callback, delayMs);
1348
1402
  });
1349
1403
  scheduleStartupNotify(() => {
1350
- const version = readKibiOpencodePackageVersion();
1404
+ const meta = readKibiPackageVersions();
1405
+ const versions = {};
1406
+ for (const key of ["opencode", "mcp", "cli", "core"]) {
1407
+ if (meta[key] !== "unknown")
1408
+ versions[key] = meta[key];
1409
+ }
1351
1410
  notifyStartup(makeStartupClient(client), {
1352
1411
  suppressToast: cfg.ux.toastStartup === false,
1353
1412
  directory: input.directory,
1354
- ...(version ? { version } : {}),
1413
+ ...(Object.keys(versions).length > 0 ? { versions } : {}),
1414
+ versionMetadataSource: meta.source,
1355
1415
  });
1356
1416
  }, 2000);
1357
1417
  }
package/dist/prompt.d.ts CHANGED
@@ -7,6 +7,7 @@ import type { PathKind } from "./path-kind.js";
7
7
  import type { RepoPosture } from "./repo-posture.js";
8
8
  import type { RiskClass } from "./risk-classifier.js";
9
9
  import type { WorkspaceHealth } from "./workspace-health.js";
10
+ import type { KbFreshnessEvaluation } from "./kb-freshness-state.js";
10
11
  declare const SENTINEL = "<!-- kibi-opencode -->";
11
12
  export declare function buildAutoBriefingGuidance(autoBriefResult: BriefingRuntimeResult | undefined, completionReminder: boolean): string | null;
12
13
  export interface PromptContext {
@@ -53,6 +54,10 @@ export interface PromptContext {
53
54
  remainingCount: number;
54
55
  reason?: string;
55
56
  };
57
+ /** KB freshness evaluation from evidence store */
58
+ kbFreshness?: KbFreshnessEvaluation;
59
+ /** Paths changed for freshness evaluation (capped at 5) */
60
+ freshnessChangedPaths?: string[];
56
61
  }
57
62
  export declare function postureGuidance(posture: RepoPosture, capability?: InitKibiCommandCapability): string | null;
58
63
  /**
package/dist/prompt.js CHANGED
@@ -110,7 +110,7 @@ function buildBootstrapRequiredBody(capability = getInitKibiCommandCapability())
110
110
  const commandBullet = capability.supported
111
111
  ? "- When the Kibi OpenCode plugin is active and native injection is supported, use `/init-kibi` as the canonical short alias; `/kibi:init-kibi:mcp` remains the namespaced MCP fallback."
112
112
  : "- This host does not support native `/init-kibi` injection. Kibi must fail closed and does not register a fake native alias; use `/kibi:init-kibi:mcp` instead.";
113
- return `This repository does not appear to have Kibi initialized. Agents should:\n${commandBullet}\n- The workflow uses \`kb_autopilot_generate\` for read-only synthesis; always preview and get approval before writes.\n- Ask the user/operator to run setup or repair outside this session if bootstrap is insufficient.\n\nUse public MCP tools only: \`kb_autopilot_generate\`, \`kb_search\`, \`kb_query\`, \`kb_status\`, \`kb_find_gaps\`, \`kb_coverage\`, \`kb_graph\`, \`kb_upsert\`, \`kb_delete\`, \`kb_check\`.`;
113
+ return `This repository does not appear to have Kibi initialized. Agents should:\n${commandBullet}\n- The workflow uses \`kb_autopilot_generate\` for read-only synthesis; always preview and get approval before writes.\n- Ask the user/operator to run setup or repair outside this session if bootstrap is insufficient.\n- For comprehensive Kibi usage guidance, use \`kb_skills_load\` with skill id \`kibi-usage\`.\n\nUse public MCP tools only: \`kb_autopilot_generate\`, \`kb_search\`, \`kb_query\`, \`kb_status\`, \`kb_find_gaps\`, \`kb_coverage\`, \`kb_graph\`, \`kb_upsert\`, \`kb_delete\`, \`kb_check\`, \`kb_skills_load\`.`;
114
114
  }
115
115
  function buildHardGateBlock(block) {
116
116
  const pathLines = block.shownPaths.map((path) => `- \`${path}\``);
@@ -240,6 +240,7 @@ ${buildBootstrapRequiredBody(capability)}`;
240
240
  context.cache &&
241
241
  context.workspaceRoot &&
242
242
  context.branch &&
243
+ !context.kbFreshness?.requiresEvidence &&
243
244
  riskClass) {
244
245
  const focusEdit = getFocusEdit(context);
245
246
  const key = {
@@ -455,6 +456,26 @@ The Kibi workspace is in a maintenance-degraded state. Guidance remains advisory
455
456
  posture !== "root_partial") {
456
457
  finalBlock = `${finalBlock}\n- Kibi impact evidence is required before completion/commit: run \`kb_check\` before completing this task.`;
457
458
  }
459
+ // Prepend KB freshness block when evidence is required
460
+ if (context.kbFreshness?.requiresEvidence) {
461
+ const changedListing = context.freshnessChangedPaths?.length
462
+ ? `Changed: ${context.freshnessChangedPaths.slice(0, 5).map(p => `\`${p}\``).join(", ")}`
463
+ : null;
464
+ const freshnessLines = [
465
+ "🧠 **Kibi freshness required**",
466
+ changedListing,
467
+ `State: ${context.kbFreshness.state}. ${context.kbFreshness.reason}`,
468
+ ...(context.kbFreshness.missingEvidence.length > 0
469
+ ? [`Missing: ${context.kbFreshness.missingEvidence.join(", ")}`]
470
+ : []),
471
+ "Resolution:",
472
+ "- **KB updated**: run `kb_search` for discovery, then `kb_upsert`/`kb_delete` for mutations, then `kb_check`.",
473
+ "- **No KB impact**: include a no-impact rationale in your final report after source-linked discovery via `kb_search`/`kb_query(sourceFile=...)` and `kb_check`.",
474
+ ].filter(Boolean).join("\n");
475
+ finalBlock = finalBlock
476
+ ? `${freshnessLines}\n\n${finalBlock}`
477
+ : freshnessLines;
478
+ }
458
479
  // Return: sentinel + one targeted block (or just sentinel if no block)
459
480
  return finalBlock
460
481
  ? `${SENTINEL}\n\n${finalBlock}`
@@ -531,7 +552,13 @@ Dogfood note for this repo: OpenCode here uses local built \`kibi-mcp\` and \`ki
531
552
  6. **Validate**: Run kb_check after KB mutations to catch violations early.
532
553
  7. **Before completion/commit**: Kibi impact evidence is required before completion/commit. If extraction output changes, refresh documentation/symbols.yaml and do not revert that update as scope creep.
533
554
 
534
- **Public Kibi tools only:** kb_autopilot_generate, kb_search, kb_query, kb_status, kb_find_gaps, kb_coverage, kb_graph, kb_upsert, kb_delete, kb_check.\n\nDo not invoke Kibi CLI commands directly from the agent.\n\n${buildInitKibiBootstrapReference(capability)}`;
555
+ **Public Kibi tools only:** kb_autopilot_generate, kb_search, kb_query, kb_status, kb_find_gaps, kb_coverage, kb_graph, kb_upsert, kb_delete, kb_check, kb_skills_load.
556
+
557
+ For comprehensive Kibi usage guidance (relationships, fact lanes, workflows), use \`kb_skills_load\` with skill id \`kibi-usage\`.
558
+
559
+ Do not invoke Kibi CLI commands directly from the agent.
560
+
561
+ ${buildInitKibiBootstrapReference(capability)}`;
535
562
  }
536
563
  /**
537
564
  * Build prompt with contextual guidance based on posture, risk class, and cache state.
@@ -6,8 +6,14 @@ export type StartupNotifierClient = ToastCapableClient & {
6
6
  };
7
7
  };
8
8
  export type StartupNotifierConfig = {
9
- version?: string;
9
+ versions?: {
10
+ opencode?: string;
11
+ mcp?: string;
12
+ cli?: string;
13
+ core?: string;
14
+ };
10
15
  suppressToast?: boolean;
11
16
  directory?: string;
17
+ versionMetadataSource?: string;
12
18
  };
13
19
  export declare function notifyStartup(client: StartupNotifierClient, cfg: StartupNotifierConfig): void;
@@ -1,10 +1,30 @@
1
1
  import { sendToast, } from "./toast.js";
2
+ const VERSION_ORDER = ["opencode", "mcp", "cli", "core"];
2
3
  // implements REQ-opencode-kibi-plugin-v1
3
4
  export function notifyStartup(client, cfg) {
4
5
  const message = "kibi-opencode started";
5
- const displayMessage = cfg.version
6
- ? `${message} (v${cfg.version})`
6
+ // Build toast message from known versions in order
7
+ const versionParts = [];
8
+ if (cfg.versions) {
9
+ for (const key of VERSION_ORDER) {
10
+ const val = cfg.versions[key];
11
+ if (val !== undefined) {
12
+ versionParts.push(`${key} v${val}`);
13
+ }
14
+ }
15
+ }
16
+ const displayMessage = versionParts.length > 0
17
+ ? `${message} (${versionParts.join(", ")})`
7
18
  : message;
19
+ // Compute unknown versions (keys from VERSION_ORDER not present in cfg.versions)
20
+ const unknownVersions = [];
21
+ if (cfg.versions) {
22
+ for (const key of VERSION_ORDER) {
23
+ if (cfg.versions[key] === undefined) {
24
+ unknownVersions.push(key);
25
+ }
26
+ }
27
+ }
8
28
  const toastPayload = {
9
29
  variant: "success",
10
30
  title: "Kibi OpenCode",
@@ -18,31 +38,36 @@ export function notifyStartup(client, cfg) {
18
38
  ...(cfg.directory ? { directory: cfg.directory } : {}),
19
39
  };
20
40
  if (result.status === "delivered") {
21
- void client.app.log({
41
+ void client.app
42
+ .log({
22
43
  body: {
23
44
  ...base,
24
45
  level: "info",
25
46
  message: "startup toast delivered",
26
47
  transport: result.transport,
27
48
  },
28
- }).catch(() => {
49
+ })
50
+ .catch(() => {
29
51
  // Advisory log failure stays silent
30
52
  });
31
53
  }
32
54
  else if (result.status === "unavailable") {
33
- void client.app.log({
55
+ void client.app
56
+ .log({
34
57
  body: {
35
58
  ...base,
36
59
  level: "info",
37
60
  message: "startup toast unavailable",
38
61
  reason: result.reason,
39
62
  },
40
- }).catch(() => {
63
+ })
64
+ .catch(() => {
41
65
  // Advisory log failure stays silent
42
66
  });
43
67
  }
44
68
  else if (result.status === "failed") {
45
- void client.app.log({
69
+ void client.app
70
+ .log({
46
71
  body: {
47
72
  ...base,
48
73
  level: "warn",
@@ -51,21 +76,30 @@ export function notifyStartup(client, cfg) {
51
76
  reason: result.reason,
52
77
  ...(result.error ? { error: result.error } : {}),
53
78
  },
54
- }).catch(() => {
79
+ })
80
+ .catch(() => {
55
81
  // Advisory log failure stays silent
56
82
  });
57
83
  }
58
84
  });
59
85
  }
60
- void Promise.resolve(client.app.log({
61
- body: {
62
- service: "kibi-opencode",
63
- level: "info",
64
- message,
65
- ...(cfg.version ? { version: cfg.version } : {}),
66
- ...(cfg.directory ? { directory: cfg.directory } : {}),
67
- },
68
- })).catch((err) => {
86
+ // Structured startup log
87
+ const logBody = {
88
+ service: "kibi-opencode",
89
+ level: "info",
90
+ message,
91
+ };
92
+ if (cfg.versions && Object.keys(cfg.versions).length > 0) {
93
+ logBody.versions = cfg.versions;
94
+ logBody.unknownVersions = unknownVersions;
95
+ }
96
+ if (cfg.versionMetadataSource) {
97
+ logBody.versionMetadataSource = cfg.versionMetadataSource;
98
+ }
99
+ if (cfg.directory) {
100
+ logBody.directory = cfg.directory;
101
+ }
102
+ void Promise.resolve(client.app.log({ body: logBody })).catch(() => {
69
103
  // Advisory log failure stays silent
70
104
  });
71
105
  }
@@ -0,0 +1,13 @@
1
+ export interface KibiPackageVersions {
2
+ opencode: string;
3
+ mcp: string;
4
+ cli: string;
5
+ core: string;
6
+ source: "generated-dist" | "workspace-packages" | "unknown";
7
+ missing: string[];
8
+ }
9
+ export interface ReadKibiPackageVersionsOptions {
10
+ baseUrl?: string | URL;
11
+ readFileSync?: (path: string | URL, encoding: "utf-8") => string;
12
+ }
13
+ export declare function readKibiPackageVersions(options?: ReadKibiPackageVersionsOptions): KibiPackageVersions;
@@ -0,0 +1,78 @@
1
+ import { readFileSync as fsReadFileSync } from "node:fs";
2
+ // implements REQ-opencode-toast-package-versions-v1
3
+ export function readKibiPackageVersions(options) {
4
+ const baseUrl = options?.baseUrl ?? import.meta.url;
5
+ const readFs = options?.readFileSync ?? fsReadFileSync;
6
+ // Tier 1: generated dist JSON (build-time artifact)
7
+ try {
8
+ const distUrl = new URL("./version-metadata.json", baseUrl);
9
+ const raw = readFs(distUrl, "utf-8");
10
+ const parsed = JSON.parse(raw);
11
+ if (typeof parsed.opencode === "string" &&
12
+ typeof parsed.mcp === "string" &&
13
+ typeof parsed.cli === "string" &&
14
+ typeof parsed.core === "string") {
15
+ return {
16
+ opencode: parsed.opencode,
17
+ mcp: parsed.mcp,
18
+ cli: parsed.cli,
19
+ core: parsed.core,
20
+ source: "generated-dist",
21
+ missing: [],
22
+ };
23
+ }
24
+ }
25
+ catch {
26
+ // fall through
27
+ }
28
+ // Tier 2: workspace package.json files
29
+ try {
30
+ const entries = [
31
+ ["opencode", "../package.json"],
32
+ ["mcp", "../../mcp/package.json"],
33
+ ["cli", "../../cli/package.json"],
34
+ ["core", "../../core/package.json"],
35
+ ];
36
+ const versions = {};
37
+ const missing = [];
38
+ for (const [name, relPath] of entries) {
39
+ try {
40
+ const pkgUrl = new URL(relPath, baseUrl);
41
+ const raw = readFs(pkgUrl, "utf-8");
42
+ const pkg = JSON.parse(raw);
43
+ versions[name] =
44
+ typeof pkg.version === "string" ? pkg.version : "unknown";
45
+ if (typeof pkg.version !== "string") {
46
+ missing.push(name);
47
+ }
48
+ }
49
+ catch {
50
+ versions[name] = "unknown";
51
+ missing.push(name);
52
+ }
53
+ }
54
+ // Only claim workspace-packages if at least one resolved
55
+ if (Object.values(versions).some((v) => v !== "unknown")) {
56
+ return {
57
+ opencode: versions.opencode ?? "unknown",
58
+ mcp: versions.mcp ?? "unknown",
59
+ cli: versions.cli ?? "unknown",
60
+ core: versions.core ?? "unknown",
61
+ source: "workspace-packages",
62
+ missing: missing.length > 0 ? missing : [],
63
+ };
64
+ }
65
+ }
66
+ catch {
67
+ // fall through
68
+ }
69
+ // Tier 3: all unknown — never throws
70
+ return {
71
+ opencode: "unknown",
72
+ mcp: "unknown",
73
+ cli: "unknown",
74
+ core: "unknown",
75
+ source: "unknown",
76
+ missing: ["opencode", "mcp", "cli", "core"],
77
+ };
78
+ }
@@ -0,0 +1 @@
1
+ {"opencode":"0.15.0","mcp":"0.14.2","cli":"0.11.1","core":"0.5.3"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "kibi-opencode",
3
- "version": "0.14.0",
3
+ "version": "0.15.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",
@@ -52,7 +52,7 @@
52
52
  "access": "public"
53
53
  },
54
54
  "scripts": {
55
- "build": "tsc -p tsconfig.json && bun run scripts/build-tui.ts && tsc -p tsconfig.tui-types.json",
55
+ "build": "tsc -p tsconfig.json && bun run scripts/generate-version-metadata.ts && bun run scripts/build-tui.ts && tsc -p tsconfig.tui-types.json",
56
56
  "build:tui": "bun run scripts/build-tui.ts && tsc -p tsconfig.tui-types.json",
57
57
  "dev": "tsc -p tsconfig.json --watch",
58
58
  "clean": "rm -rf dist",
@@ -62,10 +62,10 @@
62
62
  "@opencode-ai/plugin": "^1.4.7",
63
63
  "@opentui/core": "^0.1.99",
64
64
  "@opentui/solid": "^0.1.99",
65
- "kibi-cli": "^0.11.0"
65
+ "kibi-cli": "^0.11.1"
66
66
  },
67
67
  "devDependencies": {
68
68
  "@types/node": "latest",
69
69
  "typescript": "^5.0.0"
70
70
  }
71
- }
71
+ }