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.
- package/README.md +38 -13
- package/dist/brief-delivery-reasons.d.ts +12 -0
- package/dist/brief-delivery-reasons.js +132 -0
- package/dist/brief-intent.d.ts +15 -4
- package/dist/brief-intent.js +78 -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 +37 -0
- package/dist/idle-brief-reader.js +163 -0
- package/dist/idle-brief-runtime.d.ts +48 -0
- package/dist/idle-brief-runtime.js +478 -0
- package/dist/idle-brief-store.d.ts +113 -0
- package/dist/idle-brief-store.js +262 -0
- package/dist/index.d.ts +2 -39
- package/dist/index.js +1 -492
- 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.d.ts +1 -0
- package/dist/logger.js +17 -4
- package/dist/plugin-startup.d.ts +1 -0
- package/dist/plugin-startup.js +11 -2
- package/dist/plugin.d.ts +52 -0
- package/dist/plugin.js +1068 -0
- package/dist/prompt.d.ts +15 -3
- package/dist/prompt.js +106 -36
- package/dist/reconcile-engine.d.ts +15 -0
- package/dist/reconcile-engine.js +112 -0
- package/dist/scheduler.d.ts +13 -2
- package/dist/scheduler.js +86 -7
- 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 +23 -22
- package/dist/toast.js +36 -14
- package/dist/tui-brief-delivery.d.ts +67 -0
- package/dist/tui-brief-delivery.js +279 -0
- package/dist/tui-brief-view-model.d.ts +63 -0
- package/dist/tui-brief-view-model.js +209 -0
- package/dist/tui.d.ts +8 -0
- package/dist/tui.js +413 -0
- package/dist/tui.jsx +120 -0
- package/package.json +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
|
-
|
|
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
|
-
|
|
183
|
-
|
|
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
|
|
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 {
|
|
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
|
|
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
|
-
|
|
17
|
-
|
|
18
|
-
|
|
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
|
}
|
package/dist/startup-notifier.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import {
|
|
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
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
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
|
-
|
|
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
|
-
|
|
33
|
-
|
|
46
|
+
message: "startup toast delivery failed",
|
|
47
|
+
transport: result.transport,
|
|
48
|
+
reason: result.reason,
|
|
49
|
+
...(result.error ? { error: result.error } : {}),
|
|
34
50
|
},
|
|
35
|
-
})
|
|
36
|
-
|
|
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
|
-
|
|
66
|
+
// Advisory log failure stays silent
|
|
56
67
|
});
|
|
57
68
|
}
|