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/README.md +7 -6
- package/dist/brief-delivery-reasons.d.ts +12 -0
- package/dist/brief-delivery-reasons.js +132 -0
- package/dist/brief-intent.js +17 -2
- package/dist/idle-brief-reader.d.ts +12 -0
- package/dist/idle-brief-reader.js +31 -10
- package/dist/idle-brief-runtime.js +44 -9
- package/dist/idle-brief-store.d.ts +17 -0
- package/dist/idle-brief-store.js +54 -1
- package/dist/index.d.ts +2 -52
- package/dist/index.js +1 -1068
- package/dist/logger.d.ts +1 -0
- package/dist/logger.js +8 -1
- package/dist/plugin.d.ts +52 -0
- package/dist/plugin.js +1068 -0
- package/dist/prompt.js +3 -3
- package/dist/scheduler.d.ts +12 -2
- package/dist/scheduler.js +49 -6
- package/dist/toast.d.ts +2 -0
- package/dist/tui-brief-delivery.d.ts +20 -0
- package/dist/tui-brief-delivery.js +154 -13
- package/dist/tui-brief-view-model.d.ts +63 -0
- package/dist/tui-brief-view-model.js +209 -0
- package/dist/tui.d.ts +8 -0
- package/dist/tui.js +413 -0
- package/dist/tui.jsx +120 -0
- package/package.json +12 -4
package/dist/plugin.js
ADDED
|
@@ -0,0 +1,1068 @@
|
|
|
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, 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 { announceBriefTui, } 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.executeCommand === "function") {
|
|
98
|
+
mappedTui.executeCommand = tui.executeCommand.bind(tui);
|
|
99
|
+
}
|
|
100
|
+
if (typeof tui.clearPrompt === "function") {
|
|
101
|
+
mappedTui.clearPrompt = tui.clearPrompt.bind(tui);
|
|
102
|
+
}
|
|
103
|
+
if (typeof tui.submitPrompt === "function") {
|
|
104
|
+
mappedTui.submitPrompt = tui.submitPrompt.bind(tui);
|
|
105
|
+
}
|
|
106
|
+
return { tui: mappedTui };
|
|
107
|
+
};
|
|
108
|
+
const makeStartupClient = (client) => ({
|
|
109
|
+
...makeToastClient(client),
|
|
110
|
+
app: client.app,
|
|
111
|
+
});
|
|
112
|
+
const startup = await runPluginStartup(input);
|
|
113
|
+
if (!startup) {
|
|
114
|
+
return {};
|
|
115
|
+
}
|
|
116
|
+
const { cfg, workspaceHealth, posture, currentBranch, cache, runtimeOverlay, scheduler, maintenanceDegraded, getMaintenanceDegraded, getEffectiveMode, latchRuntimeDegraded, } = startup;
|
|
117
|
+
const hooks = {};
|
|
118
|
+
const initKibiCommandCapability = getInitKibiCommandCapability();
|
|
119
|
+
if (initKibiCommandCapability.supported) {
|
|
120
|
+
hooks.config = async (configInput) => {
|
|
121
|
+
registerInitKibiCommand(configInput, initKibiCommandCapability);
|
|
122
|
+
};
|
|
123
|
+
}
|
|
124
|
+
// Plugin instance state (not module globals)
|
|
125
|
+
const MAX_RECENT_EDITS = 5;
|
|
126
|
+
let recentEdits = [];
|
|
127
|
+
let hasRecentKbEdit = false;
|
|
128
|
+
let recentCommentSuggestion = null;
|
|
129
|
+
const seenFingerprints = new Set(); // For deduplication
|
|
130
|
+
// NOTE: autoBriefResults is ONLY for prompt-time auto-brief guidance (file.edited flow).
|
|
131
|
+
// Idle-brief runtime (session.idle flow) writes directly to .kb/briefs/ via generateIdleBrief()
|
|
132
|
+
// and MUST NEVER store results in this map or leak into prompt guidance.
|
|
133
|
+
const autoBriefResults = new Map();
|
|
134
|
+
const toastedFingerprints = new Set();
|
|
135
|
+
let lastRiskClass = null;
|
|
136
|
+
let lastRiskFilePath = null;
|
|
137
|
+
const sessionEditState = createSessionEditState({ worktree: input.worktree });
|
|
138
|
+
const fileOperationState = createFileOperationState({
|
|
139
|
+
worktree: input.worktree,
|
|
140
|
+
}); // implements REQ-opencode-file-context-guidance-v1
|
|
141
|
+
let degradedWarnedOnce = false;
|
|
142
|
+
const pathKindCache = new Map();
|
|
143
|
+
// Idle-brief state — dedupe via semantic contentHash (persisted envelope is the delivery authority)
|
|
144
|
+
let idleBriefInFlight = false;
|
|
145
|
+
let idleBriefTrailingRerun = false;
|
|
146
|
+
let idleBriefTimer = null;
|
|
147
|
+
const idleBriefDeliveredHashes = new Set();
|
|
148
|
+
// Session-scoped flag: at most one idle-brief.sync-suppressed breadcrumb per session
|
|
149
|
+
let idleSyncSuppressedOnce = false;
|
|
150
|
+
const replayedBriefContentHashes = new Set();
|
|
151
|
+
// Session-local baseline cursor: captured once per session/worktree/branch from the audit-log tail,
|
|
152
|
+
// so the first idle brief in a fresh session only reports post-baseline changes.
|
|
153
|
+
let sessionBaselineCursor = null;
|
|
154
|
+
let sessionBaselineFingerprint = null;
|
|
155
|
+
function syncSessionBaseline(branch) {
|
|
156
|
+
const nextState = syncSessionBaselineState({
|
|
157
|
+
fingerprint: sessionBaselineFingerprint,
|
|
158
|
+
cursor: sessionBaselineCursor,
|
|
159
|
+
}, {
|
|
160
|
+
sessionId: input.sessionId,
|
|
161
|
+
branch,
|
|
162
|
+
worktree: input.worktree,
|
|
163
|
+
}, () => getAuditTailCursor(input.worktree, branch));
|
|
164
|
+
sessionBaselineFingerprint = nextState.fingerprint;
|
|
165
|
+
sessionBaselineCursor = nextState.cursor;
|
|
166
|
+
}
|
|
167
|
+
syncSessionBaseline(currentBranch);
|
|
168
|
+
function normalizeSessionPath(filePath) {
|
|
169
|
+
if (path.isAbsolute(filePath)) {
|
|
170
|
+
const relativePath = path.relative(input.worktree, filePath);
|
|
171
|
+
return relativePath.startsWith("..") ? filePath : relativePath;
|
|
172
|
+
}
|
|
173
|
+
return filePath;
|
|
174
|
+
}
|
|
175
|
+
function resolveWorktreePath(filePath) {
|
|
176
|
+
return input.worktree && !path.isAbsolute(filePath)
|
|
177
|
+
? path.join(input.worktree, filePath)
|
|
178
|
+
: filePath;
|
|
179
|
+
}
|
|
180
|
+
function getKbSnapshotFingerprint(worktree, branch) {
|
|
181
|
+
try {
|
|
182
|
+
const snapshotPath = path.join(worktree, ".kb", "branches", branch, "kb.rdf");
|
|
183
|
+
const stat = fs.statSync(snapshotPath);
|
|
184
|
+
return `${stat.size}:${stat.mtimeMs}`;
|
|
185
|
+
}
|
|
186
|
+
catch {
|
|
187
|
+
return "missing";
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
function buildSyntheticSyncAuditDelta(baseDelta, sourceFiles) {
|
|
191
|
+
const timestamp = new Date().toISOString();
|
|
192
|
+
const fileSource = sourceFiles[0] ?? "workspace-sync";
|
|
193
|
+
const entityId = path.basename(fileSource).replace(/\.md$/, "") || "workspace-sync";
|
|
194
|
+
return {
|
|
195
|
+
...baseDelta,
|
|
196
|
+
hasChanges: true,
|
|
197
|
+
entries: [
|
|
198
|
+
{
|
|
199
|
+
timestamp,
|
|
200
|
+
operation: "upsert",
|
|
201
|
+
entityId,
|
|
202
|
+
payload: {
|
|
203
|
+
kind: "entity",
|
|
204
|
+
entityType: "fact",
|
|
205
|
+
changeKind: "updated",
|
|
206
|
+
title: entityId,
|
|
207
|
+
source: fileSource,
|
|
208
|
+
properties: {
|
|
209
|
+
id: entityId,
|
|
210
|
+
title: entityId,
|
|
211
|
+
source: fileSource,
|
|
212
|
+
},
|
|
213
|
+
},
|
|
214
|
+
},
|
|
215
|
+
],
|
|
216
|
+
};
|
|
217
|
+
}
|
|
218
|
+
function getTransformFocusFilePath(transformInput) {
|
|
219
|
+
if (!transformInput || typeof transformInput !== "object") {
|
|
220
|
+
return null;
|
|
221
|
+
}
|
|
222
|
+
const inputRecord = transformInput;
|
|
223
|
+
const directPath = inputRecord.focusFilePath ??
|
|
224
|
+
inputRecord.filePath ??
|
|
225
|
+
inputRecord.path ??
|
|
226
|
+
inputRecord.file ??
|
|
227
|
+
inputRecord.focusEdit?.path ??
|
|
228
|
+
inputRecord.focusEdit?.filePath;
|
|
229
|
+
if (typeof directPath !== "string" || directPath.length === 0) {
|
|
230
|
+
return null;
|
|
231
|
+
}
|
|
232
|
+
return normalizeSessionPath(directPath);
|
|
233
|
+
}
|
|
234
|
+
function readFileContent(filePath) {
|
|
235
|
+
try {
|
|
236
|
+
return fs.readFileSync(resolveWorktreePath(filePath), "utf-8");
|
|
237
|
+
}
|
|
238
|
+
catch {
|
|
239
|
+
return "";
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
function updateRecentEditsFromSession(sessionEdits) {
|
|
243
|
+
recentEdits = sessionEdits.slice(-MAX_RECENT_EDITS).map((entry) => ({
|
|
244
|
+
path: entry.filePath,
|
|
245
|
+
kind: pathKindCache.get(entry.filePath) ?? "unknown",
|
|
246
|
+
timestamp: entry.lastReconciledAt,
|
|
247
|
+
}));
|
|
248
|
+
return recentEdits;
|
|
249
|
+
}
|
|
250
|
+
function deriveRiskContext(filePath) {
|
|
251
|
+
const normalizedFilePath = normalizeSessionPath(filePath);
|
|
252
|
+
const pathAnalysis = analyzePath(normalizedFilePath, input.worktree);
|
|
253
|
+
pathKindCache.set(normalizedFilePath, pathAnalysis.kind);
|
|
254
|
+
const fileContent = readFileContent(normalizedFilePath);
|
|
255
|
+
const hasMustPriority = pathAnalysis.kind === "requirement"
|
|
256
|
+
? isMustPriorityRequirement(normalizedFilePath, input.worktree)
|
|
257
|
+
: false;
|
|
258
|
+
let precomputedSuggestion = null;
|
|
259
|
+
if (pathAnalysis.kind === "code" && cfg.guidance.commentDetection.enabled) {
|
|
260
|
+
precomputedSuggestion = analyzeCodeFile(resolveWorktreePath(normalizedFilePath), {
|
|
261
|
+
minLines: cfg.guidance.commentDetection.minLines,
|
|
262
|
+
});
|
|
263
|
+
}
|
|
264
|
+
const { riskClass } = classifyRisk({
|
|
265
|
+
pathKind: pathAnalysis.kind,
|
|
266
|
+
isUnderKb: pathAnalysis.isUnderKb,
|
|
267
|
+
hasMustPriority,
|
|
268
|
+
hasDurableComment: !!precomputedSuggestion,
|
|
269
|
+
fileContent,
|
|
270
|
+
});
|
|
271
|
+
const effectiveRiskClass = riskClass === "safe_docs_only" && precomputedSuggestion
|
|
272
|
+
? "traceability_candidate"
|
|
273
|
+
: riskClass;
|
|
274
|
+
recentCommentSuggestion =
|
|
275
|
+
pathAnalysis.kind === "code" ? precomputedSuggestion : null;
|
|
276
|
+
lastRiskClass = effectiveRiskClass;
|
|
277
|
+
lastRiskFilePath = normalizedFilePath;
|
|
278
|
+
return {
|
|
279
|
+
effectiveRiskClass,
|
|
280
|
+
pathAnalysis,
|
|
281
|
+
hasMustPriority,
|
|
282
|
+
precomputedSuggestion,
|
|
283
|
+
};
|
|
284
|
+
}
|
|
285
|
+
function buildBriefingWorkspaceContext() {
|
|
286
|
+
return {
|
|
287
|
+
workspaceRoot: input.worktree,
|
|
288
|
+
branch: currentBranch,
|
|
289
|
+
directory: input.directory,
|
|
290
|
+
...(input.workspace !== undefined ? { workspace: input.workspace } : {}),
|
|
291
|
+
};
|
|
292
|
+
}
|
|
293
|
+
function buildWorkspaceContextForBranch(branch) {
|
|
294
|
+
return {
|
|
295
|
+
...buildBriefingWorkspaceContext(),
|
|
296
|
+
branch,
|
|
297
|
+
};
|
|
298
|
+
}
|
|
299
|
+
function queueBriefingFetch(intentResult, options = {}) {
|
|
300
|
+
if (!intentResult.eligible ||
|
|
301
|
+
!input.client ||
|
|
302
|
+
getMaintenanceDegraded() ||
|
|
303
|
+
(posture.state !== "root_active" &&
|
|
304
|
+
posture.state !== "hybrid_root_plus_vendored")) {
|
|
305
|
+
return;
|
|
306
|
+
}
|
|
307
|
+
if (options.skipIfCachedResultExists === true &&
|
|
308
|
+
autoBriefResults.has(intentResult.fingerprint)) {
|
|
309
|
+
return;
|
|
310
|
+
}
|
|
311
|
+
const client = input.client;
|
|
312
|
+
const fingerprint = intentResult.fingerprint;
|
|
313
|
+
const workspaceCtx = buildBriefingWorkspaceContext();
|
|
314
|
+
void fetchBriefingResult(client, workspaceCtx, intentResult).then((result) => {
|
|
315
|
+
autoBriefResults.set(fingerprint, result);
|
|
316
|
+
if (!toastedFingerprints.has(fingerprint)) {
|
|
317
|
+
toastedFingerprints.add(fingerprint);
|
|
318
|
+
void sendToast(makeToastClient(client), {
|
|
319
|
+
message: result.toastMessage,
|
|
320
|
+
});
|
|
321
|
+
}
|
|
322
|
+
});
|
|
323
|
+
}
|
|
324
|
+
hooks.event = async ({ event }) => {
|
|
325
|
+
const activeBranch = resolveCurrentBranch(input.worktree);
|
|
326
|
+
syncSessionBaseline(activeBranch);
|
|
327
|
+
// Handle session.idle for idle-brief generation. OpenCode can emit idle
|
|
328
|
+
// while an assistant is between tool calls, so debounce until the work
|
|
329
|
+
// burst settles before generating/delivering a brief.
|
|
330
|
+
if (event.type === "session.idle") {
|
|
331
|
+
if (!input.client)
|
|
332
|
+
return;
|
|
333
|
+
const idleBranch = activeBranch;
|
|
334
|
+
const idleWorkspaceRoot = input.worktree;
|
|
335
|
+
const runIdleBrief = async () => {
|
|
336
|
+
if (idleBriefInFlight) {
|
|
337
|
+
idleBriefTrailingRerun = true;
|
|
338
|
+
return;
|
|
339
|
+
}
|
|
340
|
+
idleBriefInFlight = true;
|
|
341
|
+
idleBriefTrailingRerun = false;
|
|
342
|
+
try {
|
|
343
|
+
// Gather session edits
|
|
344
|
+
const sessionEdits = sessionEditState.getSessionEdits();
|
|
345
|
+
const sourceFiles = sessionEdits.map((e) => e.filePath);
|
|
346
|
+
const snapshotBeforeSync = getKbSnapshotFingerprint(idleWorkspaceRoot, idleBranch);
|
|
347
|
+
if (scheduler) {
|
|
348
|
+
const idleSyncBlocked = runtimeOverlay.primaryCause === "scheduler_sync_failed";
|
|
349
|
+
if (!idleSyncBlocked) {
|
|
350
|
+
scheduler.scheduleSync("session.idle");
|
|
351
|
+
await scheduler.flush();
|
|
352
|
+
}
|
|
353
|
+
else if (!idleSyncSuppressedOnce) {
|
|
354
|
+
idleSyncSuppressedOnce = true;
|
|
355
|
+
logger.info("idle-brief.sync-suppressed", {
|
|
356
|
+
event: "idle_brief_sync_suppressed",
|
|
357
|
+
primaryCause: runtimeOverlay.primaryCause,
|
|
358
|
+
});
|
|
359
|
+
}
|
|
360
|
+
}
|
|
361
|
+
const snapshotAfterSync = getKbSnapshotFingerprint(idleWorkspaceRoot, idleBranch);
|
|
362
|
+
const rawAuditDelta = computeAuditDelta(idleWorkspaceRoot, idleBranch, sessionBaselineCursor);
|
|
363
|
+
const auditDelta = rawAuditDelta.hasChanges || snapshotBeforeSync === snapshotAfterSync
|
|
364
|
+
? rawAuditDelta
|
|
365
|
+
: buildSyntheticSyncAuditDelta(rawAuditDelta, sourceFiles);
|
|
366
|
+
if (!auditDelta.hasChanges)
|
|
367
|
+
return;
|
|
368
|
+
// Branch switch guard
|
|
369
|
+
const currentBranchNow = resolveCurrentBranch(input.worktree);
|
|
370
|
+
if (guardBranchChanged(idleBranch, currentBranchNow)) {
|
|
371
|
+
logger.info("idle-brief.branch-changed", {
|
|
372
|
+
event: "idle_brief_branch_changed",
|
|
373
|
+
idleBranch,
|
|
374
|
+
currentBranch: currentBranchNow,
|
|
375
|
+
});
|
|
376
|
+
return;
|
|
377
|
+
}
|
|
378
|
+
// Generate brief
|
|
379
|
+
const workspaceCtx = buildWorkspaceContextForBranch(idleBranch);
|
|
380
|
+
const client = input.client;
|
|
381
|
+
if (!client)
|
|
382
|
+
return;
|
|
383
|
+
const reconciled = reconcileAuditEntries(auditDelta.entries);
|
|
384
|
+
const changedEntityIds = [
|
|
385
|
+
...reconciled.added.map((e) => e.id),
|
|
386
|
+
...reconciled.modified.map((e) => e.id),
|
|
387
|
+
...reconciled.removed.map((e) => e.id),
|
|
388
|
+
];
|
|
389
|
+
const result = await generateIdleBrief(input.client, workspaceCtx, auditDelta, input.sessionId ?? "unknown", sourceFiles.length > 0
|
|
390
|
+
? { sourceFiles, changedEntityIds }
|
|
391
|
+
: { changedEntityIds });
|
|
392
|
+
if (result.success && result.envelope) {
|
|
393
|
+
const envelope = result.envelope;
|
|
394
|
+
// Dedupe by semantic contentHash — persisted envelope is the delivery authority
|
|
395
|
+
const dedupeKey = `${idleWorkspaceRoot}:${idleBranch}:tui:${envelope.contentHash}`;
|
|
396
|
+
if (!idleBriefDeliveredHashes.has(dedupeKey)) {
|
|
397
|
+
idleBriefDeliveredHashes.add(dedupeKey);
|
|
398
|
+
const sharedPolicy = { briefs: loadBriefConfig(input.worktree) };
|
|
399
|
+
if (client) {
|
|
400
|
+
try {
|
|
401
|
+
const announcementResult = await announceBriefTui(makeToastClient(client), envelope, sharedPolicy);
|
|
402
|
+
if (announcementResult.toastDelivered ||
|
|
403
|
+
announcementResult.commandPublished) {
|
|
404
|
+
replayedBriefContentHashes.add(envelope.contentHash);
|
|
405
|
+
}
|
|
406
|
+
}
|
|
407
|
+
catch (err) {
|
|
408
|
+
logger.error("idle-brief.delivery-failed", {
|
|
409
|
+
event: "idle_brief_delivery_failed",
|
|
410
|
+
error: err instanceof Error ? err.message : String(err),
|
|
411
|
+
});
|
|
412
|
+
}
|
|
413
|
+
}
|
|
414
|
+
}
|
|
415
|
+
}
|
|
416
|
+
else {
|
|
417
|
+
logger.info("idle-brief.no-brief-generated", {
|
|
418
|
+
event: "idle_brief_no_brief_generated",
|
|
419
|
+
success: result.success,
|
|
420
|
+
hasEnvelope: !!result.envelope,
|
|
421
|
+
});
|
|
422
|
+
}
|
|
423
|
+
}
|
|
424
|
+
catch (error) {
|
|
425
|
+
logger.error("idle-brief.error", {
|
|
426
|
+
event: "idle_brief_error",
|
|
427
|
+
error: error instanceof Error ? error.message : String(error),
|
|
428
|
+
});
|
|
429
|
+
}
|
|
430
|
+
finally {
|
|
431
|
+
idleBriefInFlight = false;
|
|
432
|
+
// If trailing rerun was requested, run again
|
|
433
|
+
if (idleBriefTrailingRerun) {
|
|
434
|
+
idleBriefTrailingRerun = false;
|
|
435
|
+
void runIdleBrief();
|
|
436
|
+
}
|
|
437
|
+
}
|
|
438
|
+
};
|
|
439
|
+
if (idleBriefTimer) {
|
|
440
|
+
clearTimeout(idleBriefTimer);
|
|
441
|
+
}
|
|
442
|
+
idleBriefTimer = setTimeout(() => {
|
|
443
|
+
idleBriefTimer = null;
|
|
444
|
+
void runIdleBrief();
|
|
445
|
+
}, resolveIdleBriefDeliveryDelayMs(idleWorkspaceRoot));
|
|
446
|
+
return;
|
|
447
|
+
}
|
|
448
|
+
// Accept file.created, file.edited, and file.deleted lifecycle events
|
|
449
|
+
const isFileLifecycle = event.type === "file.created" ||
|
|
450
|
+
event.type === "file.edited" ||
|
|
451
|
+
event.type === "file.deleted";
|
|
452
|
+
if (!isFileLifecycle)
|
|
453
|
+
return;
|
|
454
|
+
if (idleBriefTimer) {
|
|
455
|
+
clearTimeout(idleBriefTimer);
|
|
456
|
+
idleBriefTimer = null;
|
|
457
|
+
}
|
|
458
|
+
const filePath = event
|
|
459
|
+
.properties.file;
|
|
460
|
+
if (!filePath)
|
|
461
|
+
return;
|
|
462
|
+
// Record lifecycle event into file-operation-state // implements REQ-opencode-file-context-guidance-v1
|
|
463
|
+
const lifecycle = event.type === "file.created"
|
|
464
|
+
? "created"
|
|
465
|
+
: event.type === "file.deleted"
|
|
466
|
+
? "deleted"
|
|
467
|
+
: "edited";
|
|
468
|
+
fileOperationState.recordLifecycle(filePath, lifecycle, Date.now());
|
|
469
|
+
fileOperationState.normalizePath(filePath);
|
|
470
|
+
const pathAnalysis = analyzePath(filePath, input.worktree);
|
|
471
|
+
// For file.deleted: derive path kind without reading content, classify for reminder routing only
|
|
472
|
+
if (lifecycle === "deleted") {
|
|
473
|
+
// Preserve last known semantic risk if path was already tracked during session
|
|
474
|
+
const lastKnownKind = pathKindCache.get(filePath);
|
|
475
|
+
if (lastKnownKind) {
|
|
476
|
+
// Path was tracked — preserve last known semantic risk for reminder routing
|
|
477
|
+
pathKindCache.set(filePath, pathAnalysis.kind);
|
|
478
|
+
}
|
|
479
|
+
else {
|
|
480
|
+
// Not tracked — classify only for reminder routing, not auto-briefing
|
|
481
|
+
pathKindCache.set(filePath, pathAnalysis.kind);
|
|
482
|
+
}
|
|
483
|
+
sessionEditState.recordEventHint(filePath, pathAnalysis.kind, Date.now());
|
|
484
|
+
sessionEditState.reconcilePath(filePath);
|
|
485
|
+
const sessionEdits = sessionEditState.getSessionEdits();
|
|
486
|
+
recentEdits = sessionEdits.slice(-MAX_RECENT_EDITS).map((e) => ({
|
|
487
|
+
path: e.filePath,
|
|
488
|
+
kind: pathKindCache.get(e.filePath) ?? "unknown",
|
|
489
|
+
timestamp: e.lastReconciledAt,
|
|
490
|
+
}));
|
|
491
|
+
// Schedule background sync for deleted files that pass shouldHandleFile // implements REQ-opencode-file-context-guidance-v1
|
|
492
|
+
if (cfg.sync.enabled &&
|
|
493
|
+
scheduler &&
|
|
494
|
+
fileFilter.shouldHandleFile(filePath, input.worktree)) {
|
|
495
|
+
scheduler.scheduleSync("file.deleted", filePath);
|
|
496
|
+
}
|
|
497
|
+
return;
|
|
498
|
+
}
|
|
499
|
+
sessionEditState.recordEventHint(filePath, pathAnalysis.kind, Date.now());
|
|
500
|
+
sessionEditState.reconcilePath(filePath);
|
|
501
|
+
pathKindCache.set(filePath, pathAnalysis.kind);
|
|
502
|
+
const sessionEdits = sessionEditState.getSessionEdits();
|
|
503
|
+
const focusEdit = sessionEditState.getFocusEdit();
|
|
504
|
+
// Schedule background sync for file.created/file.edited that pass shouldHandleFile // implements REQ-opencode-file-context-guidance-v1
|
|
505
|
+
if (cfg.sync.enabled &&
|
|
506
|
+
scheduler &&
|
|
507
|
+
fileFilter.shouldHandleFile(filePath, input.worktree)) {
|
|
508
|
+
scheduler.scheduleSync(lifecycle === "created" ? "file.created" : "file.edited", filePath);
|
|
509
|
+
}
|
|
510
|
+
let fileContent = "";
|
|
511
|
+
try {
|
|
512
|
+
const resolvedPath = input.worktree && !path.isAbsolute(filePath)
|
|
513
|
+
? path.join(input.worktree, filePath)
|
|
514
|
+
: filePath;
|
|
515
|
+
fileContent = fs.readFileSync(resolvedPath, "utf-8");
|
|
516
|
+
}
|
|
517
|
+
catch { }
|
|
518
|
+
const hasMustPriority = pathAnalysis.kind === "requirement"
|
|
519
|
+
? isMustPriorityRequirement(filePath, input.worktree)
|
|
520
|
+
: false;
|
|
521
|
+
let precomputedSuggestion = null;
|
|
522
|
+
if (pathAnalysis.kind === "code" && cfg.guidance.commentDetection.enabled) {
|
|
523
|
+
const resolvedPath = input.worktree && !path.isAbsolute(filePath)
|
|
524
|
+
? path.join(input.worktree, filePath)
|
|
525
|
+
: filePath;
|
|
526
|
+
precomputedSuggestion = analyzeCodeFile(resolvedPath, {
|
|
527
|
+
minLines: cfg.guidance.commentDetection.minLines,
|
|
528
|
+
});
|
|
529
|
+
}
|
|
530
|
+
const { riskClass } = classifyRisk({
|
|
531
|
+
pathKind: pathAnalysis.kind,
|
|
532
|
+
isUnderKb: pathAnalysis.isUnderKb,
|
|
533
|
+
hasMustPriority,
|
|
534
|
+
hasDurableComment: !!precomputedSuggestion,
|
|
535
|
+
fileContent,
|
|
536
|
+
});
|
|
537
|
+
const effectiveRiskClass = riskClass === "safe_docs_only" && precomputedSuggestion
|
|
538
|
+
? "traceability_candidate"
|
|
539
|
+
: riskClass;
|
|
540
|
+
const isAutoBriefRisk = effectiveRiskClass === "behavior_candidate" ||
|
|
541
|
+
effectiveRiskClass === "traceability_candidate";
|
|
542
|
+
lastRiskClass = effectiveRiskClass;
|
|
543
|
+
logger.info("smart-enforcement.risk", {
|
|
544
|
+
event: "smart_enforcement_risk",
|
|
545
|
+
file: filePath,
|
|
546
|
+
path_kind: pathAnalysis.kind,
|
|
547
|
+
risk_class: effectiveRiskClass,
|
|
548
|
+
posture_state: posture.state,
|
|
549
|
+
maintenance_state: getMaintenanceDegraded()
|
|
550
|
+
? "maintenance_degraded"
|
|
551
|
+
: "maintenance_available",
|
|
552
|
+
under_kb: pathAnalysis.isUnderKb,
|
|
553
|
+
has_must_priority: hasMustPriority,
|
|
554
|
+
posture: posture.state,
|
|
555
|
+
reason_code: effectiveRiskClass,
|
|
556
|
+
effective_mode: getEffectiveMode(),
|
|
557
|
+
static_degraded: posture.maintenanceDegraded,
|
|
558
|
+
runtime_degraded: runtimeOverlay.degraded,
|
|
559
|
+
merged_degraded: getMaintenanceDegraded(),
|
|
560
|
+
overlay_cause: runtimeOverlay.primaryCause ?? null,
|
|
561
|
+
});
|
|
562
|
+
const targetedChecksBlocked = getMaintenanceDegraded() ||
|
|
563
|
+
runtimeOverlay.primaryCause === "sync_disabled" ||
|
|
564
|
+
runtimeOverlay.primaryCause === "scheduler_unavailable" ||
|
|
565
|
+
runtimeOverlay.primaryCause === "scheduler_sync_failed" ||
|
|
566
|
+
runtimeOverlay.primaryCause === "scheduler_check_failed";
|
|
567
|
+
if (!targetedChecksBlocked &&
|
|
568
|
+
cfg.sync.enabled &&
|
|
569
|
+
scheduler &&
|
|
570
|
+
cfg.guidance.targetedChecks.enabled) {
|
|
571
|
+
const traceabilityRules = effectiveRiskClass === "traceability_candidate"
|
|
572
|
+
? ["symbol-traceability"]
|
|
573
|
+
: null;
|
|
574
|
+
const kbStructuralRules = effectiveRiskClass === "kb_doc_structural" &&
|
|
575
|
+
fileFilter.shouldHandleFile(filePath, input.worktree)
|
|
576
|
+
? [
|
|
577
|
+
"required-fields",
|
|
578
|
+
"no-dangling-refs",
|
|
579
|
+
...(pathAnalysis.kind === "fact" ? ["strict-fact-shape"] : []),
|
|
580
|
+
...(pathAnalysis.kind === "requirement"
|
|
581
|
+
? ["strict-req-fact-pairing"]
|
|
582
|
+
: []),
|
|
583
|
+
]
|
|
584
|
+
: null;
|
|
585
|
+
const checkRules = traceabilityRules ?? kbStructuralRules;
|
|
586
|
+
if (checkRules) {
|
|
587
|
+
logger.info("smart-enforcement.targeted-checks", {
|
|
588
|
+
event: "smart_enforcement_targeted_checks",
|
|
589
|
+
file: filePath,
|
|
590
|
+
risk_class: effectiveRiskClass,
|
|
591
|
+
posture: posture.state,
|
|
592
|
+
posture_state: posture.state,
|
|
593
|
+
guidance_action: "targeted_checks",
|
|
594
|
+
effective_mode: getEffectiveMode(),
|
|
595
|
+
rules: checkRules,
|
|
596
|
+
static_degraded: posture.maintenanceDegraded,
|
|
597
|
+
runtime_degraded: runtimeOverlay.degraded,
|
|
598
|
+
merged_degraded: getMaintenanceDegraded(),
|
|
599
|
+
overlay_cause: runtimeOverlay.primaryCause ?? null,
|
|
600
|
+
});
|
|
601
|
+
logger.info(`kibi-opencode: scheduling sync for ${filePath}`);
|
|
602
|
+
scheduler.scheduleSync(effectiveRiskClass === "traceability_candidate"
|
|
603
|
+
? "smart-enforcement.traceability"
|
|
604
|
+
: "smart-enforcement.kb-doc", filePath, checkRules);
|
|
605
|
+
}
|
|
606
|
+
}
|
|
607
|
+
recentEdits = sessionEdits.slice(-MAX_RECENT_EDITS).map((e) => ({
|
|
608
|
+
path: e.filePath,
|
|
609
|
+
kind: pathKindCache.get(e.filePath) ?? "unknown",
|
|
610
|
+
timestamp: e.lastReconciledAt,
|
|
611
|
+
}));
|
|
612
|
+
if (effectiveRiskClass === "safe_docs_only" ||
|
|
613
|
+
effectiveRiskClass === "safe_test_only") {
|
|
614
|
+
recentCommentSuggestion = null;
|
|
615
|
+
return;
|
|
616
|
+
}
|
|
617
|
+
const cacheKey = {
|
|
618
|
+
workspaceRoot: input.worktree,
|
|
619
|
+
branch: currentBranch,
|
|
620
|
+
posture: posture.state,
|
|
621
|
+
riskClass: effectiveRiskClass,
|
|
622
|
+
fileBucket: deriveFileBucket(pathAnalysis.kind),
|
|
623
|
+
};
|
|
624
|
+
// Always process manual_kb_edit before cache check — this is a critical safety signal
|
|
625
|
+
if (effectiveRiskClass === "manual_kb_edit") {
|
|
626
|
+
hasRecentKbEdit = true;
|
|
627
|
+
if (cfg.guidance.warnOnKbEdits) {
|
|
628
|
+
logger.warn(`kibi-opencode: .kb edit detected for ${filePath}`);
|
|
629
|
+
getSessionTracker().recordWarning("kb-edit", filePath, `Manual .kb edit: ${filePath}`);
|
|
630
|
+
}
|
|
631
|
+
return;
|
|
632
|
+
}
|
|
633
|
+
// Always emit requirement lint warnings before cache check — these are safety signals
|
|
634
|
+
if (effectiveRiskClass === "req_policy_candidate") {
|
|
635
|
+
const lintWarnings = lintRequirementDoc(filePath, input.worktree);
|
|
636
|
+
for (const warning of lintWarnings) {
|
|
637
|
+
getSessionTracker().recordWarning(warning.category, filePath, warning.message);
|
|
638
|
+
}
|
|
639
|
+
}
|
|
640
|
+
// Cache check: after critical signals have been emitted
|
|
641
|
+
if (cache.isSatisfied(cacheKey)) {
|
|
642
|
+
logger.info("smart-enforcement.cache", {
|
|
643
|
+
event: "smart_enforcement_cache",
|
|
644
|
+
cache_hit: true,
|
|
645
|
+
cache_state: "hit",
|
|
646
|
+
file: filePath,
|
|
647
|
+
risk_class: effectiveRiskClass,
|
|
648
|
+
posture: posture.state,
|
|
649
|
+
posture_state: posture.state,
|
|
650
|
+
});
|
|
651
|
+
if (!isAutoBriefRisk) {
|
|
652
|
+
return;
|
|
653
|
+
}
|
|
654
|
+
}
|
|
655
|
+
logger.info("smart-enforcement.cache", {
|
|
656
|
+
event: "smart_enforcement_cache",
|
|
657
|
+
cache_hit: false,
|
|
658
|
+
cache_state: "miss",
|
|
659
|
+
file: filePath,
|
|
660
|
+
risk_class: effectiveRiskClass,
|
|
661
|
+
posture: posture.state,
|
|
662
|
+
posture_state: posture.state,
|
|
663
|
+
});
|
|
664
|
+
if (effectiveRiskClass === "req_policy_candidate") {
|
|
665
|
+
if (getMaintenanceDegraded()) {
|
|
666
|
+
const logFn = cfg.guidance.smartEnforcement.degradedMode === "warn-once"
|
|
667
|
+
? logger.warn
|
|
668
|
+
: logger.info;
|
|
669
|
+
logFn("smart-enforcement.degraded", {
|
|
670
|
+
event: "smart_enforcement_degraded",
|
|
671
|
+
file: filePath,
|
|
672
|
+
risk_class: effectiveRiskClass,
|
|
673
|
+
posture: posture.state,
|
|
674
|
+
posture_state: posture.state,
|
|
675
|
+
maintenance_state: getMaintenanceDegraded()
|
|
676
|
+
? "maintenance_degraded"
|
|
677
|
+
: "maintenance_available",
|
|
678
|
+
reason: runtimeOverlay.primaryCause ?? "non_authoritative_posture",
|
|
679
|
+
reason_code: runtimeOverlay.primaryCause ?? "non_authoritative_posture",
|
|
680
|
+
static_degraded: posture.maintenanceDegraded,
|
|
681
|
+
runtime_degraded: runtimeOverlay.degraded,
|
|
682
|
+
merged_degraded: getMaintenanceDegraded(),
|
|
683
|
+
overlay_cause: runtimeOverlay.primaryCause ?? null,
|
|
684
|
+
effective_mode: getEffectiveMode(),
|
|
685
|
+
});
|
|
686
|
+
}
|
|
687
|
+
if (!getMaintenanceDegraded() &&
|
|
688
|
+
cfg.sync.enabled &&
|
|
689
|
+
scheduler &&
|
|
690
|
+
fileFilter.shouldHandleFile(filePath, input.worktree)) {
|
|
691
|
+
let checkRules;
|
|
692
|
+
if (cfg.guidance.targetedChecks.enabled) {
|
|
693
|
+
if (hasMustPriority && getEffectiveMode() === "strict") {
|
|
694
|
+
checkRules = [
|
|
695
|
+
"required-fields",
|
|
696
|
+
"no-dangling-refs",
|
|
697
|
+
"must-priority-coverage",
|
|
698
|
+
"strict-req-fact-pairing",
|
|
699
|
+
];
|
|
700
|
+
logger.info(`kibi-opencode: must-priority requirement detected, scheduling elevated checks for ${filePath}`);
|
|
701
|
+
}
|
|
702
|
+
else {
|
|
703
|
+
checkRules = [
|
|
704
|
+
"required-fields",
|
|
705
|
+
"no-dangling-refs",
|
|
706
|
+
"strict-req-fact-pairing",
|
|
707
|
+
];
|
|
708
|
+
}
|
|
709
|
+
}
|
|
710
|
+
logger.info("smart-enforcement.targeted-checks", {
|
|
711
|
+
event: "smart_enforcement_targeted_checks",
|
|
712
|
+
file: filePath,
|
|
713
|
+
risk_class: effectiveRiskClass,
|
|
714
|
+
posture: posture.state,
|
|
715
|
+
posture_state: posture.state,
|
|
716
|
+
guidance_action: "targeted_checks",
|
|
717
|
+
effective_mode: getEffectiveMode(),
|
|
718
|
+
rules: checkRules ?? [],
|
|
719
|
+
static_degraded: posture.maintenanceDegraded,
|
|
720
|
+
runtime_degraded: runtimeOverlay.degraded,
|
|
721
|
+
merged_degraded: getMaintenanceDegraded(),
|
|
722
|
+
overlay_cause: runtimeOverlay.primaryCause ?? null,
|
|
723
|
+
});
|
|
724
|
+
scheduler?.scheduleSync("file.edited", filePath, checkRules);
|
|
725
|
+
}
|
|
726
|
+
return;
|
|
727
|
+
}
|
|
728
|
+
if (effectiveRiskClass === "kb_doc_structural") {
|
|
729
|
+
if (getMaintenanceDegraded()) {
|
|
730
|
+
const logFn = cfg.guidance.smartEnforcement.degradedMode === "warn-once"
|
|
731
|
+
? logger.warn
|
|
732
|
+
: logger.info;
|
|
733
|
+
logFn("smart-enforcement.degraded", {
|
|
734
|
+
event: "smart_enforcement_degraded",
|
|
735
|
+
file: filePath,
|
|
736
|
+
risk_class: effectiveRiskClass,
|
|
737
|
+
posture: posture.state,
|
|
738
|
+
posture_state: posture.state,
|
|
739
|
+
maintenance_state: getMaintenanceDegraded()
|
|
740
|
+
? "maintenance_degraded"
|
|
741
|
+
: "maintenance_available",
|
|
742
|
+
reason: runtimeOverlay.primaryCause ?? "non_authoritative_posture",
|
|
743
|
+
reason_code: runtimeOverlay.primaryCause ?? "non_authoritative_posture",
|
|
744
|
+
static_degraded: posture.maintenanceDegraded,
|
|
745
|
+
runtime_degraded: runtimeOverlay.degraded,
|
|
746
|
+
merged_degraded: getMaintenanceDegraded(),
|
|
747
|
+
overlay_cause: runtimeOverlay.primaryCause ?? null,
|
|
748
|
+
effective_mode: getEffectiveMode(),
|
|
749
|
+
});
|
|
750
|
+
}
|
|
751
|
+
return;
|
|
752
|
+
}
|
|
753
|
+
if (isAutoBriefRisk) {
|
|
754
|
+
if (pathAnalysis.kind === "code" &&
|
|
755
|
+
cfg.guidance.commentDetection.enabled) {
|
|
756
|
+
const suggestion = precomputedSuggestion;
|
|
757
|
+
if (suggestion) {
|
|
758
|
+
recentCommentSuggestion = suggestion;
|
|
759
|
+
const dedupeKey = `${filePath}:${suggestion.suggestionType}:${suggestion.fingerprint}`;
|
|
760
|
+
if (!seenFingerprints.has(dedupeKey)) {
|
|
761
|
+
seenFingerprints.add(dedupeKey);
|
|
762
|
+
const warningCategory = suggestion.suggestionType === "fact"
|
|
763
|
+
? "long-comment-missed-fact"
|
|
764
|
+
: suggestion.suggestionType === "adr"
|
|
765
|
+
? "long-comment-missed-adr"
|
|
766
|
+
: "missing-traceability";
|
|
767
|
+
logger.warn(`kibi-opencode: detected durable ${suggestion.suggestionType} knowledge in ${filePath}`);
|
|
768
|
+
getSessionTracker().recordWarning(warningCategory, filePath, `Consider routing this ${suggestion.suggestionType} knowledge to Kibi instead of inline comments: ${suggestion.reasoning}`);
|
|
769
|
+
}
|
|
770
|
+
}
|
|
771
|
+
else {
|
|
772
|
+
recentCommentSuggestion = null;
|
|
773
|
+
}
|
|
774
|
+
}
|
|
775
|
+
else {
|
|
776
|
+
recentCommentSuggestion = null;
|
|
777
|
+
}
|
|
778
|
+
if (!focusEdit) {
|
|
779
|
+
// No surviving edits (all reverted to baseline) — skip auto-brief fetch
|
|
780
|
+
return;
|
|
781
|
+
}
|
|
782
|
+
const sessionSourceFiles = sessionEdits.map((e) => e.filePath);
|
|
783
|
+
const intentResult = computeBriefIntent({
|
|
784
|
+
riskClass: effectiveRiskClass,
|
|
785
|
+
posture: posture.state,
|
|
786
|
+
maintenanceDegraded: getMaintenanceDegraded(),
|
|
787
|
+
sourceFiles: sessionSourceFiles,
|
|
788
|
+
focusFilePath: focusEdit.filePath,
|
|
789
|
+
worktreeRoot: input.worktree,
|
|
790
|
+
branch: currentBranch,
|
|
791
|
+
});
|
|
792
|
+
queueBriefingFetch(intentResult);
|
|
793
|
+
}
|
|
794
|
+
return;
|
|
795
|
+
};
|
|
796
|
+
if (cfg.prompt.enabled) {
|
|
797
|
+
const hookMode = cfg.prompt.hookMode;
|
|
798
|
+
if (hookMode === "system-transform" || hookMode === "auto") {
|
|
799
|
+
hooks["experimental.chat.system.transform"] = async (transformInput, output) => {
|
|
800
|
+
// Skip if sentinel already present in any existing entry
|
|
801
|
+
if (output.system.some((entry) => entry.includes(SENTINEL))) {
|
|
802
|
+
return;
|
|
803
|
+
}
|
|
804
|
+
const maintenanceDegraded = getMaintenanceDegraded();
|
|
805
|
+
const showDegradedAdvisory = maintenanceDegraded &&
|
|
806
|
+
cfg.guidance.smartEnforcement.degradedMode === "warn-once" &&
|
|
807
|
+
!degradedWarnedOnce;
|
|
808
|
+
const transformFocusFilePath = getTransformFocusFilePath(transformInput);
|
|
809
|
+
sessionEditState.reconcileKnownPaths();
|
|
810
|
+
if (transformFocusFilePath) {
|
|
811
|
+
sessionEditState.forceEdit(transformFocusFilePath);
|
|
812
|
+
}
|
|
813
|
+
const transformSessionEdits = sessionEditState.getSessionEdits();
|
|
814
|
+
const transformFocusEdit = sessionEditState.getFocusEdit();
|
|
815
|
+
const transformRecentEdits = transformSessionEdits
|
|
816
|
+
.slice(-MAX_RECENT_EDITS)
|
|
817
|
+
.map((e) => ({
|
|
818
|
+
path: e.filePath,
|
|
819
|
+
kind: pathKindCache.get(e.filePath) ?? "unknown",
|
|
820
|
+
}));
|
|
821
|
+
const transformPromptFocusEdit = transformFocusEdit
|
|
822
|
+
? {
|
|
823
|
+
path: transformFocusEdit.filePath,
|
|
824
|
+
kind: pathKindCache.get(transformFocusEdit.filePath) ?? "unknown",
|
|
825
|
+
}
|
|
826
|
+
: null;
|
|
827
|
+
const riskContextFilePath = transformFocusEdit?.filePath ?? transformFocusFilePath;
|
|
828
|
+
let effectiveRiskClass = riskContextFilePath && lastRiskFilePath === riskContextFilePath
|
|
829
|
+
? lastRiskClass
|
|
830
|
+
: null;
|
|
831
|
+
if (riskContextFilePath &&
|
|
832
|
+
(lastRiskClass === null || lastRiskFilePath !== riskContextFilePath)) {
|
|
833
|
+
const riskCtx = deriveRiskContext(riskContextFilePath);
|
|
834
|
+
effectiveRiskClass = riskCtx.effectiveRiskClass;
|
|
835
|
+
if (!recentCommentSuggestion && riskCtx.precomputedSuggestion) {
|
|
836
|
+
recentCommentSuggestion = riskCtx.precomputedSuggestion;
|
|
837
|
+
}
|
|
838
|
+
}
|
|
839
|
+
if (effectiveRiskClass === null && lastRiskClass !== null) {
|
|
840
|
+
effectiveRiskClass = lastRiskClass;
|
|
841
|
+
}
|
|
842
|
+
const promptSourceFiles = transformSessionEdits.map((entry) => entry.filePath);
|
|
843
|
+
const promptFocusFilePath = transformFocusEdit?.filePath ?? transformFocusFilePath ?? undefined;
|
|
844
|
+
const intentResult = effectiveRiskClass
|
|
845
|
+
? computeBriefIntent({
|
|
846
|
+
riskClass: effectiveRiskClass,
|
|
847
|
+
posture: posture.state,
|
|
848
|
+
maintenanceDegraded,
|
|
849
|
+
sourceFiles: promptSourceFiles,
|
|
850
|
+
worktreeRoot: input.worktree,
|
|
851
|
+
branch: currentBranch,
|
|
852
|
+
...(promptFocusFilePath !== undefined
|
|
853
|
+
? {
|
|
854
|
+
focusFilePath: promptFocusFilePath,
|
|
855
|
+
}
|
|
856
|
+
: {}),
|
|
857
|
+
})
|
|
858
|
+
: null;
|
|
859
|
+
const autoBriefResult = intentResult
|
|
860
|
+
? autoBriefResults.get(intentResult.fingerprint)
|
|
861
|
+
: undefined;
|
|
862
|
+
const isAutoBriefRisk = effectiveRiskClass === "behavior_candidate" ||
|
|
863
|
+
effectiveRiskClass === "traceability_candidate";
|
|
864
|
+
if (!autoBriefResult && isAutoBriefRisk && intentResult) {
|
|
865
|
+
queueBriefingFetch(intentResult, { skipIfCachedResultExists: true });
|
|
866
|
+
}
|
|
867
|
+
// Replay latest unread idle brief if available // implements REQ-opencode-kibi-briefing-v4
|
|
868
|
+
if (input.worktree && currentBranch && input.client) {
|
|
869
|
+
const unreadBrief = selectLatestUnreadBrief(input.worktree, currentBranch);
|
|
870
|
+
if (unreadBrief &&
|
|
871
|
+
!replayedBriefContentHashes.has(unreadBrief.envelope.contentHash) &&
|
|
872
|
+
!hasTuiSeenBrief(input.worktree, currentBranch, unreadBrief.envelope.contentHash)) {
|
|
873
|
+
const sharedPolicy = { briefs: loadBriefConfig(input.worktree) };
|
|
874
|
+
const client = input.client;
|
|
875
|
+
try {
|
|
876
|
+
const announcementResult = await announceBriefTui(makeToastClient(client), unreadBrief.envelope, sharedPolicy);
|
|
877
|
+
if (announcementResult.toastDelivered ||
|
|
878
|
+
announcementResult.commandPublished) {
|
|
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;
|