kibi-opencode 0.6.0 → 0.7.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
@@ -152,6 +152,10 @@ Config files (project overrides global):
152
152
  | `sync.debounceMs` | number | `2000` | Debounce window in milliseconds |
153
153
  | `sync.ignore` | string[] | `[]` | Additional paths to ignore |
154
154
  | `sync.relevant` | string[] | `[]` | Additional relevant paths |
155
+ | `ux.toastStartup` | boolean | `true` | Show the startup confirmation toast independently from sync-status toasts |
156
+ | `ux.toastFailures` | boolean | `true` | Show failure toasts for sync/check issues |
157
+ | `ux.toastSuccesses` | boolean | `false` | Show success toasts for sync/check completion |
158
+ | `ux.toastCooldownMs` | number | `10000` | Cooldown between repeated UX toasts |
155
159
  | `guidance.dynamic` | boolean | `true` | Enable dynamic contextual guidance |
156
160
  | `guidance.warnOnKbEdits` | boolean | `true` | Enable loud warnings for .kb/** edits |
157
161
  | `guidance.factFirstDomainRouting` | boolean | `true` | Enable FACT-first domain routing suggestions |
@@ -41,11 +41,15 @@ function extractJsTsComments(content) {
41
41
  let i = 0;
42
42
  while (i < lines.length) {
43
43
  const line = lines[i];
44
+ if (line === undefined)
45
+ break;
44
46
  if (line.trim().startsWith("/*")) {
45
47
  const blockLines = [];
46
48
  let j = i;
47
49
  while (j < lines.length) {
48
50
  const blockLine = lines[j];
51
+ if (blockLine === undefined)
52
+ break;
49
53
  blockLines.push(blockLine);
50
54
  if (blockLine.includes("*/"))
51
55
  break;
