kibi-opencode 0.8.0 → 0.10.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (53) hide show
  1. package/README.md +37 -12
  2. package/dist/brief-intent.d.ts +41 -0
  3. package/dist/brief-intent.js +127 -0
  4. package/dist/briefing-runtime.d.ts +24 -0
  5. package/dist/briefing-runtime.js +277 -0
  6. package/dist/config.d.ts +3 -0
  7. package/dist/config.js +9 -0
  8. package/dist/e2e-coverage-signals.d.ts +6 -0
  9. package/dist/e2e-coverage-signals.js +186 -0
  10. package/dist/file-entity-links.d.ts +15 -0
  11. package/dist/file-entity-links.js +254 -0
  12. package/dist/file-operation-reminders.d.ts +24 -0
  13. package/dist/file-operation-reminders.js +55 -0
  14. package/dist/file-operation-state.d.ts +29 -0
  15. package/dist/file-operation-state.js +113 -0
  16. package/dist/idle-brief-audit.d.ts +36 -0
  17. package/dist/idle-brief-audit.js +186 -0
  18. package/dist/idle-brief-paths.d.ts +6 -0
  19. package/dist/idle-brief-paths.js +120 -0
  20. package/dist/idle-brief-reader.d.ts +25 -0
  21. package/dist/idle-brief-reader.js +142 -0
  22. package/dist/idle-brief-runtime.d.ts +48 -0
  23. package/dist/idle-brief-runtime.js +443 -0
  24. package/dist/idle-brief-store.d.ts +96 -0
  25. package/dist/idle-brief-store.js +209 -0
  26. package/dist/index.d.ts +15 -1
  27. package/dist/index.js +645 -22
  28. package/dist/init-kibi-alias.d.ts +14 -0
  29. package/dist/init-kibi-alias.js +38 -0
  30. package/dist/init-kibi-capability.d.ts +32 -0
  31. package/dist/init-kibi-capability.js +202 -0
  32. package/dist/logger.js +9 -3
  33. package/dist/plugin-startup.d.ts +1 -0
  34. package/dist/plugin-startup.js +11 -2
  35. package/dist/prompt.d.ts +18 -3
  36. package/dist/prompt.js +176 -50
  37. package/dist/reconcile-engine.d.ts +15 -0
  38. package/dist/reconcile-engine.js +112 -0
  39. package/dist/scheduler.d.ts +1 -0
  40. package/dist/scheduler.js +37 -1
  41. package/dist/session-edit-state.d.ts +25 -0
  42. package/dist/session-edit-state.js +177 -0
  43. package/dist/session-fingerprint.d.ts +11 -0
  44. package/dist/session-fingerprint.js +21 -0
  45. package/dist/source-linked-guidance.d.ts +1 -2
  46. package/dist/source-linked-guidance.js +5 -168
  47. package/dist/startup-notifier.d.ts +3 -18
  48. package/dist/startup-notifier.js +42 -36
  49. package/dist/toast.d.ts +31 -0
  50. package/dist/toast.js +40 -0
  51. package/dist/tui-brief-delivery.d.ts +47 -0
  52. package/dist/tui-brief-delivery.js +138 -0
  53. package/package.json +4 -3
package/dist/prompt.js CHANGED
@@ -1,10 +1,13 @@
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 ───────────────────────────────────────────
6
7
  const MAX_BULLETS = 5;
8
+ const MAX_AUTO_BRIEF_BULLETS_WITH_REMINDER = 4;
7
9
  const MAX_WORDS = 117; // Reserve 3 words for sentinel so total injected prompt stays ≤ 120
