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.
- package/README.md +36 -12
- package/dist/brief-intent.d.ts +15 -4
- package/dist/brief-intent.js +63 -25
- package/dist/briefing-runtime.js +2 -1
- package/dist/config.d.ts +3 -0
- package/dist/config.js +9 -0
- package/dist/e2e-coverage-signals.d.ts +6 -0
- package/dist/e2e-coverage-signals.js +186 -0
- package/dist/file-entity-links.d.ts +15 -0
- package/dist/file-entity-links.js +254 -0
- package/dist/file-operation-reminders.d.ts +24 -0
- package/dist/file-operation-reminders.js +55 -0
- package/dist/file-operation-state.d.ts +29 -0
- package/dist/file-operation-state.js +113 -0
- package/dist/idle-brief-audit.d.ts +36 -0
- package/dist/idle-brief-audit.js +186 -0
- package/dist/idle-brief-paths.d.ts +6 -0
- package/dist/idle-brief-paths.js +120 -0
- package/dist/idle-brief-reader.d.ts +25 -0
- package/dist/idle-brief-reader.js +142 -0
- package/dist/idle-brief-runtime.d.ts +48 -0
- package/dist/idle-brief-runtime.js +443 -0
- package/dist/idle-brief-store.d.ts +96 -0
- package/dist/idle-brief-store.js +209 -0
- package/dist/index.d.ts +14 -1
- package/dist/index.js +626 -50
- package/dist/init-kibi-alias.d.ts +14 -0
- package/dist/init-kibi-alias.js +38 -0
- package/dist/init-kibi-capability.d.ts +32 -0
- package/dist/init-kibi-capability.js +202 -0
- package/dist/logger.js +9 -3
- package/dist/plugin-startup.d.ts +1 -0
- package/dist/plugin-startup.js +11 -2
- package/dist/prompt.d.ts +15 -3
- package/dist/prompt.js +103 -33
- package/dist/reconcile-engine.d.ts +15 -0
- package/dist/reconcile-engine.js +112 -0
- package/dist/scheduler.d.ts +1 -0
- package/dist/scheduler.js +37 -1
- package/dist/session-edit-state.d.ts +25 -0
- package/dist/session-edit-state.js +177 -0
- package/dist/session-fingerprint.d.ts +11 -0
- package/dist/session-fingerprint.js +21 -0
- package/dist/source-linked-guidance.d.ts +1 -2
- package/dist/source-linked-guidance.js +5 -168
- package/dist/startup-notifier.js +42 -31
- package/dist/toast.d.ts +21 -22
- package/dist/toast.js +36 -14
- package/dist/tui-brief-delivery.d.ts +47 -0
- package/dist/tui-brief-delivery.js +138 -0
- package/package.json +4 -3
package/dist/prompt.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import * as path from "node:path";
|
|
2
2
|
import { isPluginEnabled } from "./config.js";
|
|
3
|
+
import { getInitKibiCommandCapability, } from "./init-kibi-capability.js";
|
|
3
4
|
import { getSourceLinkedRequirementIds } from "./source-linked-guidance.js";
|
|
4
5
|
const SENTINEL = "<!-- kibi-opencode -->";
|
|
5
6
|
// ── Token budget enforcement ───────────────────────────────────────────
|
|
@@ -17,6 +18,13 @@ function countWords(text) {
|
|
|
17
18
|
function countBullets(lines) {
|
|
18
19
|
return lines.filter((l) => l.startsWith("-")).length;
|
|
19
20
|
}
|
|
21
|
+
const ENTITY_ID_RE = /\b(?:REQ|SYM|SCEN|TEST|ADR|FACT|FLAG|EVT)-[A-Za-z0-9_-]+\b/g;
|
|
22
|
+
// implements REQ-opencode-file-context-guidance-v1
|
|
23
|
+
function hasOverlappingEntityIds(textA, textB) {
|
|
24
|
+
const idsA = new Set(textA.match(ENTITY_ID_RE) ?? []);
|
|
25
|
+
const idsB = textB.match(ENTITY_ID_RE) ?? [];
|
|
26
|
+
return idsB.length > 0 && idsB.some((id) => idsA.has(id));
|
|
27
|
+
}
|
|
20
28
|
function enforceBudget(block, maxBullets = MAX_BULLETS) {
|
|
21
29
|
const lines = block.split("\n");
|
|
22
30
|
if (countBullets(lines) > maxBullets || countWords(block) > MAX_WORDS) {
|
|
@@ -48,9 +56,16 @@ function insertBulletAfterHeader(block, bullet) {
|
|
|
48
56
|
return `${block.slice(0, headerEnd + 1)}${bullet}\n${block.slice(headerEnd + 1)}`;
|
|
49
57
|
}
|
|
50
58
|
// implements REQ-opencode-kibi-briefing-v2
|
|
51
|
-
function buildAutoBriefingGuidance(autoBriefResult, completionReminder) {
|
|
59
|
+
export function buildAutoBriefingGuidance(autoBriefResult, completionReminder) {
|
|
52
60
|
if (!autoBriefResult)
|
|
53
61
|
return null;
|
|
62
|
+
// Defensive: idle-brief results are persisted to .kb/briefs/, never injected into prompts.
|
|
63
|
+
// This function only handles auto-briefs from the file.edited risk-classification flow.
|
|
64
|
+
if (typeof autoBriefResult === "object" &&
|
|
65
|
+
autoBriefResult !== null &&
|
|
66
|
+
("briefId" in autoBriefResult || "schemaVersion" in autoBriefResult)) {
|
|
67
|
+
return null;
|
|
68
|
+
}
|
|
54
69
|
if (autoBriefResult.state === "ready") {
|
|
55
70
|
const promptBlock = autoBriefResult.promptBlock.trim();
|
|
56
71
|
if (!promptBlock)
|
|
@@ -82,6 +97,21 @@ function isAuthoritativePosture(posture) {
|
|
|
82
97
|
function deriveFileBucket(pathKind) {
|
|
83
98
|
return pathKind;
|
|
84
99
|
}
|
|
100
|
+
function getFocusEdit(context) {
|
|
101
|
+
return context.focusEdit ?? context.recentEdits[context.recentEdits.length - 1];
|
|
102
|
+
}
|
|
103
|
+
function buildInitKibiBootstrapReference(capability = getInitKibiCommandCapability()) {
|
|
104
|
+
if (capability.supported) {
|
|
105
|
+
return "Bootstrap existing repos: when the Kibi OpenCode plugin is active and native injection is supported, `/init-kibi` is the canonical short alias; `/kibi:init-kibi:mcp` remains the namespaced MCP fallback for the retroactive initialization (`kb_autopilot_generate`) workflow.";
|
|
106
|
+
}
|
|
107
|
+
return "Bootstrap existing repos: this host does not support native `/init-kibi` injection, so Kibi must fail closed and does not register a fake native alias; use `/kibi:init-kibi:mcp` for the retroactive initialization (`kb_autopilot_generate`) workflow.";
|
|
108
|
+
}
|
|
109
|
+
function buildBootstrapRequiredBody(capability = getInitKibiCommandCapability()) {
|
|
110
|
+
const commandBullet = capability.supported
|
|
111
|
+
? "- When the Kibi OpenCode plugin is active and native injection is supported, use `/init-kibi` as the canonical short alias; `/kibi:init-kibi:mcp` remains the namespaced MCP fallback."
|
|
112
|
+
: "- This host does not support native `/init-kibi` injection. Kibi must fail closed and does not register a fake native alias; use `/kibi:init-kibi:mcp` instead.";
|
|
113
|
+
return `This repository does not appear to have Kibi initialized. Agents should:\n${commandBullet}\n- The workflow uses \`kb_autopilot_generate\` for read-only synthesis; always preview and get approval before writes.\n- Ask the user/operator to run setup or repair outside this session if bootstrap is insufficient.\n\nUse public MCP tools only: \`kb_autopilot_generate\`, \`kb_search\`, \`kb_query\`, \`kb_status\`, \`kb_find_gaps\`, \`kb_coverage\`, \`kb_graph\`, \`kb_upsert\`, \`kb_delete\`, \`kb_check\`.`;
|
|
114
|
+
}
|
|
85
115
|
// ── Guidance blocks by risk class ──────────────────────────────────────
|
|
86
116
|
const GUIDANCE_BY_RISK = {
|
|
87
117
|
safe_docs_only: null,
|
|
@@ -116,7 +146,7 @@ The Kibi knowledge base is managed through public MCP tools. Direct manual edits
|
|
|
116
146
|
- Use kb_check to validate consistency`,
|
|
117
147
|
};
|
|
118
148
|
// ── Posture overrides ──────────────────────────────────────────────────
|
|
119
|
-
export function postureGuidance(posture) {
|
|
149
|
+
export function postureGuidance(posture, capability = getInitKibiCommandCapability()) {
|
|
120
150
|
// implements REQ-opencode-prompt-injection
|
|
121
151
|
switch (posture) {
|
|
122
152
|
case "vendored_only":
|
|
@@ -125,12 +155,7 @@ export function postureGuidance(posture) {
|
|
|
125
155
|
case "root_uninitialized":
|
|
126
156
|
return `🔧 **Bootstrap required**
|
|
127
157
|
|
|
128
|
-
|
|
129
|
-
- Start with \`kb_autopilot_generate\` to discover entities and bootstrap the KB (preferred workflow)
|
|
130
|
-
- Use \`/init-kibi\` as the sanctioned slash command for initial repo setup
|
|
131
|
-
- Ask the user/operator to run setup or repair outside this session if bootstrap is insufficient
|
|
132
|
-
|
|
133
|
-
Do not run \`kibi\` CLI commands directly; use public MCP tools (kb_autopilot_generate, kb_search, kb_query, kb_status, kb_find_gaps, kb_coverage, kb_graph, kb_upsert, kb_delete, kb_check).`;
|
|
158
|
+
${buildBootstrapRequiredBody(capability)}`;
|
|
134
159
|
case "root_partial":
|
|
135
160
|
return `⚠️ **Partial KB setup detected**
|
|
136
161
|
|
|
@@ -143,7 +168,7 @@ Root .kb/config.json exists but some configured KB targets are missing. Guidance
|
|
|
143
168
|
/**
|
|
144
169
|
* Build prompt guidance block based on posture, risk class, and cache state.
|
|
145
170
|
*/
|
|
146
|
-
function buildContextualGuidance(context) {
|
|
171
|
+
function buildContextualGuidance(context, capability = getInitKibiCommandCapability()) {
|
|
147
172
|
const posture = context.posture ?? "root_active";
|
|
148
173
|
const riskClass = context.riskClass;
|
|
149
174
|
const readyAutoBriefingAvailable = context.autoBriefResult?.showManualCue === false;
|
|
@@ -152,6 +177,10 @@ function buildContextualGuidance(context) {
|
|
|
152
177
|
const showDegraded = context.showDegradedAdvisory === true &&
|
|
153
178
|
context.maintenanceDegraded === true &&
|
|
154
179
|
context.degradedMode === "warn-once";
|
|
180
|
+
const fileOpReminder = context.fileOperationReminder;
|
|
181
|
+
const hasFileOpReminders = fileOpReminder !== undefined &&
|
|
182
|
+
(fileOpReminder.lifecycleReminder !== null ||
|
|
183
|
+
fileOpReminder.e2eReminder !== null);
|
|
155
184
|
// ── Single-block priority selection ──
|
|
156
185
|
// Priority order (highest wins): manual_kb_edit > posture > risk_class > safe/none
|
|
157
186
|
let selectedBlock = null;
|
|
@@ -165,43 +194,46 @@ function buildContextualGuidance(context) {
|
|
|
165
194
|
}
|
|
166
195
|
// Priority 3: Posture warnings for non-active states — not cache-suppressed
|
|
167
196
|
else if (posture === "root_uninitialized" || posture === "root_partial") {
|
|
168
|
-
const postureBlock = postureGuidance(posture);
|
|
197
|
+
const postureBlock = postureGuidance(posture, capability);
|
|
169
198
|
if (postureBlock)
|
|
170
199
|
selectedBlock = postureBlock;
|
|
171
200
|
}
|
|
172
201
|
else if (!context.posture && context.workspaceHealth?.needsBootstrap) {
|
|
173
202
|
selectedBlock = `🔧 **Bootstrap required**
|
|
174
203
|
|
|
175
|
-
|
|
176
|
-
- Start with \`kb_autopilot_generate\` to discover entities and bootstrap the KB (preferred workflow)
|
|
177
|
-
- Use \`/init-kibi\` as the sanctioned slash command for initial repo setup
|
|
178
|
-
- Ask the user/operator to run setup or repair outside this session if bootstrap is insufficient
|
|
179
|
-
|
|
180
|
-
Do not run \`kibi\` CLI commands directly; use public MCP tools (kb_autopilot_generate, kb_search, kb_query, kb_status, kb_find_gaps, kb_coverage, kb_graph, kb_upsert, kb_delete, kb_check).`;
|
|
204
|
+
${buildBootstrapRequiredBody(capability)}`;
|
|
181
205
|
// Advisory guidance: check cache before selecting, since these blocks can be safely suppressed
|
|
182
206
|
}
|
|
183
207
|
else {
|
|
184
208
|
// Cache check: skip repeated advisory guidance — only after critical signals are handled above
|
|
185
209
|
// Allow degraded advisory to bypass cache so it is always visible
|
|
210
|
+
// File-operation reminders also bypass cache (per-path suppression handled by caller)
|
|
211
|
+
let cacheSuppressedSemantic = false;
|
|
186
212
|
if (!showDegraded &&
|
|
187
213
|
context.cache &&
|
|
188
214
|
context.workspaceRoot &&
|
|
189
215
|
context.branch &&
|
|
190
216
|
riskClass) {
|
|
191
|
-
const
|
|
217
|
+
const focusEdit = getFocusEdit(context);
|
|
192
218
|
const key = {
|
|
193
219
|
workspaceRoot: context.workspaceRoot,
|
|
194
220
|
branch: context.branch,
|
|
195
221
|
posture,
|
|
196
222
|
riskClass,
|
|
197
|
-
fileBucket: deriveFileBucket(
|
|
223
|
+
fileBucket: deriveFileBucket(focusEdit?.kind ?? "unknown"),
|
|
198
224
|
};
|
|
199
225
|
if (context.cache.isSatisfied(key)) {
|
|
200
|
-
|
|
226
|
+
if (hasFileOpReminders) {
|
|
227
|
+
cacheSuppressedSemantic = true;
|
|
228
|
+
}
|
|
229
|
+
else {
|
|
230
|
+
return SENTINEL; // skip guidance — recently satisfied
|
|
231
|
+
}
|
|
201
232
|
}
|
|
202
233
|
}
|
|
203
234
|
// Priority 5: Risk-class-driven guidance (for non-safe classes)
|
|
204
|
-
if (
|
|
235
|
+
if (!cacheSuppressedSemantic &&
|
|
236
|
+
riskClass &&
|
|
205
237
|
riskClass !== "safe_docs_only" &&
|
|
206
238
|
riskClass !== "safe_test_only") {
|
|
207
239
|
const autoBriefBlock = riskClass === "behavior_candidate" ||
|
|
@@ -226,7 +258,7 @@ Do not run \`kibi\` CLI commands directly; use public MCP tools (kb_autopilot_ge
|
|
|
226
258
|
}
|
|
227
259
|
}
|
|
228
260
|
// Priority 6: Legacy path-kind fallback (when no risk class)
|
|
229
|
-
else if (!riskClass) {
|
|
261
|
+
else if (!cacheSuppressedSemantic && !riskClass) {
|
|
230
262
|
const codeEdits = context.recentEdits.filter((e) => e.kind === "code");
|
|
231
263
|
const reqEdits = context.recentEdits.filter((e) => e.kind === "requirement");
|
|
232
264
|
const kbDocEdits = context.recentEdits.filter((e) => [
|
|
@@ -285,9 +317,9 @@ If you're adding long explanatory comments, consider routing that knowledge to:
|
|
|
285
317
|
context.workspaceRoot &&
|
|
286
318
|
!suppressSourceLinkedBrief) {
|
|
287
319
|
try {
|
|
288
|
-
const
|
|
289
|
-
if (
|
|
290
|
-
const editedPath =
|
|
320
|
+
const focusEdit = getFocusEdit(context);
|
|
321
|
+
if (focusEdit?.path) {
|
|
322
|
+
const editedPath = focusEdit.path;
|
|
291
323
|
const absEdited = path.isAbsolute(editedPath)
|
|
292
324
|
? editedPath
|
|
293
325
|
: path.join(context.workspaceRoot, editedPath);
|
|
@@ -301,6 +333,41 @@ If you're adding long explanatory comments, consider routing that knowledge to:
|
|
|
301
333
|
// Non-fatal: source-linked brief is best-effort
|
|
302
334
|
}
|
|
303
335
|
}
|
|
336
|
+
// ── File-operation reminder folding ─────────────────────────────────
|
|
337
|
+
// File-operation reminders bypass generic GuidanceCache suppression but
|
|
338
|
+
// are subject to prompt budget trimming. Per-path suppression is handled
|
|
339
|
+
// by the caller via file-operation-state hasShown/markShown.
|
|
340
|
+
// implements REQ-opencode-file-context-guidance-v1
|
|
341
|
+
if (hasFileOpReminders && fileOpReminder) {
|
|
342
|
+
const foBullets = [];
|
|
343
|
+
if (fileOpReminder.lifecycleReminder) {
|
|
344
|
+
// Skip lifecycleReminder if source-linked brief already shows the same IDs
|
|
345
|
+
const hasSourceLinked = selectedBlock?.includes("- Existing Kibi links:") === true;
|
|
346
|
+
const lifecycleHasEntities = fileOpReminder.lifecycleReminder.includes("Kibi entities:");
|
|
347
|
+
const overlapsSourceLinked = hasSourceLinked &&
|
|
348
|
+
lifecycleHasEntities &&
|
|
349
|
+
selectedBlock !== null &&
|
|
350
|
+
hasOverlappingEntityIds(selectedBlock, fileOpReminder.lifecycleReminder);
|
|
351
|
+
if (!overlapsSourceLinked) {
|
|
352
|
+
foBullets.push(fileOpReminder.lifecycleReminder);
|
|
353
|
+
}
|
|
354
|
+
}
|
|
355
|
+
if (fileOpReminder.e2eReminder) {
|
|
356
|
+
foBullets.push(fileOpReminder.e2eReminder);
|
|
357
|
+
}
|
|
358
|
+
if (foBullets.length > 0) {
|
|
359
|
+
if (selectedBlock) {
|
|
360
|
+
// Fold into existing semantic block
|
|
361
|
+
for (const bullet of foBullets) {
|
|
362
|
+
selectedBlock = insertBulletAfterHeader(selectedBlock, bullet);
|
|
363
|
+
}
|
|
364
|
+
}
|
|
365
|
+
else {
|
|
366
|
+
// Create file-operation-only compact block
|
|
367
|
+
selectedBlock = `🧠 **File operation detected**\n${foBullets.join("\n")}`;
|
|
368
|
+
}
|
|
369
|
+
}
|
|
370
|
+
}
|
|
304
371
|
// Inject degraded advisory block for warn-once mode
|
|
305
372
|
if (showDegraded) {
|
|
306
373
|
const advisory = `⚠️ **Maintenance degraded**
|
|
@@ -320,13 +387,13 @@ The Kibi workspace is in a maintenance-degraded state. Guidance remains advisory
|
|
|
320
387
|
context.workspaceRoot &&
|
|
321
388
|
context.branch &&
|
|
322
389
|
riskClass) {
|
|
323
|
-
const
|
|
390
|
+
const focusEdit = getFocusEdit(context);
|
|
324
391
|
const key = {
|
|
325
392
|
workspaceRoot: context.workspaceRoot,
|
|
326
393
|
branch: context.branch,
|
|
327
394
|
posture,
|
|
328
395
|
riskClass,
|
|
329
|
-
fileBucket: deriveFileBucket(
|
|
396
|
+
fileBucket: deriveFileBucket(focusEdit?.kind ?? "unknown"),
|
|
330
397
|
};
|
|
331
398
|
context.cache.recordSatisfied(key, "guidance");
|
|
332
399
|
}
|
|
@@ -416,7 +483,8 @@ Before implementing or explaining code:
|
|
|
416
483
|
/**
|
|
417
484
|
* Build the static guidance block (original behavior).
|
|
418
485
|
*/
|
|
419
|
-
|
|
486
|
+
function buildBaseGuidance(capability = getInitKibiCommandCapability()) {
|
|
487
|
+
return `${SENTINEL}
|
|
420
488
|
This project uses Kibi (via MCP). Prefer storing durable knowledge in Kibi over code comments.
|
|
421
489
|
|
|
422
490
|
Before changing behavior: use kb_search for discovery, then kb_query by sourceFile, id, type, or tags for exact follow-up; do not rely on undocumented tools.
|
|
@@ -435,26 +503,28 @@ Dogfood note for this repo: OpenCode here uses local built \`kibi-mcp\` and \`ki
|
|
|
435
503
|
5. **Link during work**: When creating KB entities, include relationship rows: specified_by (req→scenario), implements (symbol→req for ownership), covered_by (symbol→test for coverage), executable_for (test code→test).
|
|
436
504
|
6. **Validate**: Run kb_check after KB mutations to catch violations early.
|
|
437
505
|
|
|
438
|
-
**Public Kibi tools only:** kb_autopilot_generate, kb_search, kb_query, kb_status, kb_find_gaps, kb_coverage, kb_graph, kb_upsert, kb_delete, kb_check.\n\nDo not invoke Kibi CLI commands directly from the agent.\n\
|
|
506
|
+
**Public Kibi tools only:** kb_autopilot_generate, kb_search, kb_query, kb_status, kb_find_gaps, kb_coverage, kb_graph, kb_upsert, kb_delete, kb_check.\n\nDo not invoke Kibi CLI commands directly from the agent.\n\n${buildInitKibiBootstrapReference(capability)}`;
|
|
507
|
+
}
|
|
439
508
|
/**
|
|
440
509
|
* Build prompt with contextual guidance based on posture, risk class, and cache state.
|
|
441
510
|
*/
|
|
442
|
-
export function buildPrompt(context) {
|
|
511
|
+
export function buildPrompt(context, capability = getInitKibiCommandCapability()) {
|
|
443
512
|
if (!context) {
|
|
444
|
-
return
|
|
513
|
+
return buildBaseGuidance(capability).trim();
|
|
445
514
|
}
|
|
446
|
-
return buildContextualGuidance(context).trim();
|
|
515
|
+
return buildContextualGuidance(context, capability).trim();
|
|
447
516
|
}
|
|
448
517
|
/**
|
|
449
518
|
* Inject prompt guidance if not already present.
|
|
450
519
|
*/
|
|
451
|
-
|
|
520
|
+
// implements REQ-opencode-kibi-briefing-v2
|
|
521
|
+
export function injectPrompt(current, config, context, capability = getInitKibiCommandCapability()) {
|
|
452
522
|
if (!config.prompt.enabled || !isPluginEnabled(config)) {
|
|
453
523
|
return current;
|
|
454
524
|
}
|
|
455
525
|
if (current.includes(SENTINEL)) {
|
|
456
526
|
return current;
|
|
457
527
|
}
|
|
458
|
-
return `${current}\n\n${buildPrompt(context)}`;
|
|
528
|
+
return `${current}\n\n${buildPrompt(context, capability)}`;
|
|
459
529
|
}
|
|
460
530
|
export { SENTINEL };
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import type { AuditEntry } from "./idle-brief-audit.js";
|
|
2
|
+
export interface EntityChangeItem {
|
|
3
|
+
id: string;
|
|
4
|
+
type: string;
|
|
5
|
+
title?: string;
|
|
6
|
+
source?: string;
|
|
7
|
+
textRef?: string;
|
|
8
|
+
}
|
|
9
|
+
export interface ReconcileResult {
|
|
10
|
+
added: EntityChangeItem[];
|
|
11
|
+
modified: EntityChangeItem[];
|
|
12
|
+
removed: EntityChangeItem[];
|
|
13
|
+
relationshipsChanged: number;
|
|
14
|
+
}
|
|
15
|
+
export declare function reconcileAuditEntries(entries: AuditEntry[]): ReconcileResult;
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
function normalizeWhitespace(value) {
|
|
2
|
+
return value.trim().replace(/\s+/g, " ");
|
|
3
|
+
}
|
|
4
|
+
function normalizeValue(value) {
|
|
5
|
+
if (typeof value === "string") {
|
|
6
|
+
return normalizeWhitespace(value);
|
|
7
|
+
}
|
|
8
|
+
if (Array.isArray(value)) {
|
|
9
|
+
return value.map((entry) => normalizeValue(entry));
|
|
10
|
+
}
|
|
11
|
+
if (value && typeof value === "object") {
|
|
12
|
+
return Object.fromEntries(Object.entries(value)
|
|
13
|
+
.filter(([key]) => key !== "created_at" && key !== "updated_at")
|
|
14
|
+
.sort(([left], [right]) => left.localeCompare(right))
|
|
15
|
+
.map(([key, entry]) => [key, normalizeValue(entry)]));
|
|
16
|
+
}
|
|
17
|
+
return value;
|
|
18
|
+
}
|
|
19
|
+
function fingerprintPayload(payload) {
|
|
20
|
+
return JSON.stringify(normalizeValue(payload.properties));
|
|
21
|
+
}
|
|
22
|
+
function isEntityPayload(payload) {
|
|
23
|
+
return payload?.kind === "entity";
|
|
24
|
+
}
|
|
25
|
+
function toChangeItem(payload, entityId) {
|
|
26
|
+
const title = payload.title ??
|
|
27
|
+
(typeof payload.properties.title === "string"
|
|
28
|
+
? payload.properties.title
|
|
29
|
+
: undefined);
|
|
30
|
+
const source = payload.source ??
|
|
31
|
+
(typeof payload.properties.source === "string"
|
|
32
|
+
? payload.properties.source
|
|
33
|
+
: undefined);
|
|
34
|
+
const textRef = payload.textRef ??
|
|
35
|
+
(typeof payload.properties.text_ref === "string"
|
|
36
|
+
? payload.properties.text_ref
|
|
37
|
+
: undefined);
|
|
38
|
+
return {
|
|
39
|
+
id: entityId,
|
|
40
|
+
type: payload.entityType,
|
|
41
|
+
...(title ? { title } : {}),
|
|
42
|
+
...(source ? { source } : {}),
|
|
43
|
+
...(textRef ? { textRef } : {}),
|
|
44
|
+
};
|
|
45
|
+
}
|
|
46
|
+
function compareChangeItems(left, right) {
|
|
47
|
+
return left.type.localeCompare(right.type) || left.id.localeCompare(right.id);
|
|
48
|
+
}
|
|
49
|
+
export function reconcileAuditEntries(
|
|
50
|
+
// implements REQ-opencode-kibi-briefing-v6
|
|
51
|
+
entries) {
|
|
52
|
+
const states = new Map();
|
|
53
|
+
let relationshipsChanged = 0;
|
|
54
|
+
for (const entry of [...entries].sort((left, right) => left.timestamp.localeCompare(right.timestamp))) {
|
|
55
|
+
if (entry.operation === "upsert_rel") {
|
|
56
|
+
relationshipsChanged += 1;
|
|
57
|
+
continue;
|
|
58
|
+
}
|
|
59
|
+
const state = states.get(entry.entityId) ?? {
|
|
60
|
+
sawCreate: false,
|
|
61
|
+
sawLegacyUpsert: false,
|
|
62
|
+
deleted: false,
|
|
63
|
+
};
|
|
64
|
+
if (entry.operation === "delete") {
|
|
65
|
+
state.deleted = true;
|
|
66
|
+
states.set(entry.entityId, state);
|
|
67
|
+
continue;
|
|
68
|
+
}
|
|
69
|
+
if (!isEntityPayload(entry.payload)) {
|
|
70
|
+
continue;
|
|
71
|
+
}
|
|
72
|
+
if (entry.payload.changeKind === "created") {
|
|
73
|
+
state.sawCreate = true;
|
|
74
|
+
}
|
|
75
|
+
if (!entry.payload.changeKind) {
|
|
76
|
+
state.sawLegacyUpsert = true;
|
|
77
|
+
}
|
|
78
|
+
state.lastKnown = toChangeItem(entry.payload, entry.entityId);
|
|
79
|
+
state.lastFingerprint = fingerprintPayload(entry.payload);
|
|
80
|
+
state.deleted = false;
|
|
81
|
+
states.set(entry.entityId, state);
|
|
82
|
+
}
|
|
83
|
+
const added = [];
|
|
84
|
+
const modified = [];
|
|
85
|
+
const removed = [];
|
|
86
|
+
for (const state of states.values()) {
|
|
87
|
+
if (!state.lastKnown) {
|
|
88
|
+
continue;
|
|
89
|
+
}
|
|
90
|
+
if (state.deleted) {
|
|
91
|
+
if (state.sawCreate) {
|
|
92
|
+
continue;
|
|
93
|
+
}
|
|
94
|
+
removed.push(state.lastKnown);
|
|
95
|
+
continue;
|
|
96
|
+
}
|
|
97
|
+
if (state.sawCreate || state.sawLegacyUpsert) {
|
|
98
|
+
added.push(state.lastKnown);
|
|
99
|
+
continue;
|
|
100
|
+
}
|
|
101
|
+
modified.push(state.lastKnown);
|
|
102
|
+
}
|
|
103
|
+
added.sort(compareChangeItems);
|
|
104
|
+
modified.sort(compareChangeItems);
|
|
105
|
+
removed.sort(compareChangeItems);
|
|
106
|
+
return {
|
|
107
|
+
added,
|
|
108
|
+
modified,
|
|
109
|
+
removed,
|
|
110
|
+
relationshipsChanged,
|
|
111
|
+
};
|
|
112
|
+
}
|
package/dist/scheduler.d.ts
CHANGED
|
@@ -36,6 +36,7 @@ export interface SyncScheduler {
|
|
|
36
36
|
scheduleSync(reason: string, filePath?: string, checkRules?: string[]): void;
|
|
37
37
|
onFileEdited(filePath: string): void;
|
|
38
38
|
onToolExecuteAfter(reason?: string): void;
|
|
39
|
+
flush(): Promise<void>;
|
|
39
40
|
dispose(): void;
|
|
40
41
|
}
|
|
41
42
|
export declare function createSyncScheduler(opts: SchedulerOptions): SyncScheduler;
|
package/dist/scheduler.js
CHANGED
|
@@ -18,6 +18,7 @@ class WorktreeSyncScheduler {
|
|
|
18
18
|
pending = null;
|
|
19
19
|
trailing = null;
|
|
20
20
|
lastFileEditedAt = 0;
|
|
21
|
+
flushWaiters = [];
|
|
21
22
|
constructor(opts) {
|
|
22
23
|
this.worktree = path.resolve(opts.worktree);
|
|
23
24
|
this.config = opts.config;
|
|
@@ -32,7 +33,9 @@ class WorktreeSyncScheduler {
|
|
|
32
33
|
scheduleSync(reason, filePath, checkRules) {
|
|
33
34
|
if (!this.config.sync.enabled)
|
|
34
35
|
return;
|
|
35
|
-
|
|
36
|
+
// Treat file.created, file.edited, and file.deleted same relevance-wise
|
|
37
|
+
const isFileLifecycle = reason === "file.edited" || reason === "file.created" || reason === "file.deleted";
|
|
38
|
+
if (isFileLifecycle) {
|
|
36
39
|
if (!filePath)
|
|
37
40
|
return;
|
|
38
41
|
if (!shouldHandleFile(filePath, this.worktree))
|
|
@@ -71,11 +74,30 @@ class WorktreeSyncScheduler {
|
|
|
71
74
|
this.scheduleSync(reason);
|
|
72
75
|
}
|
|
73
76
|
}
|
|
77
|
+
async flush() {
|
|
78
|
+
if (!this.config.sync.enabled)
|
|
79
|
+
return;
|
|
80
|
+
if (this.timer) {
|
|
81
|
+
this.clearTimeoutFn(this.timer);
|
|
82
|
+
this.timer = null;
|
|
83
|
+
}
|
|
84
|
+
this.flushPending();
|
|
85
|
+
if (this.isIdle()) {
|
|
86
|
+
return;
|
|
87
|
+
}
|
|
88
|
+
await new Promise((resolve) => {
|
|
89
|
+
this.flushWaiters.push(resolve);
|
|
90
|
+
});
|
|
91
|
+
}
|
|
74
92
|
dispose() {
|
|
75
93
|
if (this.timer) {
|
|
76
94
|
this.clearTimeoutFn(this.timer);
|
|
77
95
|
this.timer = null;
|
|
78
96
|
}
|
|
97
|
+
const waiters = this.flushWaiters.splice(0);
|
|
98
|
+
for (const waiter of waiters) {
|
|
99
|
+
waiter();
|
|
100
|
+
}
|
|
79
101
|
}
|
|
80
102
|
isToolExecuteAfterEnabled() {
|
|
81
103
|
if (this.explicitToolAfterHint)
|
|
@@ -147,6 +169,20 @@ class WorktreeSyncScheduler {
|
|
|
147
169
|
: {}),
|
|
148
170
|
});
|
|
149
171
|
}
|
|
172
|
+
this.resolveFlushWaitersIfIdle();
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
isIdle() {
|
|
176
|
+
return !this.inFlight && !this.timer && !this.pending && !this.dirty && !this.trailing;
|
|
177
|
+
}
|
|
178
|
+
resolveFlushWaitersIfIdle() {
|
|
179
|
+
if (!this.isIdle())
|
|
180
|
+
return;
|
|
181
|
+
if (this.flushWaiters.length === 0)
|
|
182
|
+
return;
|
|
183
|
+
const waiters = this.flushWaiters.splice(0);
|
|
184
|
+
for (const waiter of waiters) {
|
|
185
|
+
waiter();
|
|
150
186
|
}
|
|
151
187
|
}
|
|
152
188
|
emitCompletion(trigger, startedAt, exitCode, checkExitCode, checkRules) {
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
export type EditEventKind = string;
|
|
2
|
+
export interface SessionEditEntry {
|
|
3
|
+
/** Relative file path (relative to worktree root). */
|
|
4
|
+
filePath: string;
|
|
5
|
+
/** Hash of the file content at first sight (baseline). "<deleted>" sentinel if file was missing. */
|
|
6
|
+
baselineHash: string;
|
|
7
|
+
/** Current hash at last reconciliation. */
|
|
8
|
+
currentHash: string;
|
|
9
|
+
/** Timestamp (ms) of last reconciliation pass. */
|
|
10
|
+
lastReconciledAt: number;
|
|
11
|
+
}
|
|
12
|
+
export interface SessionEditState {
|
|
13
|
+
recordEventHint(filePath: string, kind: EditEventKind, timestamp?: number): void;
|
|
14
|
+
reconcilePath(filePath: string): void;
|
|
15
|
+
reconcileKnownPaths(): void;
|
|
16
|
+
getSessionEdits(): SessionEditEntry[];
|
|
17
|
+
getFocusEdit(): SessionEditEntry | null;
|
|
18
|
+
hasSessionEdits(): boolean;
|
|
19
|
+
forceEdit(filePath: string, kind?: EditEventKind, timestamp?: number): void;
|
|
20
|
+
}
|
|
21
|
+
export declare function createSessionEditState(opts: {
|
|
22
|
+
worktree: string;
|
|
23
|
+
/** Custom clock for testing. Defaults to Date.now. */
|
|
24
|
+
now?: () => number;
|
|
25
|
+
}): SessionEditState;
|