kibi-opencode 0.9.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.
Files changed (61) hide show
  1. package/README.md +38 -13
  2. package/dist/brief-delivery-reasons.d.ts +12 -0
  3. package/dist/brief-delivery-reasons.js +132 -0
  4. package/dist/brief-intent.d.ts +15 -4
  5. package/dist/brief-intent.js +78 -25
  6. package/dist/briefing-runtime.js +2 -1
  7. package/dist/config.d.ts +3 -0
  8. package/dist/config.js +9 -0
  9. package/dist/e2e-coverage-signals.d.ts +6 -0
  10. package/dist/e2e-coverage-signals.js +186 -0
  11. package/dist/file-entity-links.d.ts +15 -0
  12. package/dist/file-entity-links.js +254 -0
  13. package/dist/file-operation-reminders.d.ts +24 -0
  14. package/dist/file-operation-reminders.js +55 -0
  15. package/dist/file-operation-state.d.ts +29 -0
  16. package/dist/file-operation-state.js +113 -0
  17. package/dist/idle-brief-audit.d.ts +36 -0
  18. package/dist/idle-brief-audit.js +186 -0
  19. package/dist/idle-brief-paths.d.ts +6 -0
  20. package/dist/idle-brief-paths.js +120 -0
  21. package/dist/idle-brief-reader.d.ts +37 -0
  22. package/dist/idle-brief-reader.js +163 -0
  23. package/dist/idle-brief-runtime.d.ts +48 -0
  24. package/dist/idle-brief-runtime.js +478 -0
  25. package/dist/idle-brief-store.d.ts +113 -0
  26. package/dist/idle-brief-store.js +262 -0
  27. package/dist/index.d.ts +2 -39
  28. package/dist/index.js +1 -492
  29. package/dist/init-kibi-alias.d.ts +14 -0
  30. package/dist/init-kibi-alias.js +38 -0
  31. package/dist/init-kibi-capability.d.ts +32 -0
  32. package/dist/init-kibi-capability.js +202 -0
  33. package/dist/logger.d.ts +1 -0
  34. package/dist/logger.js +17 -4
  35. package/dist/plugin-startup.d.ts +1 -0
  36. package/dist/plugin-startup.js +11 -2
  37. package/dist/plugin.d.ts +52 -0
  38. package/dist/plugin.js +1068 -0
  39. package/dist/prompt.d.ts +15 -3
  40. package/dist/prompt.js +106 -36
  41. package/dist/reconcile-engine.d.ts +15 -0
  42. package/dist/reconcile-engine.js +112 -0
  43. package/dist/scheduler.d.ts +13 -2
  44. package/dist/scheduler.js +86 -7
  45. package/dist/session-edit-state.d.ts +25 -0
  46. package/dist/session-edit-state.js +177 -0
  47. package/dist/session-fingerprint.d.ts +11 -0
  48. package/dist/session-fingerprint.js +21 -0
  49. package/dist/source-linked-guidance.d.ts +1 -2
  50. package/dist/source-linked-guidance.js +5 -168
  51. package/dist/startup-notifier.js +42 -31
  52. package/dist/toast.d.ts +23 -22
  53. package/dist/toast.js +36 -14
  54. package/dist/tui-brief-delivery.d.ts +67 -0
  55. package/dist/tui-brief-delivery.js +279 -0
  56. package/dist/tui-brief-view-model.d.ts +63 -0
  57. package/dist/tui-brief-view-model.js +209 -0
  58. package/dist/tui.d.ts +8 -0
  59. package/dist/tui.js +413 -0
  60. package/dist/tui.jsx +120 -0
  61. package/package.json +13 -4
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)
@@ -106,9 +128,17 @@ class WorktreeSyncScheduler {
106
128
  let syncExitCode = 0;
107
129
  let checkExitCode;
108
130
  let checkRules;
131
+ let syncCommand;
132
+ let syncStdout;
133
+ let syncStderr;
134
+ let syncErrorMessage;
109
135
  try {
110
136
  const syncResult = await this.runSync(this.worktree);
111
137
  syncExitCode = syncResult.exitCode;
138
+ syncCommand = syncResult.syncCommand;
139
+ syncStdout = syncResult.syncStdout;
140
+ syncStderr = syncResult.syncStderr;
141
+ syncErrorMessage = syncResult.syncErrorMessage;
112
142
  // Run targeted checks if sync succeeded and rules specified
113
143
  if (syncExitCode === 0 &&
114
144
  trigger.checkRules &&
@@ -127,11 +157,11 @@ class WorktreeSyncScheduler {
127
157
  }
128
158
  catch (err) {
129
159
  const message = err instanceof Error ? err.message : String(err);
130
- logger.error(`sync.failed ${message}`);
131
160
  syncExitCode = 1;
161
+ syncErrorMessage = message;
132
162
  }
133
163
  finally {
134
- this.emitCompletion(trigger, startedAt, syncExitCode, checkExitCode, checkRules);
164
+ this.emitCompletion(trigger, startedAt, syncExitCode, checkExitCode, checkRules, truncateSyncOutput(syncStdout), truncateSyncOutput(syncStderr), syncErrorMessage, syncCommand);
135
165
  this.inFlight = false;
136
166
  if (this.dirty) {
137
167
  const trailing = this.trailing ?? { reason: "sync.trailing" };
@@ -147,9 +177,23 @@ class WorktreeSyncScheduler {
147
177
  : {}),
148
178
  });
149
179
  }
180
+ this.resolveFlushWaitersIfIdle();
181
+ }
182
+ }
183
+ isIdle() {
184
+ return !this.inFlight && !this.timer && !this.pending && !this.dirty && !this.trailing;
185
+ }
186
+ resolveFlushWaitersIfIdle() {
187
+ if (!this.isIdle())
188
+ return;
189
+ if (this.flushWaiters.length === 0)
190
+ return;
191
+ const waiters = this.flushWaiters.splice(0);
192
+ for (const waiter of waiters) {
193
+ waiter();
150
194
  }
151
195
  }