10
+ const AUTO_BRIEF_HEADER = "🧠 **Kibi briefing available**";
8
11
  const AUTHORITATIVE_POSTURES = [
9
12
  "root_active",
10
13
  "hybrid_root_plus_vendored",
@@ -15,14 +18,21 @@ function countWords(text) {
15
18
  function countBullets(lines) {
16
19
  return lines.filter((l) => l.startsWith("-")).length;
17
20
  }
18
- function enforceBudget(block) {
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
+ }
28
+ function enforceBudget(block, maxBullets = MAX_BULLETS) {
19
29
  const lines = block.split("\n");
20
- if (countBullets(lines) > MAX_BULLETS || countWords(block) > MAX_WORDS) {
21
- // Trim to budget: keep header + first MAX_BULLETS bullet lines
30
+ if (countBullets(lines) > maxBullets || countWords(block) > MAX_WORDS) {
31
+ // Trim to budget: keep header + first maxBullets bullet lines
22
32
  const header = [];
23
33
  const bullets = [];
24
34
  for (const line of lines) {
25
- if (line.startsWith("-") && bullets.length < MAX_BULLETS) {
35
+ if (line.startsWith("-") && bullets.length < maxBullets) {
26
36
  bullets.push(line);
27
37
  }
28
38
  else if (!line.startsWith("-")) {
@@ -45,6 +55,41 @@ function insertBulletAfterHeader(block, bullet) {
45
55
  return `${block}\n${bullet}`;
46
56
  return `${block.slice(0, headerEnd + 1)}${bullet}\n${block.slice(headerEnd + 1)}`;
47
57
  }
58
+ // implements REQ-opencode-kibi-briefing-v2
59
+ export function buildAutoBriefingGuidance(autoBriefResult, completionReminder) {
60
+ if (!autoBriefResult)
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
+ }
69
+ if (autoBriefResult.state === "ready") {
70
+ const promptBlock = autoBriefResult.promptBlock.trim();
71
+ if (!promptBlock)
72
+ return null;
73
+ const maxBullets = completionReminder
74
+ ? MAX_AUTO_BRIEF_BULLETS_WITH_REMINDER
75
+ : MAX_BULLETS;
76
+ const briefingLines = promptBlock
77
+ .split("\n")
78
+ .map((line) => line.trim())
79
+ .filter((line) => line.startsWith("-"))
80
+ .slice(0, maxBullets);
81
+ if (briefingLines.length === 0)
82
+ return null;
83
+ return `${AUTO_BRIEF_HEADER}\n${briefingLines.join("\n")}`;
84
+ }
85
+ if (autoBriefResult.state === "tldr_fallback") {
86
+ const promptBlock = autoBriefResult.promptBlock.trim();
87
+ if (!promptBlock)
88
+ return null;
89
+ return `${AUTO_BRIEF_HEADER}\n${promptBlock}`;
90
+ }
91
+ return null;
92
+ }
48
93
  function isAuthoritativePosture(posture) {
49
94
  return AUTHORITATIVE_POSTURES.includes(posture);
50
95
  }
@@ -52,6 +97,21 @@ function isAuthoritativePosture(posture) {
52
97
  function deriveFileBucket(pathKind) {
53
98
  return pathKind;
54
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
+ }
55
115
  // ── Guidance blocks by risk class ──────────────────────────────────────
56
116
  const GUIDANCE_BY_RISK = {
57
117
  safe_docs_only: null,
@@ -86,7 +146,7 @@ The Kibi knowledge base is managed through public MCP tools. Direct manual edits
86
146
  - Use kb_check to validate consistency`,
87
147
  };
88
148
  // ── Posture overrides ──────────────────────────────────────────────────
89
- export function postureGuidance(posture) {
149
+ export function postureGuidance(posture, capability = getInitKibiCommandCapability()) {
90
150
  // implements REQ-opencode-prompt-injection
91
151
  switch (posture) {
92
152
  case "vendored_only":
@@ -95,12 +155,7 @@ export function postureGuidance(posture) {
95
155
  case "root_uninitialized":
96
156
  return `🔧 **Bootstrap required**
97
157
 
98
- This repository does not appear to have Kibi initialized. Agents should:
99
- - Start with \`kb_autopilot_generate\` to discover entities and bootstrap the KB (preferred workflow)
100
- - Use \`/init-kibi\` as the sanctioned slash command for initial repo setup
101
- - Ask the user/operator to run setup or repair outside this session if bootstrap is insufficient
102
-
103
- 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)}`;
104
159
  case "root_partial":
105
160
  return `⚠️ **Partial KB setup detected**
106
161
 
@@ -113,12 +168,19 @@ Root .kb/config.json exists but some configured KB targets are missing. Guidance
113
168
  /**
114
169
  * Build prompt guidance block based on posture, risk class, and cache state.
115
170
  */
116
- function buildContextualGuidance(context) {
171
+ function buildContextualGuidance(context, capability = getInitKibiCommandCapability()) {
117
172
  const posture = context.posture ?? "root_active";
118
173
  const riskClass = context.riskClass;
174
+ const readyAutoBriefingAvailable = context.autoBriefResult?.showManualCue === false;
175
+ const suppressSourceLinkedBrief = context.autoBriefResult?.state === "ready" ||
176
+ context.autoBriefResult?.state === "tldr_fallback";
119
177
  const showDegraded = context.showDegradedAdvisory === true &&
120
178
  context.maintenanceDegraded === true &&
121
179
  context.degradedMode === "warn-once";
180
+ const fileOpReminder = context.fileOperationReminder;
181
+ const hasFileOpReminders = fileOpReminder !== undefined &&
182
+ (fileOpReminder.lifecycleReminder !== null ||
183
+ fileOpReminder.e2eReminder !== null);
122
184
  // ── Single-block priority selection ──
123
185
  // Priority order (highest wins): manual_kb_edit > posture > risk_class > safe/none
124
186
  let selectedBlock = null;
@@ -132,59 +194,71 @@ function buildContextualGuidance(context) {
132
194
  }
133
195
  // Priority 3: Posture warnings for non-active states — not cache-suppressed
134
196
  else if (posture === "root_uninitialized" || posture === "root_partial") {
135
- const postureBlock = postureGuidance(posture);
197
+ const postureBlock = postureGuidance(posture, capability);
136
198
  if (postureBlock)
137
199
  selectedBlock = postureBlock;
138
200
  }
139
201
  else if (!context.posture && context.workspaceHealth?.needsBootstrap) {
140
202
  selectedBlock = `🔧 **Bootstrap required**
141
203
 
142
- This repository does not appear to have Kibi initialized. Agents should:
143
- - Start with \`kb_autopilot_generate\` to discover entities and bootstrap the KB (preferred workflow)
144
- - Use \`/init-kibi\` as the sanctioned slash command for initial repo setup
145
- - Ask the user/operator to run setup or repair outside this session if bootstrap is insufficient
146
-
147
- 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)}`;
148
205
  // Advisory guidance: check cache before selecting, since these blocks can be safely suppressed
149
206
  }
150
207
  else {
151
208
  // Cache check: skip repeated advisory guidance — only after critical signals are handled above
152
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;
153
212
  if (!showDegraded &&
154
213
  context.cache &&
155
214
  context.workspaceRoot &&
156
215
  context.branch &&
157
216
  riskClass) {
158
- const lastEdit = context.recentEdits[context.recentEdits.length - 1];
217
+ const focusEdit = getFocusEdit(context);
159
218
  const key = {
160
219
  workspaceRoot: context.workspaceRoot,
161
220
  branch: context.branch,
162
221
  posture,
163
222
  riskClass,
164
- fileBucket: deriveFileBucket(lastEdit?.kind ?? "unknown"),
223
+ fileBucket: deriveFileBucket(focusEdit?.kind ?? "unknown"),
165
224
  };
166
225
  if (context.cache.isSatisfied(key)) {
167
- return SENTINEL; // skip guidance — recently satisfied
226
+ if (hasFileOpReminders) {
227
+ cacheSuppressedSemantic = true;
228
+ }
229
+ else {
230
+ return SENTINEL; // skip guidance — recently satisfied
231
+ }
168
232
  }
169
233
  }
170
234
  // Priority 5: Risk-class-driven guidance (for non-safe classes)
171
- if (riskClass &&
235
+ if (!cacheSuppressedSemantic &&
236
+ riskClass &&
172
237
  riskClass !== "safe_docs_only" &&
173
238
  riskClass !== "safe_test_only") {
174
- // For behavior/traceability with comment suggestions, use suggestion guidance
175
- if ((riskClass === "behavior_candidate" ||
176
- riskClass === "traceability_candidate") &&
177
- context.recentCommentSuggestion) {
178
- selectedBlock = buildCommentSuggestionGuidance(context.recentCommentSuggestion);
239
+ const autoBriefBlock = riskClass === "behavior_candidate" ||
240
+ riskClass === "traceability_candidate"
241
+ ? buildAutoBriefingGuidance(context.autoBriefResult, context.completionReminder === true)
242
+ : null;
243
+ if (autoBriefBlock) {
244
+ selectedBlock = autoBriefBlock;
179
245
  }
180
246
  else {
181
- const block = GUIDANCE_BY_RISK[riskClass];
182
- if (block)
183
- selectedBlock = block;
247
+ // For behavior/traceability with comment suggestions, use suggestion guidance
248
+ if ((riskClass === "behavior_candidate" ||
249
+ riskClass === "traceability_candidate") &&
250
+ context.recentCommentSuggestion) {
251
+ selectedBlock = buildCommentSuggestionGuidance(context.recentCommentSuggestion);
252
+ }
253
+ else {
254
+ const block = GUIDANCE_BY_RISK[riskClass];
255
+ if (block)
256
+ selectedBlock = block;
257
+ }
184
258
  }
185
259
  }
186
260
  // Priority 6: Legacy path-kind fallback (when no risk class)
187
- else if (!riskClass) {
261
+ else if (!cacheSuppressedSemantic && !riskClass) {
188
262
  const codeEdits = context.recentEdits.filter((e) => e.kind === "code");
189
263
  const reqEdits = context.recentEdits.filter((e) => e.kind === "requirement");
190
264
  const kbDocEdits = context.recentEdits.filter((e) => [
@@ -229,7 +303,8 @@ If you're adding long explanatory comments, consider routing that knowledge to:
229
303
  (riskClass === "behavior_candidate" ||
230
304
  riskClass === "traceability_candidate") &&
231
305
  isAuthoritativePosture(posture) &&
232
- !context.maintenanceDegraded) {
306
+ !context.maintenanceDegraded &&
307
+ !readyAutoBriefingAvailable) {
233
308
  selectedBlock = insertBulletAfterHeader(selectedBlock, "- Authoritative risky edit: run `/brief-kibi` before acting.");
234
309
  }
235
310
  // Source-linked micro-brief: insert after header line for code risk classes
@@ -239,11 +314,12 @@ If you're adding long explanatory comments, consider routing that knowledge to:
239
314
  if (selectedBlock &&
240
315
  (riskClass === "behavior_candidate" ||
241
316
  riskClass === "traceability_candidate") &&
242
- context.workspaceRoot) {
317
+ context.workspaceRoot &&
318
+ !suppressSourceLinkedBrief) {
243
319
  try {
244
- const lastEdit = context.recentEdits[context.recentEdits.length - 1];
245
- if (lastEdit?.path) {
246
- const editedPath = lastEdit.path;
320
+ const focusEdit = getFocusEdit(context);
321
+ if (focusEdit?.path) {
322
+ const editedPath = focusEdit.path;
247
323
  const absEdited = path.isAbsolute(editedPath)
248
324
  ? editedPath
249
325
  : path.join(context.workspaceRoot, editedPath);
@@ -257,6 +333,41 @@ If you're adding long explanatory comments, consider routing that knowledge to:
257
333
  // Non-fatal: source-linked brief is best-effort
258
334
  }
259
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
+ }
260
371
  // Inject degraded advisory block for warn-once mode
261
372
  if (showDegraded) {
262
373
  const advisory = `⚠️ **Maintenance degraded**
@@ -276,25 +387,37 @@ The Kibi workspace is in a maintenance-degraded state. Guidance remains advisory
276
387
  context.workspaceRoot &&
277
388
  context.branch &&
278
389
  riskClass) {
279
- const lastEdit = context.recentEdits[context.recentEdits.length - 1];
390
+ const focusEdit = getFocusEdit(context);
280
391
  const key = {
281
392
  workspaceRoot: context.workspaceRoot,
282
393
  branch: context.branch,
283
394
  posture,
284
395
  riskClass,
285
- fileBucket: deriveFileBucket(lastEdit?.kind ?? "unknown"),
396
+ fileBucket: deriveFileBucket(focusEdit?.kind ?? "unknown"),
286
397
  };
287
398
  context.cache.recordSatisfied(key, "guidance");
288
399
  }
289
- // Apply budget enforcement before appending the completion reminder so the
290
- // reminder bullet is never silently trimmed when bullet count exceeds MAX_BULLETS.
291
- const budgeted = selectedBlock ? enforceBudget(selectedBlock) : null;
292
- // Append completion reminder for risky classes when enabled
293
400
  const REMINDER_RISK_CLASSES = [
294
401
  "behavior_candidate",
295
402
  "traceability_candidate",
296
403
  "req_policy_candidate",
297
404
  ];
405
+ const reminderWillBeAppended = !!selectedBlock &&
406
+ context.completionReminder === true &&
407
+ !context.maintenanceDegraded &&
408
+ riskClass != null &&
409
+ REMINDER_RISK_CLASSES.includes(riskClass) &&
410
+ posture !== "root_uninitialized" &&
411
+ posture !== "root_partial";
412
+ const effectiveMaxBullets = reminderWillBeAppended
413
+ ? MAX_BULLETS - 1
414
+ : MAX_BULLETS;
415
+ // Apply budget enforcement before appending the completion reminder so the
416
+ // reminder bullet is never silently trimmed when bullet count exceeds MAX_BULLETS.
417
+ const budgeted = selectedBlock
418
+ ? enforceBudget(selectedBlock, effectiveMaxBullets)
419
+ : null;
420
+ // Append completion reminder for risky classes when enabled
298
421
  let finalBlock = budgeted;
299
422
  if (finalBlock &&
300
423
  context.completionReminder === true &&
@@ -360,7 +483,8 @@ Before implementing or explaining code:
360
483
  /**
361
484
  * Build the static guidance block (original behavior).
362
485
  */
363
- const BASE_GUIDANCE = `${SENTINEL}
486
+ function buildBaseGuidance(capability = getInitKibiCommandCapability()) {
487
+ return `${SENTINEL}
364
488
  This project uses Kibi (via MCP). Prefer storing durable knowledge in Kibi over code comments.
365
489
 
366
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.
@@ -379,26 +503,28 @@ Dogfood note for this repo: OpenCode here uses local built \`kibi-mcp\` and \`ki
379
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).
380
504
  6. **Validate**: Run kb_check after KB mutations to catch violations early.
381
505
 
382
- **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\nBootstrap existing repos: use \`/init-kibi\` to run the retroactive initialization (\`kb_autopilot_generate\`) workflow.`;
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
+ }
383
508
  /**
384
509
  * Build prompt with contextual guidance based on posture, risk class, and cache state.
385
510
  */
386
- export function buildPrompt(context) {
511
+ export function buildPrompt(context, capability = getInitKibiCommandCapability()) {
387
512
  if (!context) {
388
- return BASE_GUIDANCE.trim();
513
+ return buildBaseGuidance(capability).trim();
389
514
  }
390
- return buildContextualGuidance(context).trim();
515
+ return buildContextualGuidance(context, capability).trim();
391
516
  }
392
517
  /**
393
518
  * Inject prompt guidance if not already present.
394
519
  */
395
- export function injectPrompt(current, config, context) {
520
+ // implements REQ-opencode-kibi-briefing-v2
521
+ export function injectPrompt(current, config, context, capability = getInitKibiCommandCapability()) {
396
522
  if (!config.prompt.enabled || !isPluginEnabled(config)) {
397
523
  return current;
398
524
  }
399
525
  if (current.includes(SENTINEL)) {
400
526
  return current;
401
527
  }
402
- return `${current}\n\n${buildPrompt(context)}`;
528
+ return `${current}\n\n${buildPrompt(context, capability)}`;
403
529
  }
404
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
+ }
@@ -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
- if (reason === "file.edited") {
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;