kibi-opencode 0.9.0 → 0.10.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.
Files changed (51) hide show
  1. package/README.md +36 -12
  2. package/dist/brief-intent.d.ts +15 -4
  3. package/dist/brief-intent.js +63 -25
  4. package/dist/briefing-runtime.js +2 -1
  5. package/dist/config.d.ts +3 -0
  6. package/dist/config.js +9 -0
  7. package/dist/e2e-coverage-signals.d.ts +6 -0
  8. package/dist/e2e-coverage-signals.js +186 -0
  9. package/dist/file-entity-links.d.ts +15 -0
  10. package/dist/file-entity-links.js +254 -0
  11. package/dist/file-operation-reminders.d.ts +24 -0
  12. package/dist/file-operation-reminders.js +55 -0
  13. package/dist/file-operation-state.d.ts +29 -0
  14. package/dist/file-operation-state.js +113 -0
  15. package/dist/idle-brief-audit.d.ts +36 -0
  16. package/dist/idle-brief-audit.js +186 -0
  17. package/dist/idle-brief-paths.d.ts +6 -0
  18. package/dist/idle-brief-paths.js +120 -0
  19. package/dist/idle-brief-reader.d.ts +25 -0
  20. package/dist/idle-brief-reader.js +142 -0
  21. package/dist/idle-brief-runtime.d.ts +48 -0
  22. package/dist/idle-brief-runtime.js +443 -0
  23. package/dist/idle-brief-store.d.ts +96 -0
  24. package/dist/idle-brief-store.js +209 -0
  25. package/dist/index.d.ts +14 -1
  26. package/dist/index.js +626 -50
  27. package/dist/init-kibi-alias.d.ts +14 -0
  28. package/dist/init-kibi-alias.js +38 -0
  29. package/dist/init-kibi-capability.d.ts +32 -0
  30. package/dist/init-kibi-capability.js +202 -0
  31. package/dist/logger.js +9 -3
  32. package/dist/plugin-startup.d.ts +1 -0
  33. package/dist/plugin-startup.js +11 -2
  34. package/dist/prompt.d.ts +15 -3
  35. package/dist/prompt.js +103 -33
  36. package/dist/reconcile-engine.d.ts +15 -0
  37. package/dist/reconcile-engine.js +112 -0
  38. package/dist/scheduler.d.ts +1 -0
  39. package/dist/scheduler.js +37 -1
  40. package/dist/session-edit-state.d.ts +25 -0
  41. package/dist/session-edit-state.js +177 -0
  42. package/dist/session-fingerprint.d.ts +11 -0
  43. package/dist/session-fingerprint.js +21 -0
  44. package/dist/source-linked-guidance.d.ts +1 -2
  45. package/dist/source-linked-guidance.js +5 -168
  46. package/dist/startup-notifier.js +42 -31
  47. package/dist/toast.d.ts +21 -22
  48. package/dist/toast.js +36 -14
  49. package/dist/tui-brief-delivery.d.ts +47 -0
  50. package/dist/tui-brief-delivery.js +138 -0
  51. package/package.json +4 -3
package/dist/index.js CHANGED
@@ -1,21 +1,48 @@
1
1
  import * as path from "node:path";
2
+ import { loadBriefConfig } from "kibi-cli/brief-config";
2
3
  import { computeBriefIntent } from "./brief-intent.js";
3
4
  import { fetchBriefingResult, } from "./briefing-runtime.js";
4
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
5
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";
6
15
  import * as logger from "./logger.js";
7
16
  import { analyzePath } from "./path-kind.js";
17
+ import { runPluginStartup } from "./plugin-startup.js";
18
+ import { resolveCurrentBranch } from "./plugin-startup.js";
8
19
  import { SENTINEL, buildPrompt } from "./prompt.js";
20
+ import { reconcileAuditEntries } from "./reconcile-engine.js";
9
21
  import { isMustPriorityRequirement } from "./requirement-doc.js";
10
22
  import { classifyRisk } from "./risk-classifier.js";
23
+ import { createSessionEditState, } from "./session-edit-state.js";
24
+ import { syncSessionBaselineState, } from "./session-fingerprint.js";
11
25
  import { getSessionTracker } from "./session-tracker.js";
12
- import { notifyStartup } from "./startup-notifier.js";
13
- import { runPluginStartup } from "./plugin-startup.js";
14
- import { sendToast } from "./toast.js";
26
+ import { notifyStartup, } from "./startup-notifier.js";
27
+ import { sendToast, } from "./toast.js";
28
+ import { deliverBriefTui, } from "./tui-brief-delivery.js";
15
29
  import * as fs from "node:fs";
