kibi-opencode 1.0.0 → 2.0.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/plugin.js CHANGED
@@ -20,10 +20,23 @@ import { classifyRisk } from "./risk-classifier.js";
20
20
  import { createSyncScheduler } from "./scheduler.js";
21
21
  import { createSessionEditState, } from "./session-edit-state.js";
22
22
  import { getSessionTracker } from "./session-tracker.js";
23
+ import { handleFileLifecycleEvent, handleKbToolEvent, isKbToolEventType, } from "./smart-enforcement-events.js";
23
24
  import { notifyStartup, } from "./startup-notifier.js";
24
25
  import { sendToast, } from "./toast.js";
25
26
  import { readKibiPackageVersions } from "./version-metadata.js";
26
27
  import { resolveWorkContext, } from "./work-context-resolver.js";
28
+ export function resetCommentSuggestion() {
29
+ return null;
30
+ }
31
+ // implements REQ-opencode-file-context-guidance-v1
32
+ export function nextRecentCommentSuggestion(inspectingCodeComments, suggestion) {
33
+ if (inspectingCodeComments && suggestion)
34
+ return suggestion;
35
+ return resetCommentSuggestion();
36
+ }
37
+ export function adoptPrecomputedSuggestion(recent, precomputed) {
38
+ return recent ?? precomputed ?? null;
39
+ }
27
40
  import * as fs from "node:fs";