@@ -68,8 +72,12 @@ function extractJsTsComments(content) {
68
72
  if (line.trim().startsWith("//")) {
69
73
  const commentLines = [];
70
74
  let j = i;
71
- while (j < lines.length && lines[j].trim().startsWith("//")) {
72
- commentLines.push(lines[j].trim().replace(/^\/\/\s?/, ""));
75
+ while (j < lines.length) {
76
+ const commentLine = lines[j];
77
+ if (commentLine === undefined || !commentLine.trim().startsWith("//")) {
78
+ break;
79
+ }
80
+ commentLines.push(commentLine.trim().replace(/^\/\/\s?/, ""));
73
81
  j++;
74
82
  }
75
83
  if (commentLines.length > 0) {
@@ -85,12 +93,6 @@ function extractJsTsComments(content) {
85
93
  }
86
94
  return comments;
87
95
  }
88
- /**
89
- * Check if a line is an assignment (x = y), which would disqualify a triple-quoted string from being a docstring.
90
- */
91
- function isAssignment(line) {
92
- return /^\s*\w+\s*=\s*["']/.test(line);
93
- }
94
96
  /**
95
97
  * Check if a line starts a class or function definition.
96
98
  */
@@ -109,7 +111,7 @@ function extractPythonComments(content) {
109
111
  let classOrDefIndent = 0;
110
112
  let foundClassDocstring = false;
111
113
  function getIndent(line) {
112
- return line.match(/^(\s*)/)?.[1].length || 0;
114
+ return line.match(/^(\s*)/)?.[1]?.length ?? 0;
113
115
  }
114
116
  function isSignificantLine(line) {
115
117
  const trimmed = line.trim();
@@ -118,15 +120,20 @@ function extractPythonComments(content) {
118
120
  function extractDocstring(startIdx, quote, indent) {
119
121
  const docstringLines = [];
120
122
  let j = startIdx;
121
- const startLine = lines[j].trim();
123
+ const startLine = lines[j];
124
+ if (startLine === undefined)
125
+ return null;
126
+ const trimmedStartLine = startLine.trim();
122
127
  // Extract content from opening line
123
- const openingMatch = startLine.match(new RegExp(`^\\s*${quote}(.*)$`));
128
+ const openingMatch = trimmedStartLine.match(new RegExp(`^\\s*${quote}(.*)$`));
124
129
  if (openingMatch?.[1]) {
125
130
  docstringLines.push(openingMatch[1].trim());
126
131
  }
127
132
  j++;
128
133
  while (j < lines.length) {
129
134
  const docLine = lines[j];
135
+ if (docLine === undefined)
136
+ break;
130
137
  if (docLine.includes(quote)) {
131
138
  // Closing line
132
139
  const closingMatch = docLine.match(new RegExp(`^(.*?)${quote}`));
@@ -150,6 +157,8 @@ function extractPythonComments(content) {
150
157
  let i = 0;
151
158
  while (i < lines.length) {
152
159
  const line = lines[i];
160
+ if (line === undefined)
161
+ break;
153
162
  const trimmed = line.trim();
154
163
  const indent = getIndent(line);
155
164
  // Process # line comments FIRST (before skipping)
@@ -160,12 +169,17 @@ function extractPythonComments(content) {
160
169
  // Collect contiguous # comments at same indent level
161
170
  while (j < lines.length) {
162
171
  const commentLine = lines[j];
172
+ if (commentLine === undefined)
173
+ break;
163
174
  const lineHashMatch = commentLine.match(/^(\s*)#(.*)$/);
164
175
  if (!lineHashMatch)
165
176
  break;
166
177
  if (getIndent(commentLine) !== currentIndent)
167
178
  break;
168
- commentLines.push(lineHashMatch[2].trim());
179
+ const commentText = lineHashMatch[2];
180
+ if (commentText === undefined)
181
+ break;
182
+ commentLines.push(commentText.trim());
169
183
  j++;
170
184
  }
171
185
  if (commentLines.length > 0) {
@@ -199,24 +213,6 @@ function extractPythonComments(content) {
199
213
  // Check for triple-quoted strings
200
214
  const isTripleQuote = trimmed.startsWith('"""') || trimmed.startsWith("'''");
201
215
  if (isTripleQuote) {
202
- // Skip if it's an assignment (x = """...""")
203
- if (isAssignment(line)) {
204
- // Track that we've seen a significant non-docstring statement
205
- if (!foundModuleDocstring && !insideClassOrDef) {
206
- foundModuleDocstring = true;
207
- }
208
- if (insideClassOrDef && !foundClassDocstring) {
209
- foundClassDocstring = true;
210
- }
211
- // Skip to end of string
212
- const quote = trimmed.startsWith('"""') ? '"""' : "'''";
213
- i++;
214
- while (i < lines.length && !lines[i].includes(quote)) {
215
- i++;
216
- }
217
- i++;
218
- continue;
219
- }
220
216
  const quote = trimmed.startsWith('"""') ? '"""' : "'''";
221
217
  // Check if this is a valid docstring position
222
218
  let isDocstring = false;
@@ -241,16 +237,13 @@ function extractPythonComments(content) {
241
237
  continue;
242
238
  }
243
239
  }
244
- // Not a docstring - mark as significant and skip it
245
- if (!foundModuleDocstring && !insideClassOrDef) {
246
- foundModuleDocstring = true;
247
- }
248
- if (insideClassOrDef && !foundClassDocstring) {
249
- foundClassDocstring = true;
250
- }
251
240
  // Find the closing quote
252
241
  i++;
253
- while (i < lines.length && !lines[i].includes(quote)) {
242
+ while (i < lines.length) {
243
+ const nextLine = lines[i];
244
+ if (nextLine === undefined || nextLine.includes(quote)) {
245
+ break;
246
+ }
254
247
  i++;
255
248
  }
256
249
  i++;
package/dist/config.d.ts CHANGED
@@ -11,6 +11,7 @@ export interface KibiConfig {
11
11
  relevant: string[];
12
12
  };
13
13
  ux: {
14
+ toastStartup: boolean;
14
15
  toastFailures: boolean;
15
16
  toastSuccesses: boolean;
16
17
  toastCooldownMs: number;
@@ -43,6 +44,7 @@ export interface KibiConfig {
43
44
  logLevel: string;
44
45
  }
45
46
  declare const DEFAULTS: KibiConfig;
47
+ export declare function validateAndMerge(obj: unknown): KibiConfig;
46
48
  export declare function loadConfig(projectDir?: string): KibiConfig;
47
49
  export declare function isPluginEnabled(cfg?: KibiConfig): boolean;
48
50
  export { DEFAULTS };
package/dist/config.js CHANGED
@@ -6,7 +6,12 @@ const DEFAULTS = {
6
6
  enabled: true,
7
7
  prompt: { enabled: true, hookMode: "auto" },
8
8
  sync: { enabled: true, debounceMs: 2000, ignore: [], relevant: [] },
9
- ux: { toastFailures: true, toastSuccesses: false, toastCooldownMs: 10000 },
9
+ ux: {
10
+ toastStartup: true,
11
+ toastFailures: true,
12
+ toastSuccesses: false,
13
+ toastCooldownMs: 10000,
14
+ },
10
15
  guidance: {
11
16
  dynamic: true,
12
17
  warnOnKbEdits: true,
@@ -49,7 +54,8 @@ function readJsonIfExists(filePath) {
49
54
  return null;
50
55
  }
51
56
  }
52
- function validateAndMerge(obj) {
57
+ // implements REQ-opencode-kibi-plugin-v1
58
+ export function validateAndMerge(obj) {
53
59
  if (!obj || typeof obj !== "object") {
54
60
  logger.warn("Config is not an object, using defaults");
55
61
  return DEFAULTS;
@@ -86,6 +92,8 @@ function validateAndMerge(obj) {
86
92
  if (src.ux && typeof src.ux === "object") {
87
93
  const u = src.ux;
88
94
  out.ux = { ...DEFAULTS.ux };
95
+ if (typeof u.toastStartup === "boolean")
96
+ out.ux.toastStartup = u.toastStartup;
89
97
  if (typeof u.toastFailures === "boolean")
90
98
  out.ux.toastFailures = u.toastFailures;
91
99
  if (typeof u.toastSuccesses === "boolean")
@@ -139,7 +147,8 @@ function validateAndMerge(obj) {
139
147
  out.guidance.smartEnforcement.preflightTtlMs = se.preflightTtlMs;
140
148
  if (typeof se.idleResetMs === "number")
141
149
  out.guidance.smartEnforcement.idleResetMs = se.idleResetMs;
142
- if (se.degradedMode === "warn-once" || se.degradedMode === "structured-only")
150
+ if (se.degradedMode === "warn-once" ||
151
+ se.degradedMode === "structured-only")
143
152
  out.guidance.smartEnforcement.degradedMode = se.degradedMode;
144
153
  if (typeof se.requireRootKbForStrict === "boolean")
145
154
  out.guidance.smartEnforcement.requireRootKbForStrict =
package/dist/index.d.ts CHANGED
@@ -5,6 +5,14 @@ export interface PluginInput {
5
5
  serverUrl?: unknown;
6
6
  $?: unknown;
7
7
  client?: {
8
+ tui?: {
9
+ toast?: (payload: {
10
+ variant?: "info" | "success" | "warning" | "error";
11
+ title?: string;
12
+ message: string;
13
+ duration?: number;
14
+ }) => void | Promise<void>;
15
+ };
8
16
  app: {
9
17
  log: (payload: Record<string, unknown>) => Promise<void>;
10
18
  };
package/dist/index.js CHANGED
@@ -1,44 +1,18 @@
1
- import { execSync } from "node:child_process";
2
1
  import * as path from "node:path";
3
2
  import { analyzeCodeFile, } from "./comment-analysis.js";
4
- import * as config from "./config.js";
5
3
  import * as fileFilter from "./file-filter.js";
6
- import { getGuidanceCache } from "./guidance-cache.js";
7
4
  import * as logger from "./logger.js";
8
5
  import { analyzePath } from "./path-kind.js";
9
6
  import { SENTINEL, buildPrompt } from "./prompt.js";
10
- import { detectPosture } from "./repo-posture.js";
11
7
  import { isMustPriorityRequirement } from "./requirement-doc.js";
12
8
  import { classifyRisk } from "./risk-classifier.js";
13
- import { createSyncScheduler as importedCreateSyncScheduler, } from "./scheduler.js";
14
9
  import { getSessionTracker } from "./session-tracker.js";
15
- import { computeEffectiveMode, } from "./smart-enforcement.js";
16
- import { checkWorkspaceHealth } from "./workspace-health.js";
10
+ import { notifyStartup } from "./startup-notifier.js";
11
+ import { runPluginStartup } from "./plugin-startup.js";
17
12
  import * as fs from "node:fs";
18
13
  function deriveFileBucket(kind) {
19
14
  return kind;
20
15
  }
21
- function resolveCurrentBranch(cwd) {
22
- try {
23
- return execSync("git rev-parse --abbrev-ref HEAD", {
24
- cwd,
25
- encoding: "utf8",
26
- stdio: ["ignore", "pipe", "ignore"],
27
- }).trim();
28
- }
29
- catch {
30
- return "unknown";
31
- }
32
- }
33
- function readConfigFingerprint(cwd) {
34
- try {
35
- return fs.readFileSync(path.join(cwd, ".kb", "config.json"), "utf-8");
36
- }
37
- catch {
38
- return "missing";
39
- }
40
- }
41
- const workspaceCacheState = new Map();
42
16
  /**
43
17
  * Lint requirement documents for embedded scenarios/tests and oversized content.
44
18
  */
@@ -78,119 +52,11 @@ function lintRequirementDoc(filePath, worktree) {
78
52
  }
79
53
  // implements REQ-opencode-kibi-plugin-v1
80
54
  const kibiOpencodePlugin = async (input) => {
81
- // Load config
82
- const cfg = config.loadConfig(input.directory);
83
- if (!cfg.enabled) {
84
- logger.info("kibi-opencode: disabled via config");
55
+ const startup = await runPluginStartup(input);
56
+ if (!startup) {
85
57
  return {};
86
58
  }
87
- // Check workspace health for bootstrap nudges
88
- // Reset the logger client first to avoid leaking a previous invocation's
89
- // client into this instance, then set the new one if provided.
90
- logger.resetClient();
91
- if (input.client) {
92
- logger.setClient(input.client);
93
- }
94
- const workspaceHealth = checkWorkspaceHealth(input.worktree);
95
- if (workspaceHealth.needsBootstrap) {
96
- logger.error("kibi-opencode: workspace needs Kibi bootstrap");
97
- getSessionTracker().recordWarning("bootstrap-needed", input.worktree, "Workspace missing Kibi bootstrap");
98
- }
99
- // Log session summary periodically (gated on config)
100
- if (cfg.guidance.sessionSummary.enabled) {
101
- const tracker = getSessionTracker();
102
- if (tracker.isSessionExpired(cfg.guidance.sessionSummary.logIntervalMs)) {
103
- tracker.logSummary();
104
- tracker.reset();
105
- }
106
- }
107
- const posture = detectPosture(input.worktree);
108
- const currentBranch = resolveCurrentBranch(input.worktree);
109
- const configFingerprint = readConfigFingerprint(input.worktree);
110
- const cache = getGuidanceCache(cfg.guidance.smartEnforcement.preflightTtlMs, cfg.guidance.smartEnforcement.idleResetMs);
111
- const previousCacheState = workspaceCacheState.get(input.worktree);
112
- if (previousCacheState) {
113
- if (previousCacheState.branch !== currentBranch) {
114
- cache.invalidateForBranch(previousCacheState.branch);
115
- }
116
- if (previousCacheState.posture !== posture.state ||
117
- previousCacheState.configFingerprint !== configFingerprint) {
118
- cache.invalidateForWorkspace(input.worktree);
119
- }
120
- }
121
- workspaceCacheState.set(input.worktree, {
122
- branch: currentBranch,
123
- posture: posture.state,
124
- configFingerprint,
125
- });
126
- // Session-local runtime degraded overlay (latched, never cleared)
127
- const runtimeOverlay = {
128
- degraded: false,
129
- causes: [],
130
- };
131
- let degradedWarnedOnce = false;
132
- function latchRuntimeDegraded(cause) {
133
- if (!runtimeOverlay.degraded) {
134
- runtimeOverlay.degraded = true;
135
- runtimeOverlay.primaryCause = cause;
136
- runtimeOverlay.causes.push(cause);
137
- logger.info("smart-enforcement.degraded", {
138
- event: "smart_enforcement_degraded",
139
- overlay_cause: cause,
140
- runtime_degraded: true,
141
- static_degraded: posture.maintenanceDegraded,
142
- merged_degraded: getMaintenanceDegraded(),
143
- maintenance_state: getMaintenanceDegraded()
144
- ? "maintenance_degraded"
145
- : "maintenance_available",
146
- effective_mode: getEffectiveMode(),
147
- });
148
- }
149
- else if (!runtimeOverlay.causes.includes(cause)) {
150
- runtimeOverlay.causes.push(cause);
151
- }
152
- }
153
- function getMaintenanceDegraded() {
154
- return posture.maintenanceDegraded || runtimeOverlay.degraded;
155
- }
156
- function getEffectiveMode() {
157
- return computeEffectiveMode({
158
- mode: cfg.guidance.smartEnforcement.mode,
159
- requireRootKbForStrict: cfg.guidance.smartEnforcement.requireRootKbForStrict,
160
- posture: posture.state,
161
- maintenanceDegraded: getMaintenanceDegraded(),
162
- });
163
- }
164
- // Compute effective smart-enforcement mode from config + posture + runtime overlay
165
- // Latch startup-level runtime degraded causes
166
- if (posture.state === "vendored_only" ||
167
- posture.state === "root_uninitialized" ||
168
- posture.state === "root_partial") {
169
- latchRuntimeDegraded("non_authoritative_posture");
170
- }
171
- if (!cfg.sync.enabled) {
172
- latchRuntimeDegraded("sync_disabled");
173
- }
174
- const maintenanceDegraded = getMaintenanceDegraded();
175
- logger.info("smart-enforcement.posture", {
176
- event: "smart_enforcement_posture",
177
- posture: posture.state,
178
- posture_state: posture.state,
179
- maintenance_state: maintenanceDegraded
180
- ? "maintenance_degraded"
181
- : "maintenance_available",
182
- needs_bootstrap: workspaceHealth.needsBootstrap,
183
- posture_reason: posture.reason,
184
- reason_code: posture.reason,
185
- smart_enforcement_mode: cfg.guidance.smartEnforcement.mode,
186
- effective_mode: getEffectiveMode(),
187
- static_degraded: posture.maintenanceDegraded,
188
- runtime_degraded: runtimeOverlay.degraded,
189
- merged_degraded: maintenanceDegraded,
190
- overlay_cause: runtimeOverlay.primaryCause ?? null,
191
- branch: currentBranch,
192
- });
193
- logger.info("kibi-opencode: setting up hooks");
59
+ const { cfg, workspaceHealth, posture, currentBranch, cache, runtimeOverlay, scheduler, maintenanceDegraded, getMaintenanceDegraded, getEffectiveMode, latchRuntimeDegraded, } = startup;
194
60
  const hooks = {};
195
61
  // Plugin instance state (not module globals)
196
62
  const MAX_RECENT_EDITS = 5;
@@ -199,30 +65,7 @@ const kibiOpencodePlugin = async (input) => {
199
65
  let recentCommentSuggestion = null;
200
66
  const seenFingerprints = new Set(); // For deduplication
201
67
  let lastRiskClass = null;
202
- const createSyncScheduler = globalThis.__kibi_test_scheduler_factory ?? importedCreateSyncScheduler;
203
- // Create scheduler only if sync is enabled
204
- let scheduler = null;
205
- if (cfg.sync.enabled) {
206
- try {
207
- const schedulerOpts = {
208
- worktree: input.worktree,
209
- config: cfg,
210
- onRunComplete: (meta) => {
211
- if (meta.exitCode !== 0) {
212
- latchRuntimeDegraded("scheduler_sync_failed");
213
- }
214
- if (meta.checkExitCode !== undefined && meta.checkExitCode !== 0) {
215
- latchRuntimeDegraded("scheduler_check_failed");
216
- }
217
- },
218
- };
219
- scheduler = createSyncScheduler(schedulerOpts);
220
- }
221
- catch {
222
- latchRuntimeDegraded("scheduler_unavailable");
223
- scheduler = null;
224
- }
225
- }
68
+ let degradedWarnedOnce = false;
226
69
  hooks.event = async ({ event }) => {
227
70
  if (event.type !== "file.edited")
228
71
  return;
@@ -492,7 +335,6 @@ const kibiOpencodePlugin = async (input) => {
492
335
  else {
493
336
  recentCommentSuggestion = null;
494
337
  }
495
- return;
496
338
  }
497
339
  return;
498
340
  };
@@ -515,7 +357,6 @@ const kibiOpencodePlugin = async (input) => {
515
357
  hasRecentKbEdit,
516
358
  recentCommentSuggestion,
517
359
  posture: posture.state,
518
- riskClass: lastRiskClass ?? undefined,
519
360
  cache,
520
361
  workspaceRoot: input.worktree,
521
362
  branch: currentBranch,
@@ -523,6 +364,7 @@ const kibiOpencodePlugin = async (input) => {
523
364
  maintenanceDegraded,
524
365
  degradedMode: cfg.guidance.smartEnforcement.degradedMode,
525
366
  showDegradedAdvisory,
367
+ ...(lastRiskClass != null ? { riskClass: lastRiskClass } : {}),
526
368
  });
527
369
  logger.info("smart-enforcement.guidance", {
528
370
  event: "smart_enforcement_guidance",
@@ -582,6 +424,15 @@ const kibiOpencodePlugin = async (input) => {
582
424
  }
583
425
  }
584
426
  logger.info("kibi-opencode: setup complete");
427
+ if (input.client && !maintenanceDegraded) {
428
+ const client = input.client;
429
+ setTimeout(() => {
430
+ notifyStartup(client, {
431
+ suppressToast: cfg.ux.toastStartup === false,
432
+ directory: input.directory,
433
+ });
434
+ }, 2000);
435
+ }
585
436
  return hooks;
586
437
  };
587
438
  export default kibiOpencodePlugin;
@@ -0,0 +1,28 @@
1
+ import * as config from "./config.js";
2
+ import { getGuidanceCache } from "./guidance-cache.js";
3
+ import { detectPosture } from "./repo-posture.js";
4
+ import { createSyncScheduler } from "./scheduler.js";
5
+ import { type EffectiveMode } from "./smart-enforcement.js";
6
+ import { checkWorkspaceHealth } from "./workspace-health.js";
7
+ import type { PluginInput } from "./index.js";
8
+ type PostureSnapshot = ReturnType<typeof detectPosture>;
9
+ export interface RuntimeDegradedOverlay {
10
+ degraded: boolean;
11
+ primaryCause?: "sync_disabled" | "scheduler_unavailable" | "scheduler_sync_failed" | "scheduler_check_failed" | "non_authoritative_posture";
12
+ causes: string[];
13
+ }
14
+ export interface PluginStartupContext {
15
+ cfg: ReturnType<typeof config.loadConfig>;
16
+ workspaceHealth: ReturnType<typeof checkWorkspaceHealth>;
17
+ posture: PostureSnapshot;
18
+ currentBranch: string;
19
+ cache: ReturnType<typeof getGuidanceCache>;
20
+ runtimeOverlay: RuntimeDegradedOverlay;
21
+ scheduler: ReturnType<typeof createSyncScheduler> | null;
22
+ maintenanceDegraded: boolean;
23
+ getMaintenanceDegraded: () => boolean;
24
+ getEffectiveMode: () => EffectiveMode;
25
+ latchRuntimeDegraded: (cause: NonNullable<RuntimeDegradedOverlay["primaryCause"]>) => void;
26
+ }
27
+ export declare function runPluginStartup(input: PluginInput): Promise<PluginStartupContext | null>;
28
+ export {};
@@ -0,0 +1,177 @@
1
+ import { execSync } from "node:child_process";
2
+ import * as fs from "node:fs";
3
+ import * as path from "node:path";
4
+ import * as config from "./config.js";
5
+ import { getGuidanceCache } from "./guidance-cache.js";
6
+ import * as logger from "./logger.js";
7
+ import { detectPosture } from "./repo-posture.js";
8
+ import { createSyncScheduler } from "./scheduler.js";
9
+ import { getSessionTracker } from "./session-tracker.js";
10
+ import { computeEffectiveMode, } from "./smart-enforcement.js";
11
+ import { checkWorkspaceHealth } from "./workspace-health.js";
12
+ const workspaceCacheState = new Map();
13
+ function resolveCurrentBranch(cwd) {
14
+ try {
15
+ return execSync("git rev-parse --abbrev-ref HEAD", {
16
+ cwd,
17
+ encoding: "utf8",
18
+ stdio: ["ignore", "pipe", "ignore"],
19
+ }).trim();
20
+ }
21
+ catch {
22
+ return "unknown";
23
+ }
24
+ }
25
+ function readConfigFingerprint(cwd) {
26
+ try {
27
+ return fs.readFileSync(path.join(cwd, ".kb", "config.json"), "utf-8");
28
+ }
29
+ catch {
30
+ return "missing";
31
+ }
32
+ }
33
+ // implements REQ-opencode-smart-enforcement-v1, REQ-opencode-kibi-plugin-v1
34
+ export async function runPluginStartup(input) {
35
+ const cfg = config.loadConfig(input.directory);
36
+ if (!cfg.enabled) {
37
+ logger.info("kibi-opencode: disabled via config");
38
+ return null;
39
+ }
40
+ logger.resetClient();
41
+ if (input.client) {
42
+ logger.setClient(input.client);
43
+ }
44
+ const workspaceHealth = checkWorkspaceHealth(input.worktree);
45
+ if (workspaceHealth.needsBootstrap) {
46
+ logger.error("kibi-opencode: workspace needs Kibi bootstrap");
47
+ getSessionTracker().recordWarning("bootstrap-needed", input.worktree, "Workspace missing Kibi bootstrap");
48
+ }
49
+ if (cfg.guidance.sessionSummary.enabled) {
50
+ const tracker = getSessionTracker();
51
+ if (tracker.isSessionExpired(cfg.guidance.sessionSummary.logIntervalMs)) {
52
+ tracker.logSummary();
53
+ tracker.reset();
54
+ }
55
+ }
56
+ const posture = detectPosture(input.worktree);
57
+ const currentBranch = resolveCurrentBranch(input.worktree);
58
+ const configFingerprint = readConfigFingerprint(input.worktree);
59
+ const cache = getGuidanceCache(cfg.guidance.smartEnforcement.preflightTtlMs, cfg.guidance.smartEnforcement.idleResetMs);
60
+ const previousCacheState = workspaceCacheState.get(input.worktree);
61
+ if (previousCacheState) {
62
+ if (previousCacheState.branch !== currentBranch) {
63
+ cache.invalidateForBranch(previousCacheState.branch);
64
+ }
65
+ if (previousCacheState.posture !== posture.state ||
66
+ previousCacheState.configFingerprint !== configFingerprint) {
67
+ cache.invalidateForWorkspace(input.worktree);
68
+ }
69
+ }
70
+ workspaceCacheState.set(input.worktree, {
71
+ branch: currentBranch,
72
+ posture: posture.state,
73
+ configFingerprint,
74
+ });
75
+ const runtimeOverlay = {
76
+ degraded: false,
77
+ causes: [],
78
+ };
79
+ function latchRuntimeDegraded(cause) {
80
+ if (!runtimeOverlay.degraded) {
81
+ runtimeOverlay.degraded = true;
82
+ runtimeOverlay.primaryCause = cause;
83
+ runtimeOverlay.causes.push(cause);
84
+ logger.info("smart-enforcement.degraded", {
85
+ event: "smart_enforcement_degraded",
86
+ overlay_cause: cause,
87
+ runtime_degraded: true,
88
+ static_degraded: posture.maintenanceDegraded,
89
+ merged_degraded: getMaintenanceDegraded(),
90
+ maintenance_state: getMaintenanceDegraded()
91
+ ? "maintenance_degraded"
92
+ : "maintenance_available",
93
+ effective_mode: getEffectiveMode(),
94
+ });
95
+ }
96
+ else if (!runtimeOverlay.causes.includes(cause)) {
97
+ runtimeOverlay.causes.push(cause);
98
+ }
99
+ }
100
+ function getMaintenanceDegraded() {
101
+ return posture.maintenanceDegraded || runtimeOverlay.degraded;
102
+ }
103
+ function getEffectiveMode() {
104
+ return computeEffectiveMode({
105
+ mode: cfg.guidance.smartEnforcement.mode,
106
+ requireRootKbForStrict: cfg.guidance.smartEnforcement.requireRootKbForStrict,
107
+ posture: posture.state,
108
+ maintenanceDegraded: getMaintenanceDegraded(),
109
+ });
110
+ }
111
+ if (posture.state === "vendored_only" ||
112
+ posture.state === "root_uninitialized" ||
113
+ posture.state === "root_partial") {
114
+ latchRuntimeDegraded("non_authoritative_posture");
115
+ }
116
+ if (!cfg.sync.enabled) {
117
+ latchRuntimeDegraded("sync_disabled");
118
+ }
119
+ const maintenanceDegraded = getMaintenanceDegraded();
120
+ logger.info("smart-enforcement.posture", {
121
+ event: "smart_enforcement_posture",
122
+ posture: posture.state,
123
+ posture_state: posture.state,
124
+ maintenance_state: maintenanceDegraded
125
+ ? "maintenance_degraded"
126
+ : "maintenance_available",
127
+ needs_bootstrap: workspaceHealth.needsBootstrap,
128
+ posture_reason: posture.reason,
129
+ reason_code: posture.reason,
130
+ smart_enforcement_mode: cfg.guidance.smartEnforcement.mode,
131
+ effective_mode: getEffectiveMode(),
132
+ static_degraded: posture.maintenanceDegraded,
133
+ runtime_degraded: runtimeOverlay.degraded,
134
+ merged_degraded: maintenanceDegraded,
135
+ overlay_cause: runtimeOverlay.primaryCause ?? null,
136
+ branch: currentBranch,
137
+ });
138
+ logger.info("kibi-opencode: setting up hooks");
139
+ const schedulerFactory = globalThis.__kibi_test_scheduler_factory ?? createSyncScheduler;
140
+ const scheduler = cfg.sync
141
+ .enabled
142
+ ? (() => {
143
+ try {
144
+ const schedulerOpts = {
145
+ worktree: input.worktree,
146
+ config: cfg,
147
+ onRunComplete: (meta) => {
148
+ if (meta.exitCode !== 0)
149
+ latchRuntimeDegraded("scheduler_sync_failed");
150
+ if (meta.checkExitCode !== undefined &&
151
+ meta.checkExitCode !== 0) {
152
+ latchRuntimeDegraded("scheduler_check_failed");
153
+ }
154
+ },
155
+ };
156
+ return schedulerFactory(schedulerOpts);
157
+ }
158
+ catch {
159
+ latchRuntimeDegraded("scheduler_unavailable");
160
+ return null;
161
+ }
162
+ })()
163
+ : null;
164
+ return {
165
+ cfg,
166
+ workspaceHealth,
167
+ posture,
168
+ currentBranch,
169
+ cache,
170
+ runtimeOverlay,
171
+ scheduler,
172
+ maintenanceDegraded,
173
+ getMaintenanceDegraded,
174
+ getEffectiveMode,
175
+ latchRuntimeDegraded,
176
+ };
177
+ }
package/dist/prompt.d.ts CHANGED
@@ -33,6 +33,7 @@ export interface PromptContext {
33
33
  /** Whether to show the degraded advisory block this invocation */
34
34
  showDegradedAdvisory?: boolean;
35
35
  }
36
+ export declare function postureGuidance(posture: RepoPosture): string | null;
36
37
  /**
37
38
  * Build prompt with contextual guidance based on posture, risk class, and cache state.
38
39
  */
package/dist/prompt.js CHANGED
@@ -56,10 +56,10 @@ Requirement edits need policy alignment. Run kb_check with required-fields and n
56
56
  - Add verification: create or update linked SCEN and TEST entities`,
57
57
  behavior_candidate: `📝 **Code changes detected**
58
58
 
59
- Code changes need traceability. Use kb_search for context. For test/e2e symbols, prefer durable relationships (e.g. via symbols.yaml with covered_by + validates/verified_by); inline // implements REQ-xxx comments remain optional and backward-compatible.`,
59
+ Production code: use \`implements\` (symbol→req) for requirement ownership. Test code: use \`executable_for\` (symbol→test). \`covered_by\` is coverage evidence only. Prefer scenario-first: req→scenario→test when scenarios exist.`,
60
60
  traceability_candidate: `📝 **Code changes detected**
61
61
 
62
- Code changes need traceability. Use kb_search for context. For test/e2e symbols, prefer durable relationships (e.g. via symbols.yaml with covered_by + validates/verified_by); inline // implements REQ-xxx comments remain optional and backward-compatible.
62
+ Production code: use \`implements\` (symbol→req) for requirement ownership. Test code: use \`executable_for\` (symbol→test). \`covered_by\` is coverage evidence only. Prefer scenario-first: req→scenario→test when scenarios exist.
63
63
  - Durable knowledge comment detected — route to KB instead of inline comments
64
64
  - Use kb_upsert for FACT, ADR, or REQ entities as appropriate`,
65
65
  manual_kb_edit: `⚠️ **WARNING: Direct .kb/ edits bypass validation**
@@ -70,7 +70,8 @@ The Kibi knowledge base is managed through public MCP tools. Direct manual edits
70
70
  - Use kb_check to validate consistency`,
71
71
  };
72
72
  // ── Posture overrides ──────────────────────────────────────────────────
73
- function postureGuidance(posture) {
73
+ export function postureGuidance(posture) {
74
+ // implements REQ-opencode-prompt-injection
74
75
  switch (posture) {
75
76
  case "vendored_only":
76
77
  // Minimal guidance only, no bootstrap nags
@@ -191,7 +192,7 @@ Before implementing or explaining code:
191
192
  1. **Discover first** - Run kb_search to find related requirements, ADRs, tests, facts, and symbols.
192
193
  2. **Follow up exactly** - Run kb_query by sourceFile, id, type, or tags once you know what you need.
193
194
  3. **Prefer Kibi over comments** - Store durable knowledge in KB entities instead of inline comments.
194
- 4. **Add traceability** - For test/e2e symbols, prefer durable symbol/test/requirement relationships (e.g. via symbols.yaml with covered_by + validates/verified_by); inline // implements REQ-xxx comments remain optional and backward-compatible for quick code-only changes.
195
+ 4. **Add traceability** - Production code: \`implements\` (symbol→req) for ownership. Test code: \`executable_for\`. \`covered_by\` is coverage evidence only for production symbols.
195
196
 
196
197
  If you're adding long explanatory comments, consider routing that knowledge to:
197
198
  - \`FACT\` for domain invariants, properties, limits, cardinalities
@@ -228,9 +229,6 @@ If you're adding long explanatory comments, consider routing that knowledge to:
228
229
  if (headerEnd !== -1) {
229
230
  selectedBlock = `${selectedBlock.slice(0, headerEnd + 1)}- Existing Kibi links: ${linkedIds.join(", ")}\n${selectedBlock.slice(headerEnd + 1)}`;
230
231
  }
231
- else {
232
- selectedBlock = `${selectedBlock}\n- Existing Kibi links: ${linkedIds.join(", ")}`;
233
- }
234
232
  }
235
233
  }
236
234
  }
@@ -320,7 +318,7 @@ Your recent code edit contains a comment that looks like **behavior intent** (sy
320
318
  **Action**: Instead of inline comments, route this to a REQ entity:
321
319
  - Create \`documentation/requirements/REQ-xxx.md\` with the behavior description
322
320
  - Add SCEN and TEST entities for specification and verification
323
- - Link code to requirements: for test/e2e symbols prefer durable relationships (e.g. via symbols.yaml with covered_by + validates/verified_by); inline // implements REQ-xxx comments remain optional and backward-compatible
321
+ - Link code: production uses \`implements\` (symbol→req) for ownership; test code uses \`executable_for\`; \`covered_by\` is coverage evidence only
324
322
 
325
323
  This ensures behavior is documented and traceable.`;
326
324
  default:
@@ -330,7 +328,7 @@ Before implementing or explaining code:
330
328
  1. **Discover first** - Run kb_search to find related requirements, ADRs, tests, facts, and symbols.
331
329
  2. **Follow up exactly** - Run kb_query by sourceFile, id, type, or tags once you know what you need.
332
330
  3. **Prefer Kibi over comments** - Store durable knowledge in KB entities instead of inline comments.
333
- 4. **Add traceability** - For test/e2e symbols, prefer durable symbol/test/requirement relationships (e.g. via symbols.yaml with covered_by + validates/verified_by); inline // implements REQ-xxx comments remain optional and backward-compatible for quick code-only changes.`;
331
+ 4. **Add traceability** - Production code: \`implements\` (symbol→req) for ownership. Test code: \`executable_for\`. \`covered_by\` is coverage evidence only for production symbols.`;
334
332
  }
335
333
  }
336
334
  // ── Base guidance (no context) ─────────────────────────────────────────
@@ -342,7 +340,7 @@ This project uses Kibi (via MCP). Prefer storing durable knowledge in Kibi over
342
340
 
343
341
  Before changing behavior: use kb_search for discovery, then kb_query by sourceFile, id, type, or tags for exact follow-up; do not rely on undocumented tools.
344
342
 
345
- Keep changed symbols traceable: for test and e2e code, prefer durable symbol/test/requirement relationships (e.g. via \`symbols.yaml\`); inline \`// implements REQ-xxx\` comments remain optional and backward-compatible for quick code-only changes.
343
+ Keep changed symbols traceable: production code uses \`implements\` (symbol→req) for ownership; test code uses \`executable_for\`; \`covered_by\` is coverage evidence only. Inline \`// implements REQ-xxx\` comments remain backward-compatible.
346
344
 
347
345
  Run kb_check after KB mutations.
348
346
 
@@ -353,7 +351,7 @@ Dogfood note for this repo: OpenCode here uses local built \`kibi-mcp\` and \`ki
353
351
  2. **Confirm**: Run kb_query with sourceFile, id, type, or tags once you know the exact follow-up target.
354
352
  3. **Inspect freshness**: Run kb_status when branch or stale-state confidence matters.
355
353
  4. **Document intent**: If you are about to explain code, STOP. Route that explanation to kb_upsert instead of inline comments.
356
- 5. **Link during work**: When creating KB entities, include relationship rows: specified_by (req→scenario), verified_by (req→test), implements (symbol→req), covered_by (symbol→test).
354
+ 5. **Link during work**: When creating KB entities, include relationship rows: specified_by (req→scenario), implements (symbol→req for ownership), covered_by (symbol→test for coverage), executable_for (test code→test).
357
355
  6. **Validate**: Run kb_check after KB mutations to catch violations early.
358
356
 
359
357
  **Public Kibi tools only:** kb_search, kb_query, kb_status, kb_find_gaps, kb_coverage, kb_graph, kb_upsert, kb_delete, kb_check.
@@ -1,6 +1,6 @@
1
1
  // implements REQ-opencode-smart-enforcement-v1, REQ-opencode-kibi-plugin-v1
2
2
  import { existsSync, readFileSync, readdirSync } from "node:fs";
3
- import { join } from "node:path";
3
+ import { join, resolve } from "node:path";
4
4
  // Default sync paths — must stay in sync with file-filter.ts DEFAULT_SYNC_PATHS
5
5
  const DEFAULT_SYNC_PATHS = {
6
6
  requirements: "documentation/requirements/**/*.md",
@@ -73,11 +73,13 @@ function rootTargetsAllResolve(cwd) {
73
73
  ];
74
74
  for (const key of defaultKeys) {
75
75
  const raw = paths?.[key] ?? DEFAULT_SYNC_PATHS[key];
76
+ if (!raw)
77
+ return false;
76
78
  // Normalize: strip trailing slashes and glob patterns to get the root dir/file path
77
79
  const normalized = raw.replace(/\/+$/, "");
78
80
  const isFile = normalized.endsWith(".yaml") || normalized.endsWith(".yml");
79
81
  if (isFile) {
80
- if (!existsSync(join(cwd, normalized)))
82
+ if (!existsSync(resolve(cwd, normalized)))
81
83
  return false;
82
84
  }
83
85
  else {
@@ -90,7 +92,7 @@ function rootTargetsAllResolve(cwd) {
90
92
  rootSegments.push(seg);
91
93
  }
92
94
  const dirPath = rootSegments.join("/") || ".";
93
- if (!existsSync(join(cwd, dirPath)))
95
+ if (!existsSync(resolve(cwd, dirPath)))
94
96
  return false;
95
97
  }
96
98
  }
@@ -42,6 +42,8 @@ function parseFrontmatter(content) {
42
42
  if (!match)
43
43
  return null;
44
44
  const frontmatterText = match[1];
45
+ if (frontmatterText === undefined)
46
+ return null;
45
47
  const result = {};
46
48
  // Simple YAML-like parsing for top-level scalar values only
47
49
  // Handles inline comments by ignoring everything after # (unless quoted)
@@ -56,8 +58,10 @@ function parseFrontmatter(content) {
56
58
  let value = line.slice(colonIndex + 1).trim();
57
59
  // Strip inline comments (simple heuristic: unquoted #)
58
60
  const commentMatch = value.match(/^(.*?)\s+#\s/);
59
- if (commentMatch && !isInsideQuotes(value, commentMatch[1].length)) {
60
- value = commentMatch[1].trim();
61
+ const commentValue = commentMatch?.[1];
62
+ if (commentValue !== undefined &&
63
+ !isInsideQuotes(value, commentValue.length)) {
64
+ value = commentValue.trim();
61
65
  }
62
66
  if (key && value) {
63
67
  if ((value.startsWith('"') && value.endsWith('"')) ||
package/dist/scheduler.js CHANGED
@@ -39,7 +39,11 @@ class WorktreeSyncScheduler {
39
39
  return;
40
40
  this.lastFileEditedAt = this.now();
41
41
  }
42
- this.pending = { reason, filePath, checkRules };
42
+ this.pending = {
43
+ reason,
44
+ ...(filePath !== undefined ? { filePath } : {}),
45
+ ...(checkRules !== undefined ? { checkRules } : {}),
46
+ };
43
47
  if (this.timer)
44
48
  this.clearTimeoutFn(this.timer);
45
49
  this.timer = this.setTimeoutFn(() => {
@@ -135,8 +139,12 @@ class WorktreeSyncScheduler {
135
139
  this.trailing = null;
136
140
  void this.startRun({
137
141
  reason: `${trailing.reason}.trailing`,
138
- filePath: trailing.filePath,
139
- checkRules: trailing.checkRules,
142
+ ...(trailing.filePath !== undefined
143
+ ? { filePath: trailing.filePath }
144
+ : {}),
145
+ ...(trailing.checkRules !== undefined
146
+ ? { checkRules: trailing.checkRules }
147
+ : {}),
140
148
  });
141
149
  }
142
150
  }
@@ -146,12 +154,12 @@ class WorktreeSyncScheduler {
146
154
  const meta = {
147
155
  reason: trigger.reason,
148
156
  worktree: this.worktree,
149
- filePath: trigger.filePath,
150
157
  debounceWindowMs: this.config.sync.debounceMs,
151
158
  durationMs,
152
159
  exitCode,
153
- checkExitCode,
154
- checkRules,
160
+ ...(trigger.filePath !== undefined ? { filePath: trigger.filePath } : {}),
161
+ ...(checkExitCode !== undefined ? { checkExitCode } : {}),
162
+ ...(checkRules !== undefined ? { checkRules } : {}),
155
163
  };
156
164
  if (exitCode === 0) {
157
165
  logger.info(`sync.succeeded ${JSON.stringify(meta)}`);
@@ -10,6 +10,7 @@ const STRICT_ELIGIBLE_POSTURES = new Set([
10
10
  * Only root_active and hybrid_root_plus_vendored are considered authoritative.
11
11
  */
12
12
  export function isStrictEligible(inputs) {
13
+ // implements REQ-opencode-smart-enforcement-v1
13
14
  if (inputs.maintenanceDegraded)
14
15
  return false;
15
16
  if (inputs.requireRootKbForStrict) {
@@ -31,6 +32,7 @@ export function isStrictEligible(inputs) {
31
32
  * - maintenance-degraded → advisory regardless of config
32
33
  */
33
34
  export function computeEffectiveMode(inputs) {
35
+ // implements REQ-opencode-smart-enforcement-v1
34
36
  // Maintenance-degraded always forces advisory
35
37
  if (inputs.maintenanceDegraded) {
36
38
  return "advisory";
@@ -87,7 +87,7 @@ function parseSymbolsYaml(content) {
87
87
  let section = "none";
88
88
  let pendingRel = null;
89
89
  function flushRel() {
90
- if (pendingRel?.type && pendingRel.target && current) {
90
+ if (pendingRel?.type && pendingRel.target && current?.relationships) {
91
91
  current.relationships.push({
92
92
  type: pendingRel.type,
93
93
  target: pendingRel.target,
@@ -110,7 +110,10 @@ function parseSymbolsYaml(content) {
110
110
  const entryMatch = raw.match(/^\s+-\s+id:\s*(.+)$/);
111
111
  if (entryMatch) {
112
112
  flushEntry();
113
- current = { id: entryMatch[1].trim(), links: [], relationships: [] };
113
+ const entryId = entryMatch[1];
114
+ if (entryId === undefined)
115
+ continue;
116
+ current = { id: entryId.trim(), links: [], relationships: [] };
114
117
  section = "none";
115
118
  continue;
116
119
  }
@@ -119,7 +122,10 @@ function parseSymbolsYaml(content) {
119
122
  // sourceFile
120
123
  const srcMatch = raw.match(/^\s+sourceFile:\s*(.+)$/);
121
124
  if (srcMatch) {
122
- current.sourceFile = srcMatch[1].trim();
125
+ const sourceFile = srcMatch[1];
126
+ if (sourceFile === undefined)
127
+ continue;
128
+ current.sourceFile = sourceFile.trim();
123
129
  section = "none";
124
130
  continue;
125
131
  }
@@ -139,7 +145,10 @@ function parseSymbolsYaml(content) {
139
145
  if (section === "links") {
140
146
  const linkMatch = raw.match(/^\s+-\s+(REQ-[A-Za-z0-9_-]+)\s*$/);
141
147
  if (linkMatch) {
142
- current.links.push(linkMatch[1]);
148
+ const linkId = linkMatch[1];
149
+ if (linkId !== undefined && current.links) {
150
+ current.links.push(linkId);
151
+ }
143
152
  continue;
144
153
  }
145
154
  }
@@ -148,13 +157,19 @@ function parseSymbolsYaml(content) {
148
157
  const relTypeMatch = raw.match(/^\s+-\s+type:\s*(.+)$/);
149
158
  if (relTypeMatch) {
150
159
  flushRel();
151
- pendingRel = { type: relTypeMatch[1].trim() };
160
+ const relationType = relTypeMatch[1];
161
+ if (relationType === undefined)
162
+ continue;
163
+ pendingRel = { type: relationType.trim() };
152
164
  continue;
153
165
  }
154
166
  // Relationship target: " target: REQ-..."
155
167
  const relTargetMatch = raw.match(/^\s+target:\s*(.+)$/);
156
168
  if (relTargetMatch && pendingRel) {
157
- pendingRel.target = relTargetMatch[1].trim();
169
+ const target = relTargetMatch[1];
170
+ if (target === undefined)
171
+ continue;
172
+ pendingRel.target = target.trim();
158
173
  continue;
159
174
  }
160
175
  }
@@ -0,0 +1,28 @@
1
+ export type ToastPayload = {
2
+ variant?: "info" | "success" | "warning" | "error";
3
+ title?: string;
4
+ message: string;
5
+ duration?: number;
6
+ };
7
+ export type StartupNotifierClient = {
8
+ tui?: {
9
+ showToast?: (payload: {
10
+ body: {
11
+ title?: string;
12
+ message: string;
13
+ variant?: "info" | "success" | "warning" | "error";
14
+ duration?: number;
15
+ };
16
+ }) => void | Promise<void>;
17
+ toast?: (payload: ToastPayload) => void | Promise<void>;
18
+ };
19
+ app: {
20
+ log: (payload: Record<string, unknown>) => Promise<void>;
21
+ };
22
+ };
23
+ export type StartupNotifierConfig = {
24
+ version?: string;
25
+ suppressToast?: boolean;
26
+ directory?: string;
27
+ };
28
+ export declare function notifyStartup(client: StartupNotifierClient, cfg: StartupNotifierConfig): void;
@@ -0,0 +1,62 @@
1
+ function hasShowToast(client) {
2
+ return typeof client.tui?.showToast === "function";
3
+ }
4
+ function hasLegacyToast(client) {
5
+ return typeof client.tui?.toast === "function";
6
+ }
7
+ // implements REQ-opencode-kibi-plugin-v1
8
+ export function notifyStartup(client, cfg) {
9
+ const message = "kibi-opencode started";
10
+ const toastPayload = {
11
+ variant: "success",
12
+ title: "Kibi OpenCode",
13
+ message,
14
+ duration: 4000,
15
+ };
16
+ if (!cfg.suppressToast) {
17
+ if (hasShowToast(client)) {
18
+ void Promise.resolve(client.tui.showToast({ body: toastPayload }))
19
+ .then((result) => void Promise.resolve(client.app.log({
20
+ body: {
21
+ service: "kibi-opencode",
22
+ level: "info",
23
+ message: "startup toast result",
24
+ result: String(result),
25
+ ...(cfg.directory ? { directory: cfg.directory } : {}),
26
+ },
27
+ })).catch((logErr) => {
28
+ console.error("[kibi-opencode] startup toast result log failed:", logErr);
29
+ }))
30
+ .catch((err) => {
31
+ console.error("[kibi-opencode] startup toast failed:", err);
32
+ void Promise.resolve(client.app.log({
33
+ body: {
34
+ service: "kibi-opencode",
35
+ level: "warn",
36
+ message: "startup toast failed",
37
+ error: String(err),
38
+ ...(cfg.directory ? { directory: cfg.directory } : {}),
39
+ },
40
+ })).catch((logErr) => {
41
+ console.error("[kibi-opencode] startup toast log failed:", logErr);
42
+ });
43
+ });
44
+ }
45
+ else if (hasLegacyToast(client)) {
46
+ void Promise.resolve(client.tui.toast(toastPayload)).catch((err) => {
47
+ console.error("[kibi-opencode] startup toast failed:", err);
48
+ });
49
+ }
50
+ }
51
+ void Promise.resolve(client.app.log({
52
+ body: {
53
+ service: "kibi-opencode",
54
+ level: "info",
55
+ message,
56
+ ...(cfg.version ? { version: cfg.version } : {}),
57
+ ...(cfg.directory ? { directory: cfg.directory } : {}),
58
+ },
59
+ })).catch((err) => {
60
+ console.error("[kibi-opencode] startup log failed:", err);
61
+ });
62
+ }
@@ -30,7 +30,7 @@ export function checkWorkspaceHealth(cwd) {
30
30
  if (missingConfig) {
31
31
  // No config file: fall back to hardcoded defaults
32
32
  for (const docDir of KIBI_DOC_DIRS) {
33
- const fullPath = path.join(cwd, docDir);
33
+ const fullPath = path.resolve(cwd, docDir);
34
34
  if (!fs.existsSync(fullPath)) {
35
35
  missingDocDirs.push(docDir);
36
36
  }
@@ -50,7 +50,7 @@ export function checkWorkspaceHealth(cwd) {
50
50
  // User has custom paths: resolve targets dynamically
51
51
  const targets = getKbExistenceTargets(cwd);
52
52
  for (const target of targets) {
53
- const fullPath = path.join(cwd, target.relativePath);
53
+ const fullPath = path.resolve(cwd, target.relativePath);
54
54
  if (!fs.existsSync(fullPath)) {
55
55
  missingDocDirs.push(target.relativePath);
56
56
  }
@@ -59,7 +59,7 @@ export function checkWorkspaceHealth(cwd) {
59
59
  else {
60
60
  // Config exists but no custom paths: use hardcoded defaults
61
61
  for (const docDir of KIBI_DOC_DIRS) {
62
- const fullPath = path.join(cwd, docDir);
62
+ const fullPath = path.resolve(cwd, docDir);
63
63
  if (!fs.existsSync(fullPath)) {
64
64
  missingDocDirs.push(docDir);
65
65
  }
@@ -69,12 +69,11 @@ export function checkWorkspaceHealth(cwd) {
69
69
  // Check for any evidence of Kibi usage
70
70
  const kbDir = path.join(cwd, ".kb");
71
71
  const hasKbEvidence = fs.existsSync(kbDir) && fs.readdirSync(kbDir).length > 0;
72
- // Delegate needsBootstrap entirely to posture detection:
73
- // - root_uninitialized true
74
- // - root_partial true
75
- // - vendored_only false (nested tree handles its own KB)
76
- // - root_active / hybrid_root_plus_vendored false
77
- const needsBootstrap = posture.needsBootstrap;
72
+ // Restore lenient threshold for repos that have a config but are missing a few dirs.
73
+ // Uninitialized repos always need bootstrap; partial repos fall back to the legacy
74
+ // >2 missing dirs threshold so small gaps (e.g. unused flags/events) do not nag.
75
+ const needsBootstrap = posture.state === "root_uninitialized" ||
76
+ (posture.state === "root_partial" && missingDocDirs.length > 2);
78
77
  return {
79
78
  needsBootstrap,
80
79
  missingConfig,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "kibi-opencode",
3
- "version": "0.6.0",
3
+ "version": "0.7.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",
@@ -32,9 +32,7 @@
32
32
  "default": "./dist/file-filter.js"
33
33
  }
34
34
  },
35
- "files": [
36
- "dist"
37
- ],
35
+ "files": ["dist"],
38
36
  "engines": {
39
37
  "node": ">=18"
40
38
  },