kibi-opencode 0.10.0 → 0.11.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/dist/index.js CHANGED
@@ -1,1068 +1 @@
1
- import * as path from "node:path";
2
- import { loadBriefConfig } from "kibi-cli/brief-config";
3
- import { computeBriefIntent } from "./brief-intent.js";
4
- import { fetchBriefingResult, } from "./briefing-runtime.js";
5
- import { analyzeCodeFile, } from "./comment-analysis.js";
6
- import { getE2eCoverageSignal } from "./e2e-coverage-signals.js"; // implements REQ-opencode-file-context-guidance-v1
7
- import { getFileLinkedEntityIds } from "./file-entity-links.js"; // implements REQ-opencode-file-context-guidance-v1
8
- import * as fileFilter from "./file-filter.js";
9
- import { deriveFileOperationReminder } from "./file-operation-reminders.js"; // implements REQ-opencode-file-context-guidance-v1
10
- import { createFileOperationState, } from "./file-operation-state.js"; // implements REQ-opencode-file-context-guidance-v1
11
- import { getInitKibiCommandCapability, registerInitKibiCommand, } from "./init-kibi-capability.js";
12
- import { computeAuditDelta, getAuditTailCursor, guardBranchChanged, } from "./idle-brief-audit.js";
13
- import { hasTuiSeenBrief, markBriefRead, markBriefTuiSeen, selectLatestUnreadBrief, } from "./idle-brief-reader.js";
14
- import { generateIdleBrief } from "./idle-brief-runtime.js";
15
- import * as logger from "./logger.js";
16
- import { analyzePath } from "./path-kind.js";
17
- import { runPluginStartup } from "./plugin-startup.js";
18
- import { resolveCurrentBranch } from "./plugin-startup.js";
19
- import { SENTINEL, buildPrompt } from "./prompt.js";
20
- import { reconcileAuditEntries } from "./reconcile-engine.js";
21
- import { isMustPriorityRequirement } from "./requirement-doc.js";
22
- import { classifyRisk } from "./risk-classifier.js";
23
- import { createSessionEditState, } from "./session-edit-state.js";
24
- import { syncSessionBaselineState, } from "./session-fingerprint.js";
25
- import { getSessionTracker } from "./session-tracker.js";
26
- import { notifyStartup, } from "./startup-notifier.js";
27
- import { sendToast, } from "./toast.js";
28
- import { deliverBriefTui, } from "./tui-brief-delivery.js";
29
- import * as fs from "node:fs";
30
- function deriveFileBucket(kind) {
31
- return kind;
32
- }
33
- function resolveIdleBriefDeliveryDelayMs(worktree) {
34
- const envValue = Number(process.env.KIBI_OPENCODE_IDLE_BRIEF_DELAY_MS);
35
- if (Number.isFinite(envValue) && envValue >= 0) {
36
- return Math.min(60_000, Math.trunc(envValue));
37
- }
38
- const sharedPolicy = loadBriefConfig(worktree);
39
- const configValue = Number(sharedPolicy.tui?.idleDelayMs ?? 1500);
40
- if (!Number.isFinite(configValue))
41
- return 1500;
42
- if (configValue < 0)
43
- return 0;
44
- return Math.min(60_000, Math.trunc(configValue));
45
- }
46
- const startupNotifyGlobals = globalThis;
47
- /**
48
- * Lint requirement documents for embedded scenarios/tests and oversized content.
49
- */
50
- // implements REQ-opencode-kibi-plugin-v1
51
- function lintRequirementDoc(filePath, worktree) {
52
- const warnings = [];
53
- try {
54
- const resolvedPath = worktree && !filePath.startsWith("/")
55
- ? `${worktree}/${filePath}`
56
- : filePath;
57
- const content = fs.readFileSync(resolvedPath, "utf-8");
58
- if (/given\s+[\s\S]*?when\s+[\s\S]*?then/i.test(content)) {
59
- warnings.push({
60
- category: "embedded-scenario-in-req",
61
- message: `Requirement file ${filePath} appears to contain embedded scenario (Given/When/Then). Consider extracting to a separate SCEN entity.`,
62
- });
63
- }
64
- if (/\b(assert|verify|expected\s+to|should\s+return)\b/i.test(content)) {
65
- warnings.push({
66
- category: "embedded-test-in-req",
67
- message: `Requirement file ${filePath} appears to contain embedded test assertions. Consider extracting to a separate TEST entity.`,
68
- });
69
- }
70
- const lines = content.split("\n");
71
- const contentLines = lines.filter((l) => l.trim() && !l.startsWith("---") && !l.startsWith("#"));
72
- if (contentLines.length > 50) {
73
- warnings.push({
74
- category: "missing-traceability",
75
- message: `Requirement file ${filePath} is very long (${contentLines.length} content lines). Consider splitting into multiple requirements or extracting scenarios/tests.`,
76
- });
77
- }
78
- }
79
- catch {
80
- // Ignore read errors
81
- }
82
- return warnings;
83
- }
84
- // implements REQ-opencode-kibi-plugin-v1
85
- const kibiOpencodePlugin = async (input) => {
86
- const makeToastClient = (client) => {
87
- const tui = client.tui;
88
- if (!tui)
89
- return {};
90
- const mappedTui = {};
91
- if (typeof tui.toast === "function") {
92
- mappedTui.toast = tui.toast.bind(tui);
93
- }
94
- if (typeof tui.showToast === "function") {
95
- mappedTui.showToast = tui.showToast.bind(tui);
96
- }
97
- if (typeof tui.clearPrompt === "function") {
98
- mappedTui.clearPrompt = tui.clearPrompt.bind(tui);
99
- }
100
- if (typeof tui.submitPrompt === "function") {
101
- mappedTui.submitPrompt = tui.submitPrompt.bind(tui);
102
- }
103
- return { tui: mappedTui };
104
- };
105
- const makeStartupClient = (client) => ({
106
- ...makeToastClient(client),
107
- app: client.app,
108
- });
109
- const startup = await runPluginStartup(input);
110
- if (!startup) {
111
- return {};
112
- }
113
- const { cfg, workspaceHealth, posture, currentBranch, cache, runtimeOverlay, scheduler, maintenanceDegraded, getMaintenanceDegraded, getEffectiveMode, latchRuntimeDegraded, } = startup;
114
- const hooks = {};
115
- const initKibiCommandCapability = getInitKibiCommandCapability();
116
- if (initKibiCommandCapability.supported) {
117
- hooks.config = async (configInput) => {
118
- registerInitKibiCommand(configInput, initKibiCommandCapability);
119
- };
120
- }
121
- // Plugin instance state (not module globals)
122
- const MAX_RECENT_EDITS = 5;
123
- let recentEdits = [];
124
- let hasRecentKbEdit = false;
125
- let recentCommentSuggestion = null;
126
- const seenFingerprints = new Set(); // For deduplication
127
- // NOTE: autoBriefResults is ONLY for prompt-time auto-brief guidance (file.edited flow).
128
- // Idle-brief runtime (session.idle flow) writes directly to .kb/briefs/ via generateIdleBrief()
129
- // and MUST NEVER store results in this map or leak into prompt guidance.
130
- const autoBriefResults = new Map();
131
- const toastedFingerprints = new Set();
132
- let lastRiskClass = null;
133
- let lastRiskFilePath = null;
134
- const sessionEditState = createSessionEditState({ worktree: input.worktree });
135
- const fileOperationState = createFileOperationState({
136
- worktree: input.worktree,
137
- }); // implements REQ-opencode-file-context-guidance-v1
138
- let degradedWarnedOnce = false;
139
- const pathKindCache = new Map();
140
- // Idle-brief state — dedupe via semantic contentHash (persisted envelope is the delivery authority)
141
- let idleBriefInFlight = false;
142
- let idleBriefTrailingRerun = false;
143
- let idleBriefTimer = null;
144
- const idleBriefDeliveredHashes = new Set();
145
- const replayedBriefContentHashes = new Set();
146
- // Session-local baseline cursor: captured once per session/worktree/branch from the audit-log tail,
147
- // so the first idle brief in a fresh session only reports post-baseline changes.
148
- let sessionBaselineCursor = null;
149
- let sessionBaselineFingerprint = null;
150
- function syncSessionBaseline(branch) {
151
- const nextState = syncSessionBaselineState({
152
- fingerprint: sessionBaselineFingerprint,
153
- cursor: sessionBaselineCursor,
154
- }, {
155
- sessionId: input.sessionId,
156
- branch,
157
- worktree: input.worktree,
158
- }, () => getAuditTailCursor(input.worktree, branch));
159
- sessionBaselineFingerprint = nextState.fingerprint;
160
- sessionBaselineCursor = nextState.cursor;
161
- }
162
- syncSessionBaseline(currentBranch);
163
- function normalizeSessionPath(filePath) {
164
- if (path.isAbsolute(filePath)) {
165
- const relativePath = path.relative(input.worktree, filePath);
166
- return relativePath.startsWith("..") ? filePath : relativePath;
167
- }
168
- return filePath;
169
- }
170
- function resolveWorktreePath(filePath) {
171
- return input.worktree && !path.isAbsolute(filePath)
172
- ? path.join(input.worktree, filePath)
173
- : filePath;
174
- }
175
- function getKbSnapshotFingerprint(worktree, branch) {
176
- try {
177
- const snapshotPath = path.join(worktree, ".kb", "branches", branch, "kb.rdf");
178
- const stat = fs.statSync(snapshotPath);
179
- return `${stat.size}:${stat.mtimeMs}`;
180
- }
181
- catch {
182
- return "missing";
183
- }
184
- }
185
- function buildSyntheticSyncAuditDelta(baseDelta, sourceFiles) {
186
- const timestamp = new Date().toISOString();
187
- const fileSource = sourceFiles[0] ?? "workspace-sync";
188
- const entityId = path.basename(fileSource).replace(/\.md$/, "") || "workspace-sync";
189
- return {
190
- ...baseDelta,
191
- hasChanges: true,
192
- entries: [
193
- {
194
- timestamp,
195
- operation: "upsert",
196
- entityId,
197
- payload: {
198
- kind: "entity",
199
- entityType: "fact",
200
- changeKind: "updated",
201
- title: entityId,
202
- source: fileSource,
203
- properties: {
204
- id: entityId,
205
- title: entityId,
206
- source: fileSource,
207
- },
208
- },
209
- },
210
- ],
211
- };
212
- }
213
- function getTransformFocusFilePath(transformInput) {
214
- if (!transformInput || typeof transformInput !== "object") {
215
- return null;
216
- }
217
- const inputRecord = transformInput;
218
- const directPath = inputRecord.focusFilePath ??
219
- inputRecord.filePath ??
220
- inputRecord.path ??
221
- inputRecord.file ??
222
- inputRecord.focusEdit?.path ??
223
- inputRecord.focusEdit?.filePath;
224
- if (typeof directPath !== "string" || directPath.length === 0) {
225
- return null;
226
- }
227
- return normalizeSessionPath(directPath);
228
- }
229
- function readFileContent(filePath) {
230
- try {
231
- return fs.readFileSync(resolveWorktreePath(filePath), "utf-8");
232
- }
233
- catch {
234
- return "";
235
- }
236
- }
237
- function updateRecentEditsFromSession(sessionEdits) {
238
- recentEdits = sessionEdits.slice(-MAX_RECENT_EDITS).map((entry) => ({
239
- path: entry.filePath,
240
- kind: pathKindCache.get(entry.filePath) ?? "unknown",
241
- timestamp: entry.lastReconciledAt,
242
- }));
243
- return recentEdits;
244
- }
245
- function deriveRiskContext(filePath) {
246
- const normalizedFilePath = normalizeSessionPath(filePath);
247
- const pathAnalysis = analyzePath(normalizedFilePath, input.worktree);
248
- pathKindCache.set(normalizedFilePath, pathAnalysis.kind);
249
- const fileContent = readFileContent(normalizedFilePath);
250
- const hasMustPriority = pathAnalysis.kind === "requirement"
251
- ? isMustPriorityRequirement(normalizedFilePath, input.worktree)
252
- : false;
253
- let precomputedSuggestion = null;
254
- if (pathAnalysis.kind === "code" && cfg.guidance.commentDetection.enabled) {
255
- precomputedSuggestion = analyzeCodeFile(resolveWorktreePath(normalizedFilePath), {
256
- minLines: cfg.guidance.commentDetection.minLines,
257
- });
258
- }
259
- const { riskClass } = classifyRisk({
260
- pathKind: pathAnalysis.kind,
261
- isUnderKb: pathAnalysis.isUnderKb,
262
- hasMustPriority,
263
- hasDurableComment: !!precomputedSuggestion,
264
- fileContent,
265
- });
266
- const effectiveRiskClass = riskClass === "safe_docs_only" && precomputedSuggestion
267
- ? "traceability_candidate"
268
- : riskClass;
269
- recentCommentSuggestion =
270
- pathAnalysis.kind === "code" ? precomputedSuggestion : null;
271
- lastRiskClass = effectiveRiskClass;
272
- lastRiskFilePath = normalizedFilePath;
273
- return {
274
- effectiveRiskClass,
275
- pathAnalysis,
276
- hasMustPriority,
277
- precomputedSuggestion,
278
- };
279
- }
280
- function buildBriefingWorkspaceContext() {
281
- return {
282
- workspaceRoot: input.worktree,
283
- branch: currentBranch,
284
- directory: input.directory,
285
- ...(input.workspace !== undefined ? { workspace: input.workspace } : {}),
286
- };
287
- }
288
- function buildWorkspaceContextForBranch(branch) {
289
- return {
290
- ...buildBriefingWorkspaceContext(),
291
- branch,
292
- };
293
- }
294
- function queueBriefingFetch(intentResult, options = {}) {
295
- if (!intentResult.eligible ||
296
- !input.client ||
297
- getMaintenanceDegraded() ||
298
- (posture.state !== "root_active" &&
299
- posture.state !== "hybrid_root_plus_vendored")) {
300
- return;
301
- }
302
- if (options.skipIfCachedResultExists === true &&
303
- autoBriefResults.has(intentResult.fingerprint)) {
304
- return;
305
- }
306
- const client = input.client;
307
- const fingerprint = intentResult.fingerprint;
308
- const workspaceCtx = buildBriefingWorkspaceContext();
309
- void fetchBriefingResult(client, workspaceCtx, intentResult).then((result) => {
310
- autoBriefResults.set(fingerprint, result);
311
- if (!toastedFingerprints.has(fingerprint)) {
312
- toastedFingerprints.add(fingerprint);
313
- void sendToast(makeToastClient(client), {
314
- message: result.toastMessage,
315
- });
316
- }
317
- });
318
- }
319
- hooks.event = async ({ event }) => {
320
- const activeBranch = resolveCurrentBranch(input.worktree);
321
- syncSessionBaseline(activeBranch);
322
- // Handle session.idle for idle-brief generation. OpenCode can emit idle
323
- // while an assistant is between tool calls, so debounce until the work
324
- // burst settles before generating/delivering a brief.
325
- if (event.type === "session.idle") {
326
- if (!input.client)
327
- return;
328
- const idleBranch = activeBranch;
329
- const idleWorkspaceRoot = input.worktree;
330
- const runIdleBrief = async () => {
331
- if (idleBriefInFlight) {
332
- idleBriefTrailingRerun = true;
333
- return;
334
- }
335
- idleBriefInFlight = true;
336
- idleBriefTrailingRerun = false;
337
- try {
338
- // Gather session edits
339
- const sessionEdits = sessionEditState.getSessionEdits();
340
- const sourceFiles = sessionEdits.map((e) => e.filePath);
341
- const snapshotBeforeSync = getKbSnapshotFingerprint(idleWorkspaceRoot, idleBranch);
342
- if (scheduler) {
343
- scheduler.scheduleSync("session.idle");
344
- await scheduler.flush();
345
- }
346
- const snapshotAfterSync = getKbSnapshotFingerprint(idleWorkspaceRoot, idleBranch);
347
- const rawAuditDelta = computeAuditDelta(idleWorkspaceRoot, idleBranch, sessionBaselineCursor);
348
- const auditDelta = rawAuditDelta.hasChanges || snapshotBeforeSync === snapshotAfterSync
349
- ? rawAuditDelta
350
- : buildSyntheticSyncAuditDelta(rawAuditDelta, sourceFiles);
351
- if (!auditDelta.hasChanges)
352
- return;
353
- // Branch switch guard
354
- const currentBranchNow = resolveCurrentBranch(input.worktree);
355
- if (guardBranchChanged(idleBranch, currentBranchNow)) {
356
- logger.info("idle-brief.branch-changed", {
357
- event: "idle_brief_branch_changed",
358
- idleBranch,
359
- currentBranch: currentBranchNow,
360
- });
361
- return;
362
- }
363
- // Generate brief
364
- const workspaceCtx = buildWorkspaceContextForBranch(idleBranch);
365
- const client = input.client;
366
- if (!client)
367
- return;
368
- const reconciled = reconcileAuditEntries(auditDelta.entries);
369
- const changedEntityIds = [
370
- ...reconciled.added.map((e) => e.id),
371
- ...reconciled.modified.map((e) => e.id),
372
- ...reconciled.removed.map((e) => e.id),
373
- ];
374
- const result = await generateIdleBrief(input.client, workspaceCtx, auditDelta, input.sessionId ?? "unknown", sourceFiles.length > 0
375
- ? { sourceFiles, changedEntityIds }
376
- : { changedEntityIds });
377
- if (result.success && result.envelope) {
378
- const envelope = result.envelope;
379
- // Dedupe by semantic contentHash — persisted envelope is the delivery authority
380
- const dedupeKey = `${idleWorkspaceRoot}:${idleBranch}:tui:${envelope.contentHash}`;
381
- if (!idleBriefDeliveredHashes.has(dedupeKey)) {
382
- idleBriefDeliveredHashes.add(dedupeKey);
383
- const sharedPolicy = { briefs: loadBriefConfig(input.worktree) };
384
- const localConfig = {
385
- autoSubmit: cfg.ux?.briefs?.autoSubmit ?? true,
386
- };
387
- if (client) {
388
- try {
389
- const deliveryResult = await deliverBriefTui(makeToastClient(client), envelope, sharedPolicy, localConfig);
390
- const shouldMarkReadAfterTuiDelivery = !sharedPolicy.briefs.channels.vscode;
391
- if (deliveryResult.delivered &&
392
- result.briefPath) {
393
- if (shouldMarkReadAfterTuiDelivery) {
394
- markBriefRead(idleWorkspaceRoot, result.briefPath);
395
- }
396
- markBriefTuiSeen(idleWorkspaceRoot, idleBranch, envelope.contentHash);
397
- replayedBriefContentHashes.add(envelope.contentHash);
398
- }
399
- }
400
- catch (err) {
401
- logger.error("idle-brief.delivery-failed", {
402
- event: "idle_brief_delivery_failed",
403
- error: err instanceof Error ? err.message : String(err),
404
- });
405
- }
406
- }
407
- }
408
- }
409
- else {
410
- logger.info("idle-brief.no-brief-generated", {
411
- event: "idle_brief_no_brief_generated",
412
- success: result.success,
413
- hasEnvelope: !!result.envelope,
414
- });
415
- }
416
- }
417
- catch (error) {
418
- logger.error("idle-brief.error", {
419
- event: "idle_brief_error",
420
- error: error instanceof Error ? error.message : String(error),
421
- });
422
- }
423
- finally {
424
- idleBriefInFlight = false;
425
- // If trailing rerun was requested, run again
426
- if (idleBriefTrailingRerun) {
427
- idleBriefTrailingRerun = false;
428
- void runIdleBrief();
429
- }
430
- }
431
- };
432
- if (idleBriefTimer) {
433
- clearTimeout(idleBriefTimer);
434
- }
435
- idleBriefTimer = setTimeout(() => {
436
- idleBriefTimer = null;
437
- void runIdleBrief();
438
- }, resolveIdleBriefDeliveryDelayMs(idleWorkspaceRoot));
439
- return;
440
- }
441
- // Accept file.created, file.edited, and file.deleted lifecycle events
442
- const isFileLifecycle = event.type === "file.created" ||
443
- event.type === "file.edited" ||
444
- event.type === "file.deleted";
445
- if (!isFileLifecycle)
446
- return;
447
- if (idleBriefTimer) {
448
- clearTimeout(idleBriefTimer);
449
- idleBriefTimer = null;
450
- }
451
- const filePath = event
452
- .properties.file;
453
- if (!filePath)
454
- return;
455
- // Record lifecycle event into file-operation-state // implements REQ-opencode-file-context-guidance-v1
456
- const lifecycle = event.type === "file.created"
457
- ? "created"
458
- : event.type === "file.deleted"
459
- ? "deleted"
460
- : "edited";
461
- fileOperationState.recordLifecycle(filePath, lifecycle, Date.now());
462
- fileOperationState.normalizePath(filePath);
463
- const pathAnalysis = analyzePath(filePath, input.worktree);
464
- // For file.deleted: derive path kind without reading content, classify for reminder routing only
465
- if (lifecycle === "deleted") {
466
- // Preserve last known semantic risk if path was already tracked during session
467
- const lastKnownKind = pathKindCache.get(filePath);
468
- if (lastKnownKind) {
469
- // Path was tracked — preserve last known semantic risk for reminder routing
470
- pathKindCache.set(filePath, pathAnalysis.kind);
471
- }
472
- else {
473
- // Not tracked — classify only for reminder routing, not auto-briefing
474
- pathKindCache.set(filePath, pathAnalysis.kind);
475
- }
476
- sessionEditState.recordEventHint(filePath, pathAnalysis.kind, Date.now());
477
- sessionEditState.reconcilePath(filePath);
478
- const sessionEdits = sessionEditState.getSessionEdits();
479
- recentEdits = sessionEdits.slice(-MAX_RECENT_EDITS).map((e) => ({
480
- path: e.filePath,
481
- kind: pathKindCache.get(e.filePath) ?? "unknown",
482
- timestamp: e.lastReconciledAt,
483
- }));
484
- // Schedule background sync for deleted files that pass shouldHandleFile // implements REQ-opencode-file-context-guidance-v1
485
- if (cfg.sync.enabled &&
486
- scheduler &&
487
- fileFilter.shouldHandleFile(filePath, input.worktree)) {
488
- scheduler.scheduleSync("file.deleted", filePath);
489
- }
490
- return;
491
- }
492
- sessionEditState.recordEventHint(filePath, pathAnalysis.kind, Date.now());
493
- sessionEditState.reconcilePath(filePath);
494
- pathKindCache.set(filePath, pathAnalysis.kind);
495
- const sessionEdits = sessionEditState.getSessionEdits();
496
- const focusEdit = sessionEditState.getFocusEdit();
497
- // Schedule background sync for file.created/file.edited that pass shouldHandleFile // implements REQ-opencode-file-context-guidance-v1
498
- if (cfg.sync.enabled &&
499
- scheduler &&
500
- fileFilter.shouldHandleFile(filePath, input.worktree)) {
501
- scheduler.scheduleSync(lifecycle === "created" ? "file.created" : "file.edited", filePath);
502
- }
503
- let fileContent = "";
504
- try {
505
- const resolvedPath = input.worktree && !path.isAbsolute(filePath)
506
- ? path.join(input.worktree, filePath)
507
- : filePath;
508
- fileContent = fs.readFileSync(resolvedPath, "utf-8");
509
- }
510
- catch { }
511
- const hasMustPriority = pathAnalysis.kind === "requirement"
512
- ? isMustPriorityRequirement(filePath, input.worktree)
513
- : false;
514
- let precomputedSuggestion = null;
515
- if (pathAnalysis.kind === "code" && cfg.guidance.commentDetection.enabled) {
516
- const resolvedPath = input.worktree && !path.isAbsolute(filePath)
517
- ? path.join(input.worktree, filePath)
518
- : filePath;
519
- precomputedSuggestion = analyzeCodeFile(resolvedPath, {
520
- minLines: cfg.guidance.commentDetection.minLines,
521
- });
522
- }
523
- const { riskClass } = classifyRisk({
524
- pathKind: pathAnalysis.kind,
525
- isUnderKb: pathAnalysis.isUnderKb,
526
- hasMustPriority,
527
- hasDurableComment: !!precomputedSuggestion,
528
- fileContent,
529
- });
530
- const effectiveRiskClass = riskClass === "safe_docs_only" && precomputedSuggestion
531
- ? "traceability_candidate"
532
- : riskClass;
533
- const isAutoBriefRisk = effectiveRiskClass === "behavior_candidate" ||
534
- effectiveRiskClass === "traceability_candidate";
535
- lastRiskClass = effectiveRiskClass;
536
- logger.info("smart-enforcement.risk", {
537
- event: "smart_enforcement_risk",
538
- file: filePath,
539
- path_kind: pathAnalysis.kind,
540
- risk_class: effectiveRiskClass,
541
- posture_state: posture.state,
542
- maintenance_state: getMaintenanceDegraded()
543
- ? "maintenance_degraded"
544
- : "maintenance_available",
545
- under_kb: pathAnalysis.isUnderKb,
546
- has_must_priority: hasMustPriority,
547
- posture: posture.state,
548
- reason_code: effectiveRiskClass,
549
- effective_mode: getEffectiveMode(),
550
- static_degraded: posture.maintenanceDegraded,
551
- runtime_degraded: runtimeOverlay.degraded,
552
- merged_degraded: getMaintenanceDegraded(),
553
- overlay_cause: runtimeOverlay.primaryCause ?? null,
554
- });
555
- const targetedChecksBlocked = getMaintenanceDegraded() ||
556
- runtimeOverlay.primaryCause === "sync_disabled" ||
557
- runtimeOverlay.primaryCause === "scheduler_unavailable" ||
558
- runtimeOverlay.primaryCause === "scheduler_sync_failed" ||
559
- runtimeOverlay.primaryCause === "scheduler_check_failed";
560
- if (!targetedChecksBlocked &&
561
- cfg.sync.enabled &&
562
- scheduler &&
563
- cfg.guidance.targetedChecks.enabled) {
564
- const traceabilityRules = effectiveRiskClass === "traceability_candidate"
565
- ? ["symbol-traceability"]
566
- : null;
567
- const kbStructuralRules = effectiveRiskClass === "kb_doc_structural" &&
568
- fileFilter.shouldHandleFile(filePath, input.worktree)
569
- ? [
570
- "required-fields",
571
- "no-dangling-refs",
572
- ...(pathAnalysis.kind === "fact" ? ["strict-fact-shape"] : []),
573
- ...(pathAnalysis.kind === "requirement"
574
- ? ["strict-req-fact-pairing"]
575
- : []),
576
- ]
577
- : null;
578
- const checkRules = traceabilityRules ?? kbStructuralRules;
579
- if (checkRules) {
580
- logger.info("smart-enforcement.targeted-checks", {
581
- event: "smart_enforcement_targeted_checks",
582
- file: filePath,
583
- risk_class: effectiveRiskClass,
584
- posture: posture.state,
585
- posture_state: posture.state,
586
- guidance_action: "targeted_checks",
587
- effective_mode: getEffectiveMode(),
588
- rules: checkRules,
589
- static_degraded: posture.maintenanceDegraded,
590
- runtime_degraded: runtimeOverlay.degraded,
591
- merged_degraded: getMaintenanceDegraded(),
592
- overlay_cause: runtimeOverlay.primaryCause ?? null,
593
- });
594
- logger.info(`kibi-opencode: scheduling sync for ${filePath}`);
595
- scheduler.scheduleSync(effectiveRiskClass === "traceability_candidate"
596
- ? "smart-enforcement.traceability"
597
- : "smart-enforcement.kb-doc", filePath, checkRules);
598
- }
599
- }
600
- recentEdits = sessionEdits.slice(-MAX_RECENT_EDITS).map((e) => ({
601
- path: e.filePath,
602
- kind: pathKindCache.get(e.filePath) ?? "unknown",
603
- timestamp: e.lastReconciledAt,
604
- }));
605
- if (effectiveRiskClass === "safe_docs_only" ||
606
- effectiveRiskClass === "safe_test_only") {
607
- recentCommentSuggestion = null;
608
- return;
609
- }
610
- const cacheKey = {
611
- workspaceRoot: input.worktree,
612
- branch: currentBranch,
613
- posture: posture.state,
614
- riskClass: effectiveRiskClass,
615
- fileBucket: deriveFileBucket(pathAnalysis.kind),
616
- };
617
- // Always process manual_kb_edit before cache check — this is a critical safety signal
618
- if (effectiveRiskClass === "manual_kb_edit") {
619
- hasRecentKbEdit = true;
620
- if (cfg.guidance.warnOnKbEdits) {
621
- logger.warn(`kibi-opencode: .kb edit detected for ${filePath}`);
622
- getSessionTracker().recordWarning("kb-edit", filePath, `Manual .kb edit: ${filePath}`);
623
- }
624
- return;
625
- }
626
- // Always emit requirement lint warnings before cache check — these are safety signals
627
- if (effectiveRiskClass === "req_policy_candidate") {
628
- const lintWarnings = lintRequirementDoc(filePath, input.worktree);
629
- for (const warning of lintWarnings) {
630
- getSessionTracker().recordWarning(warning.category, filePath, warning.message);
631
- }
632
- }
633
- // Cache check: after critical signals have been emitted
634
- if (cache.isSatisfied(cacheKey)) {
635
- logger.info("smart-enforcement.cache", {
636
- event: "smart_enforcement_cache",
637
- cache_hit: true,
638
- cache_state: "hit",
639
- file: filePath,
640
- risk_class: effectiveRiskClass,
641
- posture: posture.state,
642
- posture_state: posture.state,
643
- });
644
- if (!isAutoBriefRisk) {
645
- return;
646
- }
647
- }
648
- logger.info("smart-enforcement.cache", {
649
- event: "smart_enforcement_cache",
650
- cache_hit: false,
651
- cache_state: "miss",
652
- file: filePath,
653
- risk_class: effectiveRiskClass,
654
- posture: posture.state,
655
- posture_state: posture.state,
656
- });
657
- if (effectiveRiskClass === "req_policy_candidate") {
658
- if (getMaintenanceDegraded()) {
659
- const logFn = cfg.guidance.smartEnforcement.degradedMode === "warn-once"
660
- ? logger.warn
661
- : logger.info;
662
- logFn("smart-enforcement.degraded", {
663
- event: "smart_enforcement_degraded",
664
- file: filePath,
665
- risk_class: effectiveRiskClass,
666
- posture: posture.state,
667
- posture_state: posture.state,
668
- maintenance_state: getMaintenanceDegraded()
669
- ? "maintenance_degraded"
670
- : "maintenance_available",
671
- reason: runtimeOverlay.primaryCause ?? "non_authoritative_posture",
672
- reason_code: runtimeOverlay.primaryCause ?? "non_authoritative_posture",
673
- static_degraded: posture.maintenanceDegraded,
674
- runtime_degraded: runtimeOverlay.degraded,
675
- merged_degraded: getMaintenanceDegraded(),
676
- overlay_cause: runtimeOverlay.primaryCause ?? null,
677
- effective_mode: getEffectiveMode(),
678
- });
679
- }
680
- if (!getMaintenanceDegraded() &&
681
- cfg.sync.enabled &&
682
- scheduler &&
683
- fileFilter.shouldHandleFile(filePath, input.worktree)) {
684
- let checkRules;
685
- if (cfg.guidance.targetedChecks.enabled) {
686
- if (hasMustPriority && getEffectiveMode() === "strict") {
687
- checkRules = [
688
- "required-fields",
689
- "no-dangling-refs",
690
- "must-priority-coverage",
691
- "strict-req-fact-pairing",
692
- ];
693
- logger.info(`kibi-opencode: must-priority requirement detected, scheduling elevated checks for ${filePath}`);
694
- }
695
- else {
696
- checkRules = [
697
- "required-fields",
698
- "no-dangling-refs",
699
- "strict-req-fact-pairing",
700
- ];
701
- }
702
- }
703
- logger.info("smart-enforcement.targeted-checks", {
704
- event: "smart_enforcement_targeted_checks",
705
- file: filePath,
706
- risk_class: effectiveRiskClass,
707
- posture: posture.state,
708
- posture_state: posture.state,
709
- guidance_action: "targeted_checks",
710
- effective_mode: getEffectiveMode(),
711
- rules: checkRules ?? [],
712
- static_degraded: posture.maintenanceDegraded,
713
- runtime_degraded: runtimeOverlay.degraded,
714
- merged_degraded: getMaintenanceDegraded(),
715
- overlay_cause: runtimeOverlay.primaryCause ?? null,
716
- });
717
- scheduler?.scheduleSync("file.edited", filePath, checkRules);
718
- }
719
- return;
720
- }
721
- if (effectiveRiskClass === "kb_doc_structural") {
722
- if (getMaintenanceDegraded()) {
723
- const logFn = cfg.guidance.smartEnforcement.degradedMode === "warn-once"
724
- ? logger.warn
725
- : logger.info;
726
- logFn("smart-enforcement.degraded", {
727
- event: "smart_enforcement_degraded",
728
- file: filePath,
729
- risk_class: effectiveRiskClass,
730
- posture: posture.state,
731
- posture_state: posture.state,
732
- maintenance_state: getMaintenanceDegraded()
733
- ? "maintenance_degraded"
734
- : "maintenance_available",
735
- reason: runtimeOverlay.primaryCause ?? "non_authoritative_posture",
736
- reason_code: runtimeOverlay.primaryCause ?? "non_authoritative_posture",
737
- static_degraded: posture.maintenanceDegraded,
738
- runtime_degraded: runtimeOverlay.degraded,
739
- merged_degraded: getMaintenanceDegraded(),
740
- overlay_cause: runtimeOverlay.primaryCause ?? null,
741
- effective_mode: getEffectiveMode(),
742
- });
743
- }
744
- return;
745
- }
746
- if (isAutoBriefRisk) {
747
- if (pathAnalysis.kind === "code" &&
748
- cfg.guidance.commentDetection.enabled) {
749
- const suggestion = precomputedSuggestion;
750
- if (suggestion) {
751
- recentCommentSuggestion = suggestion;
752
- const dedupeKey = `${filePath}:${suggestion.suggestionType}:${suggestion.fingerprint}`;
753
- if (!seenFingerprints.has(dedupeKey)) {
754
- seenFingerprints.add(dedupeKey);
755
- const warningCategory = suggestion.suggestionType === "fact"
756
- ? "long-comment-missed-fact"
757
- : suggestion.suggestionType === "adr"
758
- ? "long-comment-missed-adr"
759
- : "missing-traceability";
760
- logger.warn(`kibi-opencode: detected durable ${suggestion.suggestionType} knowledge in ${filePath}`);
761
- getSessionTracker().recordWarning(warningCategory, filePath, `Consider routing this ${suggestion.suggestionType} knowledge to Kibi instead of inline comments: ${suggestion.reasoning}`);
762
- }
763
- }
764
- else {
765
- recentCommentSuggestion = null;
766
- }
767
- }
768
- else {
769
- recentCommentSuggestion = null;
770
- }
771
- if (!focusEdit) {
772
- // No surviving edits (all reverted to baseline) — skip auto-brief fetch
773
- return;
774
- }
775
- const sessionSourceFiles = sessionEdits.map((e) => e.filePath);
776
- const intentResult = computeBriefIntent({
777
- riskClass: effectiveRiskClass,
778
- posture: posture.state,
779
- maintenanceDegraded: getMaintenanceDegraded(),
780
- sourceFiles: sessionSourceFiles,
781
- focusFilePath: focusEdit.filePath,
782
- worktreeRoot: input.worktree,
783
- branch: currentBranch,
784
- });
785
- queueBriefingFetch(intentResult);
786
- }
787
- return;
788
- };
789
- if (cfg.prompt.enabled) {
790
- const hookMode = cfg.prompt.hookMode;
791
- if (hookMode === "system-transform" || hookMode === "auto") {
792
- hooks["experimental.chat.system.transform"] = async (transformInput, output) => {
793
- // Skip if sentinel already present in any existing entry
794
- if (output.system.some((entry) => entry.includes(SENTINEL))) {
795
- return;
796
- }
797
- const maintenanceDegraded = getMaintenanceDegraded();
798
- const showDegradedAdvisory = maintenanceDegraded &&
799
- cfg.guidance.smartEnforcement.degradedMode === "warn-once" &&
800
- !degradedWarnedOnce;
801
- const transformFocusFilePath = getTransformFocusFilePath(transformInput);
802
- sessionEditState.reconcileKnownPaths();
803
- if (transformFocusFilePath) {
804
- sessionEditState.forceEdit(transformFocusFilePath);
805
- }
806
- const transformSessionEdits = sessionEditState.getSessionEdits();
807
- const transformFocusEdit = sessionEditState.getFocusEdit();
808
- const transformRecentEdits = transformSessionEdits
809
- .slice(-MAX_RECENT_EDITS)
810
- .map((e) => ({
811
- path: e.filePath,
812
- kind: pathKindCache.get(e.filePath) ?? "unknown",
813
- }));
814
- const transformPromptFocusEdit = transformFocusEdit
815
- ? {
816
- path: transformFocusEdit.filePath,
817
- kind: pathKindCache.get(transformFocusEdit.filePath) ?? "unknown",
818
- }
819
- : null;
820
- const riskContextFilePath = transformFocusEdit?.filePath ?? transformFocusFilePath;
821
- let effectiveRiskClass = riskContextFilePath && lastRiskFilePath === riskContextFilePath
822
- ? lastRiskClass
823
- : null;
824
- if (riskContextFilePath &&
825
- (lastRiskClass === null || lastRiskFilePath !== riskContextFilePath)) {
826
- const riskCtx = deriveRiskContext(riskContextFilePath);
827
- effectiveRiskClass = riskCtx.effectiveRiskClass;
828
- if (!recentCommentSuggestion && riskCtx.precomputedSuggestion) {
829
- recentCommentSuggestion = riskCtx.precomputedSuggestion;
830
- }
831
- }
832
- if (effectiveRiskClass === null && lastRiskClass !== null) {
833
- effectiveRiskClass = lastRiskClass;
834
- }
835
- const promptSourceFiles = transformSessionEdits.map((entry) => entry.filePath);
836
- const promptFocusFilePath = transformFocusEdit?.filePath ?? transformFocusFilePath ?? undefined;
837
- const intentResult = effectiveRiskClass
838
- ? computeBriefIntent({
839
- riskClass: effectiveRiskClass,
840
- posture: posture.state,
841
- maintenanceDegraded,
842
- sourceFiles: promptSourceFiles,
843
- worktreeRoot: input.worktree,
844
- branch: currentBranch,
845
- ...(promptFocusFilePath !== undefined
846
- ? {
847
- focusFilePath: promptFocusFilePath,
848
- }
849
- : {}),
850
- })
851
- : null;
852
- const autoBriefResult = intentResult
853
- ? autoBriefResults.get(intentResult.fingerprint)
854
- : undefined;
855
- const isAutoBriefRisk = effectiveRiskClass === "behavior_candidate" ||
856
- effectiveRiskClass === "traceability_candidate";
857
- if (!autoBriefResult && isAutoBriefRisk && intentResult) {
858
- queueBriefingFetch(intentResult, { skipIfCachedResultExists: true });
859
- }
860
- // Replay latest unread idle brief if available // implements REQ-opencode-kibi-briefing-v4
861
- if (input.worktree && currentBranch && input.client) {
862
- const unreadBrief = selectLatestUnreadBrief(input.worktree, currentBranch);
863
- if (unreadBrief &&
864
- !replayedBriefContentHashes.has(unreadBrief.envelope.contentHash) &&
865
- !hasTuiSeenBrief(input.worktree, currentBranch, unreadBrief.envelope.contentHash)) {
866
- const sharedPolicy = { briefs: loadBriefConfig(input.worktree) };
867
- const localConfig = {
868
- autoSubmit: cfg.ux?.briefs?.autoSubmit ?? true,
869
- };
870
- const client = input.client;
871
- try {
872
- const deliveryResult = await deliverBriefTui(makeToastClient(client), unreadBrief.envelope, sharedPolicy, localConfig);
873
- const shouldMarkReadAfterTuiDelivery = !sharedPolicy.briefs.channels.vscode;
874
- if (deliveryResult.delivered) {
875
- if (shouldMarkReadAfterTuiDelivery) {
876
- markBriefRead(input.worktree, unreadBrief.filePath);
877
- }
878
- markBriefTuiSeen(input.worktree, currentBranch, unreadBrief.envelope.contentHash);
879
- replayedBriefContentHashes.add(unreadBrief.envelope.contentHash);
880
- }
881
- }
882
- catch (err) {
883
- logger.error("idle-brief.replay-failed", {
884
- event: "idle_brief_replay_failed",
885
- error: err instanceof Error ? err.message : String(err),
886
- });
887
- }
888
- }
889
- }
890
- // Steps 3-4: File-operation reminder selection with suppression // implements REQ-opencode-file-context-guidance-v1
891
- let fileOperationReminder;
892
- const focusPathForReminder = transformFocusFilePath ?? promptFocusFilePath;
893
- if (focusPathForReminder) {
894
- const normalizedFocusPath = fileOperationState.normalizePath(focusPathForReminder);
895
- const pendingLifecycle = fileOperationState.peekPending(normalizedFocusPath);
896
- if (pendingLifecycle) {
897
- // Check if any reminder kind for this lifecycle has not yet been shown
898
- const reminderKindsForLifecycle = pendingLifecycle.lifecycle === "deleted"
899
- ? ["kibi_delete", "e2e_delete"]
900
- : pendingLifecycle.lifecycle === "created"
901
- ? ["kibi_write", "e2e_write"]
902
- : ["e2e_write"];
903
- const hasUnshownReminder = reminderKindsForLifecycle.some((kind) => !fileOperationState.hasShown(normalizedFocusPath, kind));
904
- if (hasUnshownReminder) {
905
- // Resolve linked entities and e2e signal
906
- const linkedEntityResult = getFileLinkedEntityIds(input.worktree, focusPathForReminder);
907
- const e2eSignal = getE2eCoverageSignal(input.worktree, focusPathForReminder);
908
- const focusPathKind = pathKindCache.get(normalizedFocusPath) ?? "unknown";
909
- const reminderResult = deriveFileOperationReminder({
910
- normalizedPath: normalizedFocusPath,
911
- lifecycle: pendingLifecycle.lifecycle,
912
- pathKind: focusPathKind,
913
- linkedEntityResult,
914
- e2eSignal,
915
- currentSemanticRisk: effectiveRiskClass ?? "safe_docs_only",
916
- posture: posture.state,
917
- });
918
- fileOperationReminder = {
919
- path: normalizedFocusPath,
920
- lifecycleReminder: reminderResult.lifecycleReminder,
921
- e2eReminder: reminderResult.e2eReminder,
922
- };
923
- }
924
- }
925
- }
926
- const guidance = buildPrompt({
927
- recentEdits: transformRecentEdits,
928
- focusEdit: transformPromptFocusEdit,
929
- workspaceHealth,
930
- hasRecentKbEdit,
931
- recentCommentSuggestion,
932
- posture: posture.state,
933
- cache,
934
- workspaceRoot: input.worktree,
935
- branch: currentBranch,
936
- completionReminder: cfg.guidance.smartEnforcement.completionReminder,
937
- maintenanceDegraded,
938
- degradedMode: cfg.guidance.smartEnforcement.degradedMode,
939
- showDegradedAdvisory,
940
- ...(autoBriefResult !== undefined ? { autoBriefResult } : {}),
941
- ...(effectiveRiskClass != null
942
- ? { riskClass: effectiveRiskClass }
943
- : {}),
944
- ...(fileOperationReminder !== undefined
945
- ? { fileOperationReminder }
946
- : {}),
947
- });
948
- logger.info("smart-enforcement.guidance", {
949
- event: "smart_enforcement_guidance",
950
- emitted: guidance.trim() !== "" && guidance.trim() !== SENTINEL,
951
- posture: posture.state,
952
- posture_state: posture.state,
953
- guidance_action: guidance.trim() !== "" && guidance.trim() !== SENTINEL
954
- ? "emit"
955
- : "skip",
956
- risk_class: lastRiskClass,
957
- recent_edits: recentEdits.length,
958
- static_degraded: posture.maintenanceDegraded,
959
- runtime_degraded: runtimeOverlay.degraded,
960
- merged_degraded: maintenanceDegraded,
961
- overlay_cause: runtimeOverlay.primaryCause ?? null,
962
- });
963
- // Emit completion-reminder log only when prompt-visible reminder text is present
964
- const REMINDER_TEXT = "Run `kb_check` before completing this task.";
965
- if (cfg.guidance.smartEnforcement.completionReminder &&
966
- !maintenanceDegraded &&
967
- guidance.includes(REMINDER_TEXT)) {
968
- logger.info("smart-enforcement.completion-reminder", {
969
- event: "smart_enforcement_completion_reminder",
970
- risk_class: lastRiskClass,
971
- posture: posture.state,
972
- posture_state: posture.state,
973
- guidance_action: "completion_reminder",
974
- reminder: "kb_check",
975
- static_degraded: posture.maintenanceDegraded,
976
- runtime_degraded: runtimeOverlay.degraded,
977
- merged_degraded: maintenanceDegraded,
978
- overlay_cause: runtimeOverlay.primaryCause ?? null,
979
- });
980
- }
981
- // Step 6: After prompt generation, mark reminders as shown if guidance contains the text // implements REQ-opencode-file-context-guidance-v1
982
- if (fileOperationReminder) {
983
- const lifecycleReminderText = fileOperationReminder.lifecycleReminder;
984
- const e2eReminderText = fileOperationReminder.e2eReminder;
985
- const focusPathForConsume = fileOperationReminder.path;
986
- // Determine which reminders were actually emitted in guidance
987
- const lifecycleEmitted = lifecycleReminderText !== null &&
988
- guidance.includes(lifecycleReminderText);
989
- const e2eEmitted = e2eReminderText !== null && guidance.includes(e2eReminderText);
990
- // Mark shown and log only for reminders that were actually emitted
991
- if (lifecycleEmitted) {
992
- const kind = fileOperationState.peekPending(focusPathForConsume)?.lifecycle ===
993
- "deleted"
994
- ? "kibi_delete"
995
- : "kibi_write";
996
- fileOperationState.markShown(focusPathForConsume, kind);
997
- logger.info("smart-enforcement.file-operation-reminder", {
998
- event: "smart_enforcement_file_operation_reminder",
999
- file: focusPathForConsume,
1000
- lifecycle: fileOperationState.peekPending(focusPathForConsume)
1001
- ?.lifecycle ?? null,
1002
- posture_state: posture.state,
1003
- risk_class: effectiveRiskClass,
1004
- });
1005
- }
1006
- if (e2eEmitted) {
1007
- const kind = fileOperationState.peekPending(focusPathForConsume)?.lifecycle ===
1008
- "deleted"
1009
- ? "e2e_delete"
1010
- : "e2e_write";
1011
- fileOperationState.markShown(focusPathForConsume, kind);
1012
- const e2eSignalForLog = getE2eCoverageSignal(input.worktree, focusPathForConsume);
1013
- logger.info("smart-enforcement.e2e-reminder", {
1014
- event: "smart_enforcement_e2e_reminder",
1015
- file: focusPathForConsume,
1016
- lifecycle: fileOperationState.peekPending(focusPathForConsume)
1017
- ?.lifecycle ?? null,
1018
- signal_level: e2eSignalForLog.level,
1019
- posture_state: posture.state,
1020
- risk_class: effectiveRiskClass,
1021
- });
1022
- }
1023
- // Consume pending only if at least one reminder was emitted
1024
- if (lifecycleEmitted || e2eEmitted) {
1025
- fileOperationState.consumePending(focusPathForConsume);
1026
- }
1027
- }
1028
- // Latch degraded advisory warning-once state
1029
- if (showDegradedAdvisory && guidance.includes("Maintenance degraded")) {
1030
- degradedWarnedOnce = true;
1031
- }
1032
- const last = output.system.length > 0
1033
- ? output.system[output.system.length - 1]
1034
- : undefined;
1035
- if (last !== guidance) {
1036
- output.system.push(guidance);
1037
- }
1038
- };
1039
- }
1040
- if (hookMode === "chat-params" || hookMode === "auto") {
1041
- hooks["chat.params"] = async (_input, _output) => {
1042
- // chat.params only exposes model options, not prompt text.
1043
- // In auto mode the system.transform hook handles injection;
1044
- // this hook is a no-op but kept registered so OpenCode knows
1045
- // the plugin is active.
1046
- if (hookMode === "auto") {
1047
- logger.info("kibi-opencode: chat.params hook active (prompt injection via system.transform)");
1048
- }
1049
- };
1050
- }
1051
- }
1052
- logger.info("kibi-opencode: setup complete");
1053
- if (input.client && !maintenanceDegraded) {
1054
- const client = input.client;
1055
- const scheduleStartupNotify = startupNotifyGlobals.__kibi_test_schedule_startup_notify ??
1056
- ((callback, delayMs) => {
1057
- setTimeout(callback, delayMs);
1058
- });
1059
- scheduleStartupNotify(() => {
1060
- notifyStartup(makeStartupClient(client), {
1061
- suppressToast: cfg.ux.toastStartup === false,
1062
- directory: input.directory,
1063
- });
1064
- }, 2000);
1065
- }
1066
- return hooks;
1067
- };
1068
- export default kibiOpencodePlugin;
1
+ export { default } from "./plugin.js";