28
41
  function deriveFileBucket(kind) {
29
42
  return kind;
@@ -108,13 +121,15 @@ const kibiOpencodePlugin = async (input) => {
108
121
  }
109
122
  // Plugin instance state (not module globals)
110
123
  const MAX_RECENT_EDITS = 5;
111
- let recentEdits = [];
112
- let hasRecentKbEdit = false;
113
- let recentCommentSuggestion = null;
114
- const seenFingerprints = new Set(); // For deduplication
115
- let lastRiskClass = null;
116
- let lastRiskFilePath = null;
117
- let lastRiskScopeKey = null;
124
+ const enforcementState = {
125
+ recentEdits: [],
126
+ hasRecentKbEdit: false,
127
+ recentCommentSuggestion: null,
128
+ seenFingerprints: new Set(), // For deduplication
129
+ lastRiskClass: null,
130
+ lastRiskFilePath: null,
131
+ lastRiskScopeKey: null,
132
+ };
118
133
  const schedulerRegistry = new Map();
119
134
  if (startupScheduler) {
120
135
  schedulerRegistry.set(path.resolve(input.worktree), startupScheduler);
@@ -298,12 +313,14 @@ const kibiOpencodePlugin = async (input) => {
298
313
  }
299
314
  }
300
315
  function updateRecentEditsFromSession(sessionEdits, scopedPathKindCache) {
301
- recentEdits = sessionEdits.slice(-MAX_RECENT_EDITS).map((entry) => ({
316
+ enforcementState.recentEdits = sessionEdits
317
+ .slice(-MAX_RECENT_EDITS)
318
+ .map((entry) => ({
302
319
  path: entry.filePath,
303
320
  kind: scopedPathKindCache.get(entry.filePath) ?? "unknown",
304
321
  timestamp: entry.lastReconciledAt,
305
322
  }));
306
- return recentEdits;
323
+ return enforcementState.recentEdits;
307
324
  }
308
325
  function deriveRiskContext(context, filePath, scopedPathKindCache) {
309
326
  const normalizedFilePath = normalizeSessionPath(filePath, context.worktreeRoot);
@@ -329,11 +346,11 @@ const kibiOpencodePlugin = async (input) => {
329
346
  const effectiveRiskClass = riskClass === "safe_docs_only" && precomputedSuggestion
330
347
  ? "traceability_candidate"
331
348
  : riskClass;
332
- recentCommentSuggestion =
349
+ enforcementState.recentCommentSuggestion =
333
350
  pathAnalysis.kind === "code" ? precomputedSuggestion : null;
334
- lastRiskClass = effectiveRiskClass;
335
- lastRiskFilePath = normalizedFilePath;
336
- lastRiskScopeKey = buildRiskPathScopeKey(context, normalizedFilePath);
351
+ enforcementState.lastRiskClass = effectiveRiskClass;
352
+ enforcementState.lastRiskFilePath = normalizedFilePath;
353
+ enforcementState.lastRiskScopeKey = buildRiskPathScopeKey(context, normalizedFilePath);
337
354
  return {
338
355
  effectiveRiskClass,
339
356
  pathAnalysis,
@@ -341,354 +358,43 @@ const kibiOpencodePlugin = async (input) => {
341
358
  precomputedSuggestion,
342
359
  };
343
360
  }
361
+ const eventEnv = {
362
+ input,
363
+ cfg,
364
+ cache,
365
+ freshnessStore,
366
+ fileFilter,
367
+ log: logger,
368
+ rootWorkContext,
369
+ state: enforcementState,
370
+ resolveScopedWorkContext,
371
+ getSessionEditState,
372
+ getFileOperationState,
373
+ getPathKindCache,
374
+ getSchedulerForContext,
375
+ normalizeSessionPath,
376
+ resolveWorktreePath,
377
+ buildRiskPathScopeKey,
378
+ buildScopedCacheKey,
379
+ readFileContent,
380
+ updateRecentEditsFromSession,
381
+ recordWarning: (category, filePath, message) => getSessionTracker().recordWarning(category, filePath, message),
382
+ getMaintenanceDegraded,
383
+ getEffectiveMode,
384
+ runtimeOverlay,
385
+ posture,
386
+ lintRequirementDoc,
387
+ };
344
388
  hooks.event = async ({ event }) => {
345
- // Observe KB tool execution events for freshness evidence (best-effort)
346
- const TOOL_EVENT_TYPES = new Set([
347
- "tool.execute.after",
348
- "tool.executed",
349
- "tool.call.completed",
350
- "tool.Execute.after",
351
- "tool.Call.completed",
352
- ]);
353
- if (TOOL_EVENT_TYPES.has(event.type)) {
354
- const props = event.properties ?? {};
355
- const toolName = (props.tool ??
356
- props.toolName ??
357
- props.name ??
358
- props.call?.name ??
359
- props.input?.tool);
360
- if (typeof toolName === "string" && toolName.startsWith("kb_")) {
361
- const scope = {
362
- ...(input.sessionId !== undefined
363
- ? { sessionId: input.sessionId }
364
- : {}),
365
- agentIdentity: input.agentIdentity ?? "unknown",
366
- worktree: input.worktree,
367
- branch: rootWorkContext.branch,
368
- fingerprint: `${input.sessionId ?? ""}-${rootWorkContext.branch}`,
369
- };
370
- try {
371
- freshnessStore.recordToolEvidence(scope, toolName);
372
- logger.info("kb-freshness.tool-evidence", {
373
- event: "kb_freshness_tool_evidence",
374
- tool: toolName,
375
- });
376
- }
377
- catch {
378
- // best-effort, never crash the event handler
379
- }
380
- }
381
- return; // tool events don't need file lifecycle processing
382
- }
383
- // Accept file.created, file.edited, and file.deleted lifecycle events
384
- const isFileLifecycle = event.type === "file.created" ||
385
- event.type === "file.edited" ||
386
- event.type === "file.deleted";
387
- if (!isFileLifecycle)
388
- return;
389
- const filePath = event
390
- .properties.file;
391
- if (!filePath)
392
- return;
393
- const eventContext = resolveScopedWorkContext(filePath);
394
- const scopedSessionEditState = getSessionEditState(eventContext);
395
- const scopedFileOperationState = getFileOperationState(eventContext);
396
- const scopedPathKindCache = getPathKindCache(eventContext);
397
- const scopedScheduler = getSchedulerForContext(eventContext);
398
- const normalizedFilePath = normalizeSessionPath(filePath, eventContext.worktreeRoot);
399
- // Record lifecycle event into file-operation-state // implements REQ-opencode-file-context-guidance-v1
400
- const lifecycle = event.type === "file.created"
401
- ? "created"
402
- : event.type === "file.deleted"
403
- ? "deleted"
404
- : "edited";
405
- scopedFileOperationState.recordLifecycle(filePath, lifecycle, Date.now());
406
- scopedFileOperationState.normalizePath(filePath);
407
- const pathAnalysis = analyzePath(normalizedFilePath, eventContext.worktreeRoot);
408
- // For file.deleted: derive path kind without reading content, classify for reminder routing only
409
- if (lifecycle === "deleted") {
410
- // Preserve last known semantic risk if path was already tracked during session
411
- const lastKnownKind = scopedPathKindCache.get(normalizedFilePath);
412
- if (lastKnownKind) {
413
- // Path was tracked — preserve last known semantic risk for reminder routing
414
- scopedPathKindCache.set(normalizedFilePath, pathAnalysis.kind);
415
- }
416
- else {
417
- // Not tracked — classify only for reminder routing.
418
- scopedPathKindCache.set(normalizedFilePath, pathAnalysis.kind);
419
- }
420
- scopedSessionEditState.recordEventHint(normalizedFilePath, pathAnalysis.kind, Date.now());
421
- scopedSessionEditState.reconcilePath(normalizedFilePath);
422
- const sessionEdits = scopedSessionEditState.getSessionEdits();
423
- updateRecentEditsFromSession(sessionEdits, scopedPathKindCache);
424
- // Schedule background sync for deleted files that pass shouldHandleFile // implements REQ-opencode-file-context-guidance-v1
425
- if (cfg.sync.enabled &&
426
- scopedScheduler &&
427
- fileFilter.shouldHandleFile(normalizedFilePath, eventContext.worktreeRoot)) {
428
- scopedScheduler.scheduleSync("file.deleted", normalizedFilePath);
429
- }
430
- return;
431
- }
432
- scopedSessionEditState.recordEventHint(normalizedFilePath, pathAnalysis.kind, Date.now());
433
- scopedSessionEditState.reconcilePath(normalizedFilePath);
434
- scopedPathKindCache.set(normalizedFilePath, pathAnalysis.kind);
435
- const sessionEdits = scopedSessionEditState.getSessionEdits();
436
- const focusEdit = scopedSessionEditState.getFocusEdit();
437
- // Schedule background sync for file.created/file.edited that pass shouldHandleFile // implements REQ-opencode-file-context-guidance-v1
438
- if (cfg.sync.enabled &&
439
- scopedScheduler &&
440
- fileFilter.shouldHandleFile(normalizedFilePath, eventContext.worktreeRoot)) {
441
- scopedScheduler.scheduleSync(lifecycle === "created" ? "file.created" : "file.edited", normalizedFilePath);
442
- }
443
- const fileContent = readFileContent(normalizedFilePath, eventContext.worktreeRoot);
444
- const hasMustPriority = pathAnalysis.kind === "requirement"
445
- ? isMustPriorityRequirement(normalizedFilePath, eventContext.worktreeRoot)
446
- : false;
447
- let precomputedSuggestion = null;
448
- if (pathAnalysis.kind === "code" && cfg.guidance.commentDetection.enabled) {
449
- precomputedSuggestion = analyzeCodeFile(resolveWorktreePath(normalizedFilePath, eventContext.worktreeRoot), {
450
- minLines: cfg.guidance.commentDetection.minLines,
451
- });
452
- }
453
- const { riskClass } = classifyRisk({
454
- pathKind: pathAnalysis.kind,
455
- isUnderKb: pathAnalysis.isUnderKb,
456
- hasMustPriority,
457
- hasDurableComment: !!precomputedSuggestion,
458
- fileContent,
459
- });
460
- const effectiveRiskClass = riskClass === "safe_docs_only" && precomputedSuggestion
461
- ? "traceability_candidate"
462
- : riskClass;
463
- lastRiskClass = effectiveRiskClass;
464
- lastRiskFilePath = normalizedFilePath;
465
- lastRiskScopeKey = buildRiskPathScopeKey(eventContext, normalizedFilePath);
466
- logger.info("smart-enforcement.risk", {
467
- event: "smart_enforcement_risk",
468
- file: normalizedFilePath,
469
- path_kind: pathAnalysis.kind,
470
- risk_class: effectiveRiskClass,
471
- posture_state: eventContext.posture,
472
- maintenance_state: getMaintenanceDegraded()
473
- ? "maintenance_degraded"
474
- : "maintenance_available",
475
- under_kb: pathAnalysis.isUnderKb,
476
- has_must_priority: hasMustPriority,
477
- posture: eventContext.posture,
478
- reason_code: effectiveRiskClass,
479
- effective_mode: getEffectiveMode(),
480
- static_degraded: posture.maintenanceDegraded,
481
- runtime_degraded: runtimeOverlay.degraded,
482
- merged_degraded: getMaintenanceDegraded(),
483
- overlay_cause: runtimeOverlay.primaryCause ?? null,
484
- });
485
- const targetedChecksBlocked = getMaintenanceDegraded() ||
486
- runtimeOverlay.primaryCause === "sync_disabled" ||
487
- runtimeOverlay.primaryCause === "scheduler_unavailable" ||
488
- runtimeOverlay.primaryCause === "scheduler_sync_failed" ||
489
- runtimeOverlay.primaryCause === "scheduler_check_failed";
490
- if (!targetedChecksBlocked &&
491
- cfg.sync.enabled &&
492
- scopedScheduler &&
493
- cfg.guidance.targetedChecks.enabled) {
494
- const traceabilityRules = effectiveRiskClass === "traceability_candidate"
495
- ? ["symbol-traceability"]
496
- : null;
497
- const kbStructuralRules = effectiveRiskClass === "kb_doc_structural" &&
498
- fileFilter.shouldHandleFile(normalizedFilePath, eventContext.worktreeRoot)
499
- ? [
500
- "required-fields",
501
- "no-dangling-refs",
502
- ...(pathAnalysis.kind === "fact" ? ["strict-fact-shape"] : []),
503
- ...(pathAnalysis.kind === "requirement"
504
- ? ["strict-req-fact-pairing"]
505
- : []),
506
- ]
507
- : null;
508
- const checkRules = traceabilityRules ?? kbStructuralRules;
509
- if (checkRules) {
510
- logger.info("smart-enforcement.targeted-checks", {
511
- event: "smart_enforcement_targeted_checks",
512
- file: normalizedFilePath,
513
- risk_class: effectiveRiskClass,
514
- posture: eventContext.posture,
515
- posture_state: eventContext.posture,
516
- guidance_action: "targeted_checks",
517
- effective_mode: getEffectiveMode(),
518
- rules: checkRules,
519
- static_degraded: posture.maintenanceDegraded,
520
- runtime_degraded: runtimeOverlay.degraded,
521
- merged_degraded: getMaintenanceDegraded(),
522
- overlay_cause: runtimeOverlay.primaryCause ?? null,
523
- });
524
- logger.info(`kibi-opencode: scheduling sync for ${normalizedFilePath}`);
525
- scopedScheduler.scheduleSync(effectiveRiskClass === "traceability_candidate"
526
- ? "smart-enforcement.traceability"
527
- : "smart-enforcement.kb-doc", normalizedFilePath, checkRules);
528
- }
529
- }
530
- updateRecentEditsFromSession(sessionEdits, scopedPathKindCache);
531
- if (effectiveRiskClass === "safe_docs_only" ||
532
- effectiveRiskClass === "safe_test_only") {
533
- recentCommentSuggestion = null;
534
- return;
535
- }
536
- const cacheKey = buildScopedCacheKey(eventContext, effectiveRiskClass, deriveFileBucket(pathAnalysis.kind), [normalizedFilePath, pathAnalysis.kind, effectiveRiskClass]);
537
- // Always process manual_kb_edit before cache check — this is a critical safety signal
538
- if (effectiveRiskClass === "manual_kb_edit") {
539
- hasRecentKbEdit = true;
540
- if (cfg.guidance.warnOnKbEdits) {
541
- logger.warn(`kibi-opencode: .kb edit detected for ${normalizedFilePath}`);
542
- getSessionTracker().recordWarning("kb-edit", normalizedFilePath, `Manual .kb edit: ${normalizedFilePath}`);
543
- }
544
- return;
545
- }
546
- // Always emit requirement lint warnings before cache check — these are safety signals
547
- if (effectiveRiskClass === "req_policy_candidate") {
548
- const lintWarnings = lintRequirementDoc(normalizedFilePath, eventContext.worktreeRoot);
549
- for (const warning of lintWarnings) {
550
- getSessionTracker().recordWarning(warning.category, normalizedFilePath, warning.message);
551
- }
552
- }
553
- // Cache check: after critical signals have been emitted
554
- if (cache.isSatisfied(cacheKey)) {
555
- logger.info("smart-enforcement.cache", {
556
- event: "smart_enforcement_cache",
557
- cache_hit: true,
558
- cache_state: "hit",
559
- file: normalizedFilePath,
560
- risk_class: effectiveRiskClass,
561
- posture: eventContext.posture,
562
- posture_state: eventContext.posture,
563
- });
564
- return;
565
- }
566
- logger.info("smart-enforcement.cache", {
567
- event: "smart_enforcement_cache",
568
- cache_hit: false,
569
- cache_state: "miss",
570
- file: normalizedFilePath,
571
- risk_class: effectiveRiskClass,
572
- posture: eventContext.posture,
573
- posture_state: eventContext.posture,
574
- });
575
- if (effectiveRiskClass === "req_policy_candidate") {
576
- if (getMaintenanceDegraded()) {
577
- const logFn = cfg.guidance.smartEnforcement.degradedMode === "warn-once"
578
- ? logger.warn
579
- : logger.info;
580
- logFn("smart-enforcement.degraded", {
581
- event: "smart_enforcement_degraded",
582
- file: normalizedFilePath,
583
- risk_class: effectiveRiskClass,
584
- posture: eventContext.posture,
585
- posture_state: eventContext.posture,
586
- maintenance_state: getMaintenanceDegraded()
587
- ? "maintenance_degraded"
588
- : "maintenance_available",
589
- reason: runtimeOverlay.primaryCause ?? "non_authoritative_posture",
590
- reason_code: runtimeOverlay.primaryCause ?? "non_authoritative_posture",
591
- static_degraded: posture.maintenanceDegraded,
592
- runtime_degraded: runtimeOverlay.degraded,
593
- merged_degraded: getMaintenanceDegraded(),
594
- overlay_cause: runtimeOverlay.primaryCause ?? null,
595
- effective_mode: getEffectiveMode(),
596
- });
597
- }
598
- if (!getMaintenanceDegraded() &&
599
- cfg.sync.enabled &&
600
- scopedScheduler &&
601
- fileFilter.shouldHandleFile(normalizedFilePath, eventContext.worktreeRoot)) {
602
- let checkRules;
603
- if (cfg.guidance.targetedChecks.enabled) {
604
- if (hasMustPriority && getEffectiveMode() === "strict") {
605
- checkRules = [
606
- "required-fields",
607
- "no-dangling-refs",
608
- "must-priority-coverage",
609
- "strict-req-fact-pairing",
610
- ];
611
- logger.info(`kibi-opencode: must-priority requirement detected, scheduling elevated checks for ${normalizedFilePath}`);
612
- }
613
- else {
614
- checkRules = [
615
- "required-fields",
616
- "no-dangling-refs",
617
- "strict-req-fact-pairing",
618
- ];
619
- }
620
- }
621
- logger.info("smart-enforcement.targeted-checks", {
622
- event: "smart_enforcement_targeted_checks",
623
- file: normalizedFilePath,
624
- risk_class: effectiveRiskClass,
625
- posture: eventContext.posture,
626
- posture_state: eventContext.posture,
627
- guidance_action: "targeted_checks",
628
- effective_mode: getEffectiveMode(),
629
- rules: checkRules ?? [],
630
- static_degraded: posture.maintenanceDegraded,
631
- runtime_degraded: runtimeOverlay.degraded,
632
- merged_degraded: getMaintenanceDegraded(),
633
- overlay_cause: runtimeOverlay.primaryCause ?? null,
634
- });
635
- scopedScheduler.scheduleSync("file.edited", normalizedFilePath, checkRules);
636
- }
389
+ if (isKbToolEventType(event.type)) {
390
+ handleKbToolEvent(eventEnv, event, input.sessionId);
637
391
  return;
638
392
  }
639
- if (effectiveRiskClass === "kb_doc_structural") {
640
- if (getMaintenanceDegraded()) {
641
- const logFn = cfg.guidance.smartEnforcement.degradedMode === "warn-once"
642
- ? logger.warn
643
- : logger.info;
644
- logFn("smart-enforcement.degraded", {
645
- event: "smart_enforcement_degraded",
646
- file: normalizedFilePath,
647
- risk_class: effectiveRiskClass,
648
- posture: eventContext.posture,
649
- posture_state: eventContext.posture,
650
- maintenance_state: getMaintenanceDegraded()
651
- ? "maintenance_degraded"
652
- : "maintenance_available",
653
- reason: runtimeOverlay.primaryCause ?? "non_authoritative_posture",
654
- reason_code: runtimeOverlay.primaryCause ?? "non_authoritative_posture",
655
- static_degraded: posture.maintenanceDegraded,
656
- runtime_degraded: runtimeOverlay.degraded,
657
- merged_degraded: getMaintenanceDegraded(),
658
- overlay_cause: runtimeOverlay.primaryCause ?? null,
659
- effective_mode: getEffectiveMode(),
660
- });
661
- }
662
- return;
663
- }
664
- if (effectiveRiskClass === "behavior_candidate" ||
665
- effectiveRiskClass === "traceability_candidate") {
666
- if (pathAnalysis.kind === "code" &&
667
- cfg.guidance.commentDetection.enabled) {
668
- const suggestion = precomputedSuggestion;
669
- if (suggestion) {
670
- recentCommentSuggestion = suggestion;
671
- const dedupeKey = `${buildRiskPathScopeKey(eventContext, normalizedFilePath)}:${suggestion.suggestionType}:${suggestion.fingerprint}`;
672
- if (!seenFingerprints.has(dedupeKey)) {
673
- seenFingerprints.add(dedupeKey);
674
- const warningCategory = suggestion.suggestionType === "fact"
675
- ? "long-comment-missed-fact"
676
- : suggestion.suggestionType === "adr"
677
- ? "long-comment-missed-adr"
678
- : "missing-traceability";
679
- logger.warn(`kibi-opencode: detected durable ${suggestion.suggestionType} knowledge in ${normalizedFilePath}`);
680
- getSessionTracker().recordWarning(warningCategory, normalizedFilePath, `Consider routing this ${suggestion.suggestionType} knowledge to Kibi instead of inline comments: ${suggestion.reasoning}`);
681
- }
682
- }
683
- else {
684
- recentCommentSuggestion = null;
685
- }
686
- }
687
- else {
688
- recentCommentSuggestion = null;
689
- }
393
+ if (event.type === "file.created" ||
394
+ event.type === "file.edited" ||
395
+ event.type === "file.deleted") {
396
+ handleFileLifecycleEvent(eventEnv, event);
690
397
  }
691
- return;
692
398
  };
693
399
  if (cfg.prompt.enabled) {
694
400
  const hookMode = cfg.prompt.hookMode;
@@ -730,19 +436,20 @@ const kibiOpencodePlugin = async (input) => {
730
436
  const riskScopeKey = riskContextFilePath
731
437
  ? buildRiskPathScopeKey(promptWorkContext, riskContextFilePath)
732
438
  : null;
733
- let effectiveRiskClass = riskScopeKey !== null && lastRiskScopeKey === riskScopeKey
734
- ? lastRiskClass
439
+ let effectiveRiskClass = riskScopeKey !== null &&
440
+ enforcementState.lastRiskScopeKey === riskScopeKey
441
+ ? enforcementState.lastRiskClass
735
442
  : null;
736
443
  if (riskContextFilePath &&
737
- (lastRiskClass === null || lastRiskScopeKey !== riskScopeKey)) {
444
+ (enforcementState.lastRiskClass === null ||
445
+ enforcementState.lastRiskScopeKey !== riskScopeKey)) {
738
446
  const riskCtx = deriveRiskContext(promptWorkContext, riskContextFilePath, promptPathKindCache);
739
447
  effectiveRiskClass = riskCtx.effectiveRiskClass;
740
- if (!recentCommentSuggestion && riskCtx.precomputedSuggestion) {
741
- recentCommentSuggestion = riskCtx.precomputedSuggestion;
742
- }
448
+ enforcementState.recentCommentSuggestion = adoptPrecomputedSuggestion(enforcementState.recentCommentSuggestion, riskCtx.precomputedSuggestion);
743
449
  }
744
- if (effectiveRiskClass === null && lastRiskClass !== null) {
745
- effectiveRiskClass = lastRiskClass;
450
+ if (effectiveRiskClass === null &&
451
+ enforcementState.lastRiskClass !== null) {
452
+ effectiveRiskClass = enforcementState.lastRiskClass;
746
453
  }
747
454
  const promptFocusFilePath = transformFocusEdit?.filePath ?? transformFocusFilePath ?? undefined;
748
455
  // Steps 3-4: File-operation reminder selection with suppression // implements REQ-opencode-file-context-guidance-v1
@@ -900,8 +607,8 @@ const kibiOpencodePlugin = async (input) => {
900
607
  recentEdits: transformRecentEdits,
901
608
  focusEdit: transformPromptFocusEdit,
902
609
  workspaceHealth,
903
- hasRecentKbEdit,
904
- recentCommentSuggestion,
610
+ hasRecentKbEdit: enforcementState.hasRecentKbEdit,
611
+ recentCommentSuggestion: enforcementState.recentCommentSuggestion,
905
612
  posture: promptWorkContext.posture,
906
613
  cache,
907
614
  workspaceRoot: promptWorkContext.worktreeRoot,
@@ -934,8 +641,8 @@ const kibiOpencodePlugin = async (input) => {
934
641
  guidance_action: guidance.trim() !== "" && guidance.trim() !== SENTINEL
935
642
  ? "emit"
936
643
  : "skip",
937
- risk_class: lastRiskClass,
938
- recent_edits: recentEdits.length,
644
+ risk_class: enforcementState.lastRiskClass,
645
+ recent_edits: enforcementState.recentEdits.length,
939
646
  static_degraded: posture.maintenanceDegraded,
940
647
  runtime_degraded: runtimeOverlay.degraded,
941
648
  merged_degraded: maintenanceDegraded,
@@ -948,7 +655,7 @@ const kibiOpencodePlugin = async (input) => {
948
655
  guidance.includes(REMINDER_TEXT)) {
949
656
  logger.info("smart-enforcement.completion-reminder", {
950
657
  event: "smart_enforcement_completion_reminder",
951
- risk_class: lastRiskClass,
658
+ risk_class: enforcementState.lastRiskClass,
952
659
  posture: promptWorkContext.posture,
953
660
  posture_state: promptWorkContext.posture,
954
661
  guidance_action: "completion_reminder",
@@ -1,4 +1,8 @@
1
1
  export type EditEventKind = string;
2
+ export declare function pushEventHintIfKind(hints: Array<{
3
+ kind: string;
4
+ timestamp: number;
5
+ }>, kind: string | undefined, timestamp: number): void;
2
6
  export interface SessionEditEntry {
3
7
  /** Relative file path (relative to worktree root). */
4
8
  filePath: string;
@@ -1,6 +1,10 @@
1
1
  import * as crypto from "node:crypto";
2
2
  import * as fs from "node:fs";
3
3
  import * as path from "node:path";
4
+ export function pushEventHintIfKind(hints, kind, timestamp) {
5
+ if (kind)
6
+ hints.push({ kind, timestamp });
7
+ }
4
8
  // ---------------------------------------------------------------------------
5
9
  // Constants
6
10
  // ---------------------------------------------------------------------------
@@ -162,9 +166,7 @@ export function createSessionEditState(opts) {
162
166
  const abs = resolveToAbsolute(rel);
163
167
  entry.currentHash = hashFile(abs);
164
168
  entry.lastReconciledAt = timestamp ?? now();
165
- if (kind) {
166
- entry.eventHints.push({ kind, timestamp: timestamp ?? now() });
167
- }
169
+ pushEventHintIfKind(entry.eventHints, kind, timestamp ?? now());
168
170
  }
169
171
  return {
170
172
  recordEventHint,
@@ -0,0 +1,100 @@
1
+ import type { CommentAnalysisResult } from "./comment-analysis.js";
2
+ import type { FileOperationState } from "./file-operation-state.js";
3
+ import type { CacheKey } from "./guidance-cache.js";
4
+ import type { KbFreshnessScope } from "./kb-freshness-state.js";
5
+ import type { PathKind } from "./path-kind.js";
6
+ import type { RuntimeDegradedOverlay } from "./plugin-startup.js";
7
+ import type { RiskClass } from "./risk-classifier.js";
8
+ import type { SyncScheduler } from "./scheduler.js";
9
+ import type { SessionEditState } from "./session-edit-state.js";
10
+ import type { WarningCategory } from "./session-tracker.js";
11
+ import type { WorkContext } from "./work-context-resolver.js";
12
+ export interface PluginEventInput {
13
+ sessionId?: string;
14
+ agentIdentity?: string;
15
+ worktree: string;
16
+ directory: string;
17
+ }
18
+ export interface PluginEvent {
19
+ type: string;
20
+ properties?: Record<string, unknown>;
21
+ }
22
+ /** Mutable cross-event state shared by the event and chat hooks. */
23
+ export interface SmartEnforcementState {
24
+ recentEdits: Array<{
25
+ path: string;
26
+ kind: PathKind;
27
+ timestamp: number;
28
+ }>;
29
+ hasRecentKbEdit: boolean;
30
+ recentCommentSuggestion: CommentAnalysisResult | null;
31
+ seenFingerprints: Set<string>;
32
+ lastRiskClass: RiskClass | null;
33
+ lastRiskFilePath: string | null;
34
+ lastRiskScopeKey: string | null;
35
+ }
36
+ export interface KibiEventEnv {
37
+ input: PluginEventInput;
38
+ cfg: {
39
+ sync: {
40
+ enabled: boolean;
41
+ };
42
+ guidance: {
43
+ warnOnKbEdits: boolean;
44
+ commentDetection: {
45
+ enabled: boolean;
46
+ minLines: number;
47
+ };
48
+ targetedChecks: {
49
+ enabled: boolean;
50
+ };
51
+ smartEnforcement: {
52
+ degradedMode: string;
53
+ };
54
+ };
55
+ };
56
+ cache: {
57
+ isSatisfied(key: CacheKey): boolean;
58
+ };
59
+ freshnessStore: {
60
+ recordToolEvidence(scope: KbFreshnessScope, tool: string): void;
61
+ };
62
+ fileFilter: {
63
+ shouldHandleFile(filePath: string, root: string): boolean;
64
+ };
65
+ log: {
66
+ info(...args: unknown[]): void;
67
+ warn(...args: unknown[]): void;
68
+ };
69
+ rootWorkContext: WorkContext;
70
+ state: SmartEnforcementState;
71
+ resolveScopedWorkContext(filePath?: string): WorkContext;
72
+ getSessionEditState(context: WorkContext): SessionEditState;
73
+ getFileOperationState(context: WorkContext): FileOperationState;
74
+ getPathKindCache(context: WorkContext): Map<string, PathKind>;
75
+ getSchedulerForContext(context: WorkContext): SyncScheduler | null;
76
+ normalizeSessionPath(filePath: string, worktree?: string): string;
77
+ resolveWorktreePath(filePath: string, worktree?: string): string;
78
+ buildRiskPathScopeKey(context: WorkContext, filePath: string): string;
79
+ buildScopedCacheKey(context: WorkContext, riskClass: RiskClass, fileBucket: string, dirtyRelevantInputs: Iterable<string | null | undefined>): CacheKey;
80
+ readFileContent(filePath: string, worktree?: string): string;
81
+ updateRecentEditsFromSession(sessionEdits: Array<{
82
+ filePath: string;
83
+ lastReconciledAt: number;
84
+ }>, scopedPathKindCache: Map<string, PathKind>): SmartEnforcementState["recentEdits"];
85
+ recordWarning(category: WarningCategory | string, filePath: string, message: string): void;
86
+ getMaintenanceDegraded(): boolean;
87
+ getEffectiveMode(): string;
88
+ posture: {
89
+ maintenanceDegraded: boolean;
90
+ state: string;
91
+ };
92
+ runtimeOverlay: RuntimeDegradedOverlay;
93
+ lintRequirementDoc(filePath: string, worktreeRoot: string): Array<{
94
+ category: string;
95
+ message: string;
96
+ }>;
97
+ }
98
+ export declare function isKbToolEventType(eventType: string): boolean;
99
+ export declare function handleKbToolEvent(env: KibiEventEnv, event: PluginEvent, sessionId: string | undefined): void;
100
+ export declare function handleFileLifecycleEvent(env: KibiEventEnv, event: PluginEvent): void;