kibi-opencode 0.9.0 → 0.10.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (51) hide show
  1. package/README.md +36 -12
  2. package/dist/brief-intent.d.ts +15 -4
  3. package/dist/brief-intent.js +63 -25
  4. package/dist/briefing-runtime.js +2 -1
  5. package/dist/config.d.ts +3 -0
  6. package/dist/config.js +9 -0
  7. package/dist/e2e-coverage-signals.d.ts +6 -0
  8. package/dist/e2e-coverage-signals.js +186 -0
  9. package/dist/file-entity-links.d.ts +15 -0
  10. package/dist/file-entity-links.js +254 -0
  11. package/dist/file-operation-reminders.d.ts +24 -0
  12. package/dist/file-operation-reminders.js +55 -0
  13. package/dist/file-operation-state.d.ts +29 -0
  14. package/dist/file-operation-state.js +113 -0
  15. package/dist/idle-brief-audit.d.ts +36 -0
  16. package/dist/idle-brief-audit.js +186 -0
  17. package/dist/idle-brief-paths.d.ts +6 -0
  18. package/dist/idle-brief-paths.js +120 -0
  19. package/dist/idle-brief-reader.d.ts +25 -0
  20. package/dist/idle-brief-reader.js +142 -0
  21. package/dist/idle-brief-runtime.d.ts +48 -0
  22. package/dist/idle-brief-runtime.js +443 -0
  23. package/dist/idle-brief-store.d.ts +96 -0
  24. package/dist/idle-brief-store.js +209 -0
  25. package/dist/index.d.ts +14 -1
  26. package/dist/index.js +626 -50
  27. package/dist/init-kibi-alias.d.ts +14 -0
  28. package/dist/init-kibi-alias.js +38 -0
  29. package/dist/init-kibi-capability.d.ts +32 -0
  30. package/dist/init-kibi-capability.js +202 -0
  31. package/dist/logger.js +9 -3
  32. package/dist/plugin-startup.d.ts +1 -0
  33. package/dist/plugin-startup.js +11 -2
  34. package/dist/prompt.d.ts +15 -3
  35. package/dist/prompt.js +103 -33
  36. package/dist/reconcile-engine.d.ts +15 -0
  37. package/dist/reconcile-engine.js +112 -0
  38. package/dist/scheduler.d.ts +1 -0
  39. package/dist/scheduler.js +37 -1
  40. package/dist/session-edit-state.d.ts +25 -0
  41. package/dist/session-edit-state.js +177 -0
  42. package/dist/session-fingerprint.d.ts +11 -0
  43. package/dist/session-fingerprint.js +21 -0
  44. package/dist/source-linked-guidance.d.ts +1 -2
  45. package/dist/source-linked-guidance.js +5 -168
  46. package/dist/startup-notifier.js +42 -31
  47. package/dist/toast.d.ts +21 -22
  48. package/dist/toast.js +36 -14
  49. package/dist/tui-brief-delivery.d.ts +47 -0
  50. package/dist/tui-brief-delivery.js +138 -0
  51. package/package.json +4 -3
package/dist/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
- This repository does not appear to have Kibi initialized. Agents should:
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
- This repository does not appear to have Kibi initialized. Agents should:
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 lastEdit = context.recentEdits[context.recentEdits.length - 1];
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(lastEdit?.kind ?? "unknown"),
223
+ fileBucket: deriveFileBucket(focusEdit?.kind ?? "unknown"),
198
224
  };
199
225
  if (context.cache.isSatisfied(key)) {
200
- return SENTINEL; // skip guidance — recently satisfied
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 (riskClass &&
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 lastEdit = context.recentEdits[context.recentEdits.length - 1];
289
- if (lastEdit?.path) {
290
- const editedPath = lastEdit.path;
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 lastEdit = context.recentEdits[context.recentEdits.length - 1];
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(lastEdit?.kind ?? "unknown"),
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
- const BASE_GUIDANCE = `${SENTINEL}
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\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
+ }
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 BASE_GUIDANCE.trim();
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
- export function injectPrompt(current, config, context) {
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
+ }
@@ -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;