16
30
  function deriveFileBucket(kind) {
17
31
  return kind;
18
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
+ }
19
46
  const startupNotifyGlobals = globalThis;
20
47
  /**
21
48
  * Lint requirement documents for embedded scenarios/tests and oversized content.
@@ -56,32 +83,423 @@ function lintRequirementDoc(filePath, worktree) {
56
83
  }
57
84
  // implements REQ-opencode-kibi-plugin-v1
58
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
+ });
59
109
  const startup = await runPluginStartup(input);
60
110
  if (!startup) {
61
111
  return {};
62
112
  }
63
113
  const { cfg, workspaceHealth, posture, currentBranch, cache, runtimeOverlay, scheduler, maintenanceDegraded, getMaintenanceDegraded, getEffectiveMode, latchRuntimeDegraded, } = startup;
64
114
  const hooks = {};
115
+ const initKibiCommandCapability = getInitKibiCommandCapability();
116
+ if (initKibiCommandCapability.supported) {
117
+ hooks.config = async (configInput) => {
118
+ registerInitKibiCommand(configInput, initKibiCommandCapability);
119
+ };
120
+ }
65
121
  // Plugin instance state (not module globals)
66
122
  const MAX_RECENT_EDITS = 5;
67
123
  let recentEdits = [];
68
124
  let hasRecentKbEdit = false;
69
125
  let recentCommentSuggestion = null;
70
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.
71
130
  const autoBriefResults = new Map();
72
131
  const toastedFingerprints = new Set();
73
132
  let lastRiskClass = null;
74
- let lastEditedFilePath = null;
75
- let lastBriefFingerprint = 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
76
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
+ }
77
319
  hooks.event = async ({ event }) => {
78
- if (event.type !== "file.edited")
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)
79
446
  return;
447
+ if (idleBriefTimer) {
448
+ clearTimeout(idleBriefTimer);
449
+ idleBriefTimer = null;
450
+ }
80
451
  const filePath = event
81
452
  .properties.file;
82
453
  if (!filePath)
83
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);
84
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
+ }
85
503
  let fileContent = "";
86
504
  try {
87
505
  const resolvedPath = input.worktree && !path.isAbsolute(filePath)
@@ -115,7 +533,6 @@ const kibiOpencodePlugin = async (input) => {
115
533
  const isAutoBriefRisk = effectiveRiskClass === "behavior_candidate" ||
116
534
  effectiveRiskClass === "traceability_candidate";
117
535
  lastRiskClass = effectiveRiskClass;
118
- lastEditedFilePath = filePath;
119
536
  logger.info("smart-enforcement.risk", {
120
537
  event: "smart_enforcement_risk",
121
538
  file: filePath,
@@ -153,7 +570,9 @@ const kibiOpencodePlugin = async (input) => {
153
570
  "required-fields",
154
571
  "no-dangling-refs",
155
572
  ...(pathAnalysis.kind === "fact" ? ["strict-fact-shape"] : []),
156
- ...(pathAnalysis.kind === "requirement" ? ["strict-req-fact-pairing"] : []),
573
+ ...(pathAnalysis.kind === "requirement"
574
+ ? ["strict-req-fact-pairing"]
575
+ : []),
157
576
  ]
158
577
  : null;
159
578
  const checkRules = traceabilityRules ?? kbStructuralRules;
@@ -178,15 +597,11 @@ const kibiOpencodePlugin = async (input) => {
178
597
  : "smart-enforcement.kb-doc", filePath, checkRules);
179
598
  }
180
599
  }
181
- const now = Date.now();
182
- recentEdits.push({
183
- path: filePath,
184
- kind: pathAnalysis.kind,
185
- timestamp: now,
186
- });
187
- if (recentEdits.length > MAX_RECENT_EDITS) {
188
- recentEdits = recentEdits.slice(-MAX_RECENT_EDITS);
189
- }
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
+ }));
190
605
  if (effectiveRiskClass === "safe_docs_only" ||
191
606
  effectiveRiskClass === "safe_test_only") {
192
607
  recentCommentSuggestion = null;
@@ -278,7 +693,11 @@ const kibiOpencodePlugin = async (input) => {
278
693
  logger.info(`kibi-opencode: must-priority requirement detected, scheduling elevated checks for ${filePath}`);
279
694
  }
280
695
  else {
281
- checkRules = ["required-fields", "no-dangling-refs", "strict-req-fact-pairing"];
696
+ checkRules = [
697
+ "required-fields",
698
+ "no-dangling-refs",
699
+ "strict-req-fact-pairing",
700
+ ];
282
701
  }
283
702
  }
284
703
  logger.info("smart-enforcement.targeted-checks", {
@@ -349,45 +768,28 @@ const kibiOpencodePlugin = async (input) => {
349
768
  else {
350
769
  recentCommentSuggestion = null;
351
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);
352
776
  const intentResult = computeBriefIntent({
353
777
  riskClass: effectiveRiskClass,
354
778
  posture: posture.state,
355
779
  maintenanceDegraded: getMaintenanceDegraded(),
356
- editedFile: filePath,
780
+ sourceFiles: sessionSourceFiles,
781
+ focusFilePath: focusEdit.filePath,
357
782
  worktreeRoot: input.worktree,
358
783
  branch: currentBranch,
359
784
  });
360
- lastBriefFingerprint = intentResult.fingerprint;
361
- if (intentResult.eligible &&
362
- input.client &&
363
- !getMaintenanceDegraded() &&
364
- (posture.state === "root_active" ||
365
- posture.state === "hybrid_root_plus_vendored")) {
366
- const client = input.client;
367
- const fingerprint = intentResult.fingerprint;
368
- const workspaceCtx = {
369
- workspaceRoot: input.worktree,
370
- branch: currentBranch,
371
- directory: input.directory,
372
- ...(input.workspace !== undefined ? { workspace: input.workspace } : {}),
373
- };
374
- void fetchBriefingResult(client, workspaceCtx, intentResult).then((result) => {
375
- autoBriefResults.set(fingerprint, result);
376
- if (!toastedFingerprints.has(fingerprint)) {
377
- toastedFingerprints.add(fingerprint);
378
- void sendToast(client, { message: result.toastMessage }).catch(() => {
379
- // toast delivery failure is non-fatal
380
- });
381
- }
382
- });
383
- }
785
+ queueBriefingFetch(intentResult);
384
786
  }
385
787
  return;
386
788
  };
387
789
  if (cfg.prompt.enabled) {
388
790
  const hookMode = cfg.prompt.hookMode;
389
791
  if (hookMode === "system-transform" || hookMode === "auto") {
390
- hooks["experimental.chat.system.transform"] = async (_input, output) => {
792
+ hooks["experimental.chat.system.transform"] = async (transformInput, output) => {
391
793
  // Skip if sentinel already present in any existing entry
392
794
  if (output.system.some((entry) => entry.includes(SENTINEL))) {
393
795
  return;
@@ -396,12 +798,134 @@ const kibiOpencodePlugin = async (input) => {
396
798
  const showDegradedAdvisory = maintenanceDegraded &&
397
799
  cfg.guidance.smartEnforcement.degradedMode === "warn-once" &&
398
800
  !degradedWarnedOnce;
399
- const autoBriefResult = lastBriefFingerprint != null
400
- ? autoBriefResults.get(lastBriefFingerprint)
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)
401
854
  : undefined;
402
- // Build only the guidance block and append it; existing entries are preserved
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
+ }
403
926
  const guidance = buildPrompt({
404
- recentEdits,
927
+ recentEdits: transformRecentEdits,
928
+ focusEdit: transformPromptFocusEdit,
405
929
  workspaceHealth,
406
930
  hasRecentKbEdit,
407
931
  recentCommentSuggestion,
@@ -414,7 +938,12 @@ const kibiOpencodePlugin = async (input) => {
414
938
  degradedMode: cfg.guidance.smartEnforcement.degradedMode,
415
939
  showDegradedAdvisory,
416
940
  ...(autoBriefResult !== undefined ? { autoBriefResult } : {}),
417
- ...(lastRiskClass != null ? { riskClass: lastRiskClass } : {}),
941
+ ...(effectiveRiskClass != null
942
+ ? { riskClass: effectiveRiskClass }
943
+ : {}),
944
+ ...(fileOperationReminder !== undefined
945
+ ? { fileOperationReminder }
946
+ : {}),
418
947
  });
419
948
  logger.info("smart-enforcement.guidance", {
420
949
  event: "smart_enforcement_guidance",
@@ -449,6 +978,53 @@ const kibiOpencodePlugin = async (input) => {
449
978
  overlay_cause: runtimeOverlay.primaryCause ?? null,
450
979
  });
451
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
+ }
452
1028
  // Latch degraded advisory warning-once state
453
1029
  if (showDegradedAdvisory && guidance.includes("Maintenance degraded")) {
454
1030
  degradedWarnedOnce = true;
@@ -481,7 +1057,7 @@ const kibiOpencodePlugin = async (input) => {
481
1057
  setTimeout(callback, delayMs);
482
1058
  });
483
1059
  scheduleStartupNotify(() => {
484
- notifyStartup(client, {
1060
+ notifyStartup(makeStartupClient(client), {
485
1061
  suppressToast: cfg.ux.toastStartup === false,
486
1062
  directory: input.directory,
487
1063
  });