152
- emitCompletion(trigger, startedAt, exitCode, checkExitCode, checkRules) {
196
+ emitCompletion(trigger, startedAt, exitCode, checkExitCode, checkRules, syncStdout, syncStderr, syncErrorMessage, syncCommand) {
153
197
  const durationMs = Math.max(0, this.now() - startedAt);
154
198
  const normalizedReason = trigger.reason.endsWith(".trailing")
155
199
  ? trigger.reason.slice(0, -".trailing".length)
@@ -164,6 +208,10 @@ class WorktreeSyncScheduler {
164
208
  ...(trigger.filePath !== undefined ? { filePath: trigger.filePath } : {}),
165
209
  ...(checkExitCode !== undefined ? { checkExitCode } : {}),
166
210
  ...(checkRules !== undefined ? { checkRules } : {}),
211
+ ...(syncStdout !== undefined ? { syncStdout } : {}),
212
+ ...(syncStderr !== undefined ? { syncStderr } : {}),
213
+ ...(syncErrorMessage !== undefined ? { syncErrorMessage } : {}),
214
+ ...(syncCommand !== undefined ? { syncCommand } : {}),
167
215
  };
168
216
  if (exitCode === 0) {
169
217
  logger.info(`sync.succeeded ${JSON.stringify(meta)}`);
@@ -177,11 +225,42 @@ class WorktreeSyncScheduler {
177
225
  this.onRunComplete?.(meta);
178
226
  }
179
227
  }
228
+ const TRUNCATE_LIMIT = 4000;
229
+ const TRUNCATE_SUFFIX = "\n...[truncated]";
230
+ function truncateSyncOutput(value) {
231
+ if (!value)
232
+ return undefined;
233
+ if (value.length > TRUNCATE_LIMIT) {
234
+ return value.slice(0, TRUNCATE_LIMIT) + TRUNCATE_SUFFIX;
235
+ }
236
+ return value;
237
+ }
180
238
  async function runKibiSync(worktree) {
181
239
  return new Promise((resolve) => {
182
- exec("kibi sync", { cwd: worktree }, (error) => {
183
- resolve({ exitCode: error ? (error.code ?? 1) : 0 });
184
- });
240
+ try {
241
+ exec("kibi sync", { cwd: worktree }, (error, stdout, stderr) => {
242
+ if (error) {
243
+ const truncatedOut = truncateSyncOutput(stdout || undefined);
244
+ const truncatedErr = truncateSyncOutput(stderr || undefined);
245
+ const signal = error.signal ? ` (signal: ${error.signal})` : "";
246
+ const errorMessage = error.message ? `${error.message}${signal}` : signal || undefined;
247
+ resolve({
248
+ exitCode: error.code ?? 1,
249
+ syncCommand: "kibi sync",
250
+ ...(truncatedOut !== undefined ? { syncStdout: truncatedOut } : {}),
251
+ ...(truncatedErr !== undefined ? { syncStderr: truncatedErr } : {}),
252
+ ...(errorMessage ? { syncErrorMessage: errorMessage } : {}),
253
+ });
254
+ }
255
+ else {
256
+ resolve({ exitCode: 0, syncCommand: "kibi sync" });
257
+ }
258
+ });
259
+ }
260
+ catch (err) {
261
+ const message = err instanceof Error ? err.message : String(err);
262
+ resolve({ exitCode: 1, syncCommand: "kibi sync", syncErrorMessage: message });
263
+ }
185
264
  });
186
265
  }
187
266
  async function runKibiCheck(worktree, rules) {
@@ -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;
@@ -0,0 +1,177 @@
1
+ import * as crypto from "node:crypto";
2
+ import * as fs from "node:fs";
3
+ import * as path from "node:path";
4
+ // ---------------------------------------------------------------------------
5
+ // Constants
6
+ // ---------------------------------------------------------------------------
7
+ const SENTINEL_HASH = "<deleted>";
8
+ // ---------------------------------------------------------------------------
9
+ // Implementation
10
+ // ---------------------------------------------------------------------------
11
+ export function createSessionEditState(opts) {
12
+ const worktree = opts.worktree;
13
+ const now = opts.now ?? Date.now;
14
+ // ---- Per-instance state (no module globals) ----
15
+ /**
16
+ * Tracked files keyed by relative path.
17
+ * Undefined baselineHash means we haven't taken a snapshot yet.
18
+ */
19
+ const tracked = new Map();
20
+ // ---- Internal helpers ----
21
+ function resolveToRelative(filePath) {
22
+ if (path.isAbsolute(filePath)) {
23
+ const rel = path.relative(worktree, filePath);
24
+ // Normalise away any leading ./ or ../ that escapes worktree
25
+ return rel.startsWith("..") ? filePath : rel;
26
+ }
27
+ return filePath;
28
+ }
29
+ function resolveToAbsolute(relPath) {
30
+ return path.join(worktree, relPath);
31
+ }
32
+ function hashContent(content) {
33
+ return crypto.createHash("sha256").update(content).digest("hex");
34
+ }
35
+ function hashFile(absPath) {
36
+ try {
37
+ const content = fs.readFileSync(absPath, "utf-8");
38
+ return hashContent(content);
39
+ }
40
+ catch {
41
+ return SENTINEL_HASH;
42
+ }
43
+ }
44
+ /**
45
+ * Take a baseline snapshot if we haven't yet.
46
+ * Returns the baseline hash.
47
+ */
48
+ function ensureBaseline(entry, relPath) {
49
+ if (entry.baselineHash !== undefined) {
50
+ return entry.baselineHash;
51
+ }
52
+ const abs = resolveToAbsolute(relPath);
53
+ const h = hashFile(abs);
54
+ entry.baselineHash = h;
55
+ return h;
56
+ }
57
+ // ---- Public API ----
58
+ function recordEventHint(filePath, kind, timestamp) {
59
+ const rel = resolveToRelative(filePath);
60
+ let entry = tracked.get(rel);
61
+ if (!entry) {
62
+ entry = {
63
+ baselineHash: undefined,
64
+ currentHash: undefined,
65
+ lastReconciledAt: 0,
66
+ eventHints: [],
67
+ };
68
+ tracked.set(rel, entry);
69
+ }
70
+ entry.eventHints.push({ kind, timestamp: timestamp ?? now() });
71
+ }
72
+ function reconcilePath(filePath) {
73
+ const rel = resolveToRelative(filePath);
74
+ let entry = tracked.get(rel);
75
+ if (!entry) {
76
+ entry = {
77
+ baselineHash: undefined,
78
+ currentHash: undefined,
79
+ lastReconciledAt: 0,
80
+ eventHints: [],
81
+ };
82
+ tracked.set(rel, entry);
83
+ }
84
+ // Lazy baseline snapshot
85
+ ensureBaseline(entry, rel);
86
+ // Current hash
87
+ const abs = resolveToAbsolute(rel);
88
+ const current = hashFile(abs);
89
+ entry.currentHash = current;
90
+ entry.lastReconciledAt = now();
91
+ }
92
+ function reconcileKnownPaths() {
93
+ for (const relPath of tracked.keys()) {
94
+ reconcilePath(relPath);
95
+ }
96
+ }
97
+ /**
98
+ * Return surviving session edits: files whose current hash differs from baseline.
99
+ * Sorted by lastReconciledAt ascending (oldest first).
100
+ */
101
+ function getSessionEdits() {
102
+ const results = [];
103
+ for (const [relPath, entry] of tracked) {
104
+ if (entry.baselineHash === undefined || entry.currentHash === undefined) {
105
+ // Not yet reconciled
106
+ continue;
107
+ }
108
+ if (entry.currentHash !== entry.baselineHash) {
109
+ results.push({
110
+ filePath: relPath,
111
+ baselineHash: entry.baselineHash,
112
+ currentHash: entry.currentHash,
113
+ lastReconciledAt: entry.lastReconciledAt,
114
+ });
115
+ }
116
+ }
117
+ results.sort((a, b) => a.lastReconciledAt - b.lastReconciledAt);
118
+ return results;
119
+ }
120
+ /**
121
+ * Focus edit = the last reconciled surviving edit (highest lastReconciledAt).
122
+ */
123
+ function getFocusEdit() {
124
+ const edits = getSessionEdits();
125
+ if (edits.length === 0)
126
+ return null;
127
+ // edits are sorted ascending by lastReconciledAt, so last = most recent
128
+ return edits[edits.length - 1];
129
+ }
130
+ function hasSessionEdits() {
131
+ for (const [, entry] of tracked) {
132
+ if (entry.baselineHash !== undefined &&
133
+ entry.currentHash !== undefined &&
134
+ entry.currentHash !== entry.baselineHash) {
135
+ return true;
136
+ }
137
+ }
138
+ return false;
139
+ }
140
+ /**
141
+ * Force a file to be treated as a session edit without requiring a prior baseline.
142
+ * Used for eventless edits where the host signals a change via transform hook
143
+ * but no file.edited event was emitted to establish a pre-change baseline.
144
+ */
145
+ function forceEdit(filePath, kind, timestamp) {
146
+ const rel = resolveToRelative(filePath);
147
+ let entry = tracked.get(rel);
148
+ if (!entry) {
149
+ entry = {
150
+ baselineHash: undefined,
151
+ currentHash: undefined,
152
+ lastReconciledAt: 0,
153
+ eventHints: [],
154
+ };
155
+ tracked.set(rel, entry);
156
+ }
157
+ // Set a synthetic baseline that will never match real file content
158
+ if (entry.baselineHash === undefined) {
159
+ entry.baselineHash = hashContent(`__FORCED_BASELINE__${rel}`);
160
+ }
161
+ const abs = resolveToAbsolute(rel);
162
+ entry.currentHash = hashFile(abs);
163
+ entry.lastReconciledAt = timestamp ?? now();
164
+ if (kind) {
165
+ entry.eventHints.push({ kind, timestamp: timestamp ?? now() });
166
+ }
167
+ }
168
+ return {
169
+ recordEventHint,
170
+ reconcilePath,
171
+ reconcileKnownPaths,
172
+ getSessionEdits,
173
+ getFocusEdit,
174
+ hasSessionEdits,
175
+ forceEdit,
176
+ };
177
+ }
@@ -0,0 +1,11 @@
1
+ export interface SessionFingerprintInput {
2
+ sessionId?: string | undefined;
3
+ branch: string;
4
+ worktree: string;
5
+ }
6
+ export interface SessionBaselineState<Cursor> {
7
+ fingerprint: string | null;
8
+ cursor: Cursor | null;
9
+ }
10
+ export declare function buildSessionFingerprint(input: SessionFingerprintInput): string;
11
+ export declare function syncSessionBaselineState<Cursor>(state: SessionBaselineState<Cursor>, input: SessionFingerprintInput, captureBaseline: () => Cursor | null): SessionBaselineState<Cursor>;
@@ -0,0 +1,21 @@
1
+ export function buildSessionFingerprint(
2
+ // implements REQ-opencode-kibi-briefing-v6
3
+ input) {
4
+ return [
5
+ input.sessionId?.trim() || "unknown",
6
+ input.branch,
7
+ input.worktree,
8
+ ].join("\0");
9
+ }
10
+ export function syncSessionBaselineState(
11
+ // implements REQ-opencode-kibi-briefing-v6
12
+ state, input, captureBaseline) {
13
+ const fingerprint = buildSessionFingerprint(input);
14
+ if (state.fingerprint === fingerprint) {
15
+ return state;
16
+ }
17
+ return {
18
+ fingerprint,
19
+ cursor: captureBaseline(),
20
+ };
21
+ }
@@ -1,8 +1,7 @@
1
1
  /**
2
2
  * Resolve the configured symbols manifest path using loadKbSyncPaths(worktree),
3
3
  * read the YAML synchronously, and return up to 3 deduped REQ IDs linked to
4
- * the edited file path. Preference is given to relationships[type=implements].target
5
- * (in file order) then static links as a fallback, preserving file order.
4
+ * the edited file path via implements relationships.
6
5
  *
7
6
  * Supports both YAML formats: top-level array and { symbols: [...] } object.
8
7
  * This function is purely synchronous and makes no runtime KB queries.
@@ -1,179 +1,16 @@
1
1
  // implements REQ-opencode-smart-enforcement-v1
2
- import { existsSync, readFileSync } from "node:fs";
3
- import * as path from "node:path";
4
- import { loadKbSyncPaths } from "./file-filter.js";
2
+ import { getFileLinkedTargetsByType } from "./file-entity-links.js";
5
3
  /**
6
4
  * Resolve the configured symbols manifest path using loadKbSyncPaths(worktree),
7
5
  * read the YAML synchronously, and return up to 3 deduped REQ IDs linked to
8
- * the edited file path. Preference is given to relationships[type=implements].target
9
- * (in file order) then static links as a fallback, preserving file order.
6
+ * the edited file path via implements relationships.
10
7
  *
11
8
  * Supports both YAML formats: top-level array and { symbols: [...] } object.
12
9
  * This function is purely synchronous and makes no runtime KB queries.
13
10
  */
14
11
  // implements REQ-opencode-smart-enforcement-v1
15
12
  export function getSourceLinkedRequirementIds(worktree, editedAbsolutePath) {
16
- try {
17
- const paths = loadKbSyncPaths(worktree);
18
- const symbolsPathRaw = paths.symbols;
19
- if (!symbolsPathRaw)
20
- return [];
21
- const symbolsPath = path.isAbsolute(symbolsPathRaw)
22
- ? symbolsPathRaw
23
- : path.join(worktree, symbolsPathRaw);
24
- if (!existsSync(symbolsPath))
25
- return [];
26
- const content = readFileSync(symbolsPath, "utf8");
27
- const symbols = parseSymbolsYaml(content);
28
- const relEdited = path
29
- .relative(worktree, editedAbsolutePath)
30
- .split(path.sep)
31
- .join("/");
32
- const matchedRows = symbols.filter((s) => s.sourceFile === relEdited);
33
- if (matchedRows.length === 0)
34
- return [];
35
- const seen = new Set();
36
- const orderedIds = [];
37
- // First pass: collect implements relationships in file order
38
- for (const row of matchedRows) {
39
- for (const r of row.relationships ?? []) {
40
- if (r.type === "implements") {
41
- const id = r.target;
42
- if (!seen.has(id)) {
43
- seen.add(id);
44
- orderedIds.push(id);
45
- if (orderedIds.length >= 3)
46
- return orderedIds.slice(0, 3);
47
- }
48
- }
49
- }
50
- }
51
- // Second pass: fall back to static links, preserving file order
52
- for (const row of matchedRows) {
53
- for (const l of row.links ?? []) {
54
- if (!seen.has(l)) {
55
- seen.add(l);
56
- orderedIds.push(l);
57
- if (orderedIds.length >= 3)
58
- return orderedIds.slice(0, 3);
59
- }
60
- }
61
- }
62
- return orderedIds.slice(0, 3);
63
- }
64
- catch {
65
- return [];
66
- }
67
- }
68
- // ── Lightweight YAML parser (symbols.yaml subset) ────────────────────
69
- //
70
- // Handles:
71
- // symbols:
72
- // - id: SYM-xxx
73
- // sourceFile: path/to/file
74
- // links:
75
- // - REQ-xxx
76
- // relationships:
77
- // - type: implements
78
- // target: REQ-xxx
79
- //
80
- // And bare array format (no wrapping `symbols:` key):
81
- // - id: SYM-xxx
82
- // ...
83
- function parseSymbolsYaml(content) {
84
- const entries = [];
85
- const lines = content.split("\n");
86
- let current = null;
87
- let section = "none";
88
- let pendingRel = null;
89
- function flushRel() {
90
- if (pendingRel?.type && pendingRel.target && current?.relationships) {
91
- current.relationships.push({
92
- type: pendingRel.type,
93
- target: pendingRel.target,
94
- });
95
- }
96
- pendingRel = null;
97
- }
98
- function flushEntry() {
99
- flushRel();
100
- if (current?.id && current?.sourceFile) {
101
- entries.push(current);
102
- }
103
- current = null;
104
- section = "none";
105
- }
106
- for (const raw of lines) {
107
- if (raw.trim().startsWith("#"))
108
- continue;
109
- // New entry: " - id: ..."
110
- const entryMatch = raw.match(/^\s+-\s+id:\s*(.+)$/);
111
- if (entryMatch) {
112
- flushEntry();
113
- const entryId = entryMatch[1];
114
- if (entryId === undefined)
115
- continue;
116
- current = { id: entryId.trim(), links: [], relationships: [] };
117
- section = "none";
118
- continue;
119
- }
120
- if (!current)
121
- continue;
122
- // sourceFile
123
- const srcMatch = raw.match(/^\s+sourceFile:\s*(.+)$/);
124
- if (srcMatch) {
125
- const sourceFile = srcMatch[1];
126
- if (sourceFile === undefined)
127
- continue;
128
- current.sourceFile = sourceFile.trim();
129
- section = "none";
130
- continue;
131
- }
132
- // links section header
133
- if (/^\s+links:\s*$/.test(raw)) {
134
- flushRel();
135
- section = "links";
136
- continue;
137
- }
138
- // relationships section header
139
- if (/^\s+relationships:\s*$/.test(raw)) {
140
- flushRel();
141
- section = "relationships";
142
- continue;
143
- }
144
- // Link item: " - REQ-xxx"
145
- if (section === "links") {
146
- const linkMatch = raw.match(/^\s+-\s+(REQ-[A-Za-z0-9_-]+)\s*$/);
147
- if (linkMatch) {
148
- const linkId = linkMatch[1];
149
- if (linkId !== undefined && current.links) {
150
- current.links.push(linkId);
151
- }
152
- continue;
153
- }
154
- }
155
- // Relationship type: " - type: implements"
156
- if (section === "relationships") {
157
- const relTypeMatch = raw.match(/^\s+-\s+type:\s*(.+)$/);
158
- if (relTypeMatch) {
159
- flushRel();
160
- const relationType = relTypeMatch[1];
161
- if (relationType === undefined)
162
- continue;
163
- pendingRel = { type: relationType.trim() };
164
- continue;
165
- }
166
- // Relationship target: " target: REQ-..."
167
- const relTargetMatch = raw.match(/^\s+target:\s*(.+)$/);
168
- if (relTargetMatch && pendingRel) {
169
- const target = relTargetMatch[1];
170
- if (target === undefined)
171
- continue;
172
- pendingRel.target = target.trim();
173
- continue;
174
- }
175
- }
176
- }
177
- flushEntry();
178
- return entries;
13
+ // Delegate to the shared file-entity-links resolver with implements-only filter.
14
+ // implements relationships always target REQ- IDs, so no additional filtering needed.
15
+ return getFileLinkedTargetsByType(worktree, editedAbsolutePath, ["implements"]).slice(0, 3);
179
16
  }
@@ -1,4 +1,4 @@
1
- import { hasLegacyToast, hasShowToast, sendToast, } from "./toast.js";
1
+ import { sendToast, } from "./toast.js";
2
2
  // implements REQ-opencode-kibi-plugin-v1
3
3
  export function notifyStartup(client, cfg) {
4
4
  const message = "kibi-opencode started";
@@ -9,39 +9,50 @@ export function notifyStartup(client, cfg) {
9
9
  duration: 4000,
10
10
  };
11
11
  if (!cfg.suppressToast) {
12
- if (hasShowToast(client)) {
13
- void Promise.resolve(sendToast(client, toastPayload))
14
- .then((result) => void Promise.resolve(client.app.log({
15
- body: {
16
- service: "kibi-opencode",
17
- level: "info",
18
- message: "startup toast result",
19
- result: String(result),
20
- ...(cfg.directory ? { directory: cfg.directory } : {}),
21
- },
22
- })).catch((logErr) => {
23
- console.error("[kibi-opencode] startup toast result log failed:", logErr);
24
- }))
25
- .catch((err) => {
26
- console.error("[kibi-opencode] startup toast failed:", err);
27
- void Promise.resolve(client.app.log({
12
+ void sendToast(client, toastPayload).then((result) => {
13
+ const base = {
14
+ service: "kibi-opencode",
15
+ ...(cfg.directory ? { directory: cfg.directory } : {}),
16
+ };
17
+ if (result.status === "delivered") {
18
+ void client.app.log({
28
19
  body: {
29
- service: "kibi-opencode",
20
+ ...base,
21
+ level: "info",
22
+ message: "startup toast delivered",
23
+ transport: result.transport,
24
+ },
25
+ }).catch(() => {
26
+ // Advisory log failure stays silent
27
+ });
28
+ }
29
+ else if (result.status === "unavailable") {
30
+ void client.app.log({
31
+ body: {
32
+ ...base,
33
+ level: "info",
34
+ message: "startup toast unavailable",
35
+ reason: result.reason,
36
+ },
37
+ }).catch(() => {
38
+ // Advisory log failure stays silent
39
+ });
40
+ }
41
+ else if (result.status === "failed") {
42
+ void client.app.log({
43
+ body: {
44
+ ...base,
30
45
  level: "warn",
31
- message: "startup toast failed",
32
- error: String(err),
33
- ...(cfg.directory ? { directory: cfg.directory } : {}),
46
+ message: "startup toast delivery failed",
47
+ transport: result.transport,
48
+ reason: result.reason,
49
+ ...(result.error ? { error: result.error } : {}),
34
50
  },
35
- })).catch((logErr) => {
36
- console.error("[kibi-opencode] startup toast log failed:", logErr);
51
+ }).catch(() => {
52
+ // Advisory log failure stays silent
37
53
  });
38
- });
39
- }
40
- else if (hasLegacyToast(client)) {
41
- void Promise.resolve(sendToast(client, toastPayload)).catch((err) => {
42
- console.error("[kibi-opencode] startup toast failed:", err);
43
- });
44
- }
54
+ }
55
+ });
45
56
  }
46
57
  void Promise.resolve(client.app.log({
47
58
  body: {
@@ -52,6 +63,6 @@ export function notifyStartup(client, cfg) {
52
63
  ...(cfg.directory ? { directory: cfg.directory } : {}),
53
64
  },
54
65
  })).catch((err) => {
55
- console.error("[kibi-opencode] startup log failed:", err);
66
+ // Advisory log failure stays silent
56
67
  });
57
68
  }