kibi-opencode 0.12.0 → 0.13.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/dist/graph-narrator.d.ts +25 -0
- package/dist/graph-narrator.js +408 -0
- package/dist/idle-brief-runtime.d.ts +7 -0
- package/dist/idle-brief-runtime.js +24 -6
- package/dist/plugin.js +53 -4
- package/dist/prompt.js +6 -3
- package/dist/scheduler.js +2 -0
- package/dist/startup-notifier.js +4 -1
- package/dist/utils/brief-marker.d.ts +19 -0
- package/dist/utils/brief-marker.js +101 -0
- package/package.json +2 -2
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
export interface GraphNarrativeResult {
|
|
2
|
+
headline: string;
|
|
3
|
+
tldr: string;
|
|
4
|
+
domains: Array<{
|
|
5
|
+
name: string;
|
|
6
|
+
changes: string[];
|
|
7
|
+
}>;
|
|
8
|
+
relationshipChanges: string[];
|
|
9
|
+
validationStatus: string;
|
|
10
|
+
}
|
|
11
|
+
export declare function generateGraphNarrative(client: unknown, workspaceCtx: {
|
|
12
|
+
workspaceRoot: string;
|
|
13
|
+
branch: string;
|
|
14
|
+
}, changedEntityIds: string[], changedRelationships: Array<{
|
|
15
|
+
from: string;
|
|
16
|
+
to: string;
|
|
17
|
+
type: string;
|
|
18
|
+
}>, checkResult: {
|
|
19
|
+
count: number;
|
|
20
|
+
violations: Array<{
|
|
21
|
+
rule: string;
|
|
22
|
+
entityId: string;
|
|
23
|
+
description: string;
|
|
24
|
+
}>;
|
|
25
|
+
}): Promise<GraphNarrativeResult | null>;
|
|
@@ -0,0 +1,408 @@
|
|
|
1
|
+
// implements REQ-opencode-kibi-briefing-v6
|
|
2
|
+
function asRecord(value) {
|
|
3
|
+
return typeof value === "object" && value !== null
|
|
4
|
+
? value
|
|
5
|
+
: null;
|
|
6
|
+
}
|
|
7
|
+
function asString(value) {
|
|
8
|
+
return typeof value === "string" ? value : "";
|
|
9
|
+
}
|
|
10
|
+
function asStringArray(value) {
|
|
11
|
+
return Array.isArray(value)
|
|
12
|
+
? value.filter((entry) => typeof entry === "string")
|
|
13
|
+
: [];
|
|
14
|
+
}
|
|
15
|
+
function getSessionApi(client) {
|
|
16
|
+
const root = asRecord(client);
|
|
17
|
+
const session = asRecord(root?.session);
|
|
18
|
+
if (!session) {
|
|
19
|
+
return null;
|
|
20
|
+
}
|
|
21
|
+
const create = session.create;
|
|
22
|
+
const prompt = session.prompt;
|
|
23
|
+
if (typeof create !== "function" || typeof prompt !== "function") {
|
|
24
|
+
return null;
|
|
25
|
+
}
|
|
26
|
+
return {
|
|
27
|
+
create: create,
|
|
28
|
+
prompt: prompt,
|
|
29
|
+
};
|
|
30
|
+
}
|
|
31
|
+
function extractSessionId(response) {
|
|
32
|
+
const root = asRecord(response);
|
|
33
|
+
if (!root) {
|
|
34
|
+
return null;
|
|
35
|
+
}
|
|
36
|
+
const directId = asString(root.id).trim();
|
|
37
|
+
if (directId) {
|
|
38
|
+
return directId;
|
|
39
|
+
}
|
|
40
|
+
const data = asRecord(root.data);
|
|
41
|
+
return asString(data?.id).trim() || null;
|
|
42
|
+
}
|
|
43
|
+
function extractPromptResponseJson(response) {
|
|
44
|
+
const root = asRecord(response);
|
|
45
|
+
if (!root)
|
|
46
|
+
return null;
|
|
47
|
+
const data = asRecord(root.data);
|
|
48
|
+
const parts = Array.isArray(data?.parts)
|
|
49
|
+
? data.parts
|
|
50
|
+
: Array.isArray(root.parts)
|
|
51
|
+
? root.parts
|
|
52
|
+
: null;
|
|
53
|
+
if (!parts)
|
|
54
|
+
return null;
|
|
55
|
+
for (const part of parts) {
|
|
56
|
+
const record = asRecord(part);
|
|
57
|
+
if (record?.type !== "text") {
|
|
58
|
+
continue;
|
|
59
|
+
}
|
|
60
|
+
const text = asString(record.text);
|
|
61
|
+
if (!text) {
|
|
62
|
+
continue;
|
|
63
|
+
}
|
|
64
|
+
try {
|
|
65
|
+
return JSON.parse(text);
|
|
66
|
+
}
|
|
67
|
+
catch {
|
|
68
|
+
return null;
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
return null;
|
|
72
|
+
}
|
|
73
|
+
async function invokeTool(sessionApi, sessionID, tool, args) {
|
|
74
|
+
const response = await sessionApi.prompt({
|
|
75
|
+
sessionID,
|
|
76
|
+
parts: [{ type: "text", text: JSON.stringify({ tool, args }) }],
|
|
77
|
+
tools: { [tool]: true },
|
|
78
|
+
format: {
|
|
79
|
+
type: "json_schema",
|
|
80
|
+
schema: {
|
|
81
|
+
type: ["object", "array"],
|
|
82
|
+
},
|
|
83
|
+
},
|
|
84
|
+
});
|
|
85
|
+
return extractPromptResponseJson(response);
|
|
86
|
+
}
|
|
87
|
+
function inferTypeFromId(id) {
|
|
88
|
+
const prefix = id.split("-")[0]?.toLowerCase() ?? "entity";
|
|
89
|
+
switch (prefix) {
|
|
90
|
+
case "req":
|
|
91
|
+
case "scenario":
|
|
92
|
+
case "test":
|
|
93
|
+
case "adr":
|
|
94
|
+
case "flag":
|
|
95
|
+
case "event":
|
|
96
|
+
case "symbol":
|
|
97
|
+
case "fact":
|
|
98
|
+
return prefix;
|
|
99
|
+
case "scen":
|
|
100
|
+
return "scenario";
|
|
101
|
+
case "sym":
|
|
102
|
+
return "symbol";
|
|
103
|
+
default:
|
|
104
|
+
return prefix;
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
function humanizeType(type, count = 1) {
|
|
108
|
+
const singular = {
|
|
109
|
+
req: "requirement",
|
|
110
|
+
scenario: "scenario",
|
|
111
|
+
test: "test",
|
|
112
|
+
adr: "ADR",
|
|
113
|
+
flag: "flag",
|
|
114
|
+
event: "event",
|
|
115
|
+
symbol: "symbol",
|
|
116
|
+
fact: "fact",
|
|
117
|
+
}[type] ?? type;
|
|
118
|
+
if (count === 1) {
|
|
119
|
+
return singular;
|
|
120
|
+
}
|
|
121
|
+
if (singular === "ADR") {
|
|
122
|
+
return "ADRs";
|
|
123
|
+
}
|
|
124
|
+
return singular.endsWith("s") ? singular : `${singular}s`;
|
|
125
|
+
}
|
|
126
|
+
function humanizeDomain(domain) {
|
|
127
|
+
const normalized = domain.trim().toLowerCase();
|
|
128
|
+
switch (normalized) {
|
|
129
|
+
case "opencode":
|
|
130
|
+
return "OpenCode";
|
|
131
|
+
case "mcp":
|
|
132
|
+
return "MCP";
|
|
133
|
+
case "cli":
|
|
134
|
+
return "CLI";
|
|
135
|
+
case "vscode":
|
|
136
|
+
return "VSCode";
|
|
137
|
+
case "core":
|
|
138
|
+
return "Core";
|
|
139
|
+
case "requirements":
|
|
140
|
+
return "Requirements";
|
|
141
|
+
default:
|
|
142
|
+
return normalized ? normalized.charAt(0).toUpperCase() + normalized.slice(1) : "Changes";
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
function formatList(items) {
|
|
146
|
+
if (items.length === 0)
|
|
147
|
+
return "";
|
|
148
|
+
if (items.length === 1)
|
|
149
|
+
return items[0] ?? "";
|
|
150
|
+
if (items.length === 2)
|
|
151
|
+
return `${items[0]} and ${items[1]}`;
|
|
152
|
+
return `${items.slice(0, -1).join(", ")}, and ${items[items.length - 1]}`;
|
|
153
|
+
}
|
|
154
|
+
function normalizeEntity(record, idFallback) {
|
|
155
|
+
const properties = asRecord(record.properties) ?? {};
|
|
156
|
+
const id = asString(record.id || properties.id).trim() || idFallback;
|
|
157
|
+
const type = asString(record.type).trim() ||
|
|
158
|
+
asString(record.entityType).trim() ||
|
|
159
|
+
inferTypeFromId(id);
|
|
160
|
+
const title = asString(record.title).trim() ||
|
|
161
|
+
asString(properties.title).trim() ||
|
|
162
|
+
id;
|
|
163
|
+
const status = asString(record.status).trim() ||
|
|
164
|
+
asString(properties.status).trim() ||
|
|
165
|
+
asString(properties.change_kind).trim();
|
|
166
|
+
const source = asString(record.source).trim() ||
|
|
167
|
+
asString(properties.source).trim();
|
|
168
|
+
const tags = [
|
|
169
|
+
...asStringArray(record.tags),
|
|
170
|
+
...asStringArray(properties.tags),
|
|
171
|
+
];
|
|
172
|
+
const factKind = asString(record.fact_kind).trim() ||
|
|
173
|
+
asString(properties.fact_kind).trim();
|
|
174
|
+
return {
|
|
175
|
+
id,
|
|
176
|
+
type,
|
|
177
|
+
title,
|
|
178
|
+
status,
|
|
179
|
+
source,
|
|
180
|
+
tags,
|
|
181
|
+
factKind,
|
|
182
|
+
removed: false,
|
|
183
|
+
};
|
|
184
|
+
}
|
|
185
|
+
function normalizeGraph(value) {
|
|
186
|
+
const root = asRecord(value);
|
|
187
|
+
if (!root) {
|
|
188
|
+
return null;
|
|
189
|
+
}
|
|
190
|
+
const edges = Array.isArray(root.edges)
|
|
191
|
+
? root.edges
|
|
192
|
+
.map((edge) => {
|
|
193
|
+
const record = asRecord(edge);
|
|
194
|
+
if (!record)
|
|
195
|
+
return null;
|
|
196
|
+
const from = asString(record.from).trim();
|
|
197
|
+
const to = asString(record.to).trim();
|
|
198
|
+
const type = asString(record.type).trim();
|
|
199
|
+
return from && to && type ? { from, to, type } : null;
|
|
200
|
+
})
|
|
201
|
+
.filter((edge) => edge !== null)
|
|
202
|
+
: [];
|
|
203
|
+
return { edges };
|
|
204
|
+
}
|
|
205
|
+
function describeEntity(entity, id) {
|
|
206
|
+
if (!entity) {
|
|
207
|
+
return id;
|
|
208
|
+
}
|
|
209
|
+
return `${entity.title} (${entity.id})`;
|
|
210
|
+
}
|
|
211
|
+
function inferDomain(entity) {
|
|
212
|
+
const source = entity.source.replaceAll("\\", "/");
|
|
213
|
+
const packageMatch = source.match(/packages\/([^/]+)\//);
|
|
214
|
+
if (packageMatch?.[1]) {
|
|
215
|
+
return humanizeDomain(packageMatch[1]);
|
|
216
|
+
}
|
|
217
|
+
const tagDomain = entity.tags.find((tag) => ["cli", "mcp", "vscode", "opencode", "core"].includes(tag.toLowerCase()));
|
|
218
|
+
if (tagDomain) {
|
|
219
|
+
return humanizeDomain(tagDomain);
|
|
220
|
+
}
|
|
221
|
+
if (["req", "scenario", "test"].includes(entity.type)) {
|
|
222
|
+
return "Requirements";
|
|
223
|
+
}
|
|
224
|
+
return humanizeDomain(entity.type);
|
|
225
|
+
}
|
|
226
|
+
function classifyRelationship(relationship, entities) {
|
|
227
|
+
const fromEntity = entities.get(relationship.from);
|
|
228
|
+
const toEntity = entities.get(relationship.to);
|
|
229
|
+
const fromText = describeEntity(fromEntity, relationship.from);
|
|
230
|
+
const toText = describeEntity(toEntity, relationship.to);
|
|
231
|
+
switch (relationship.type) {
|
|
232
|
+
case "supersedes":
|
|
233
|
+
return `${toText} was superseded by ${fromText}`;
|
|
234
|
+
case "implements":
|
|
235
|
+
return `${fromText} now implements ${toText}`;
|
|
236
|
+
case "covered_by":
|
|
237
|
+
return `${fromText} gained test coverage via ${toText}`;
|
|
238
|
+
case "verified_by":
|
|
239
|
+
return `${fromText} is verified by ${toText}`;
|
|
240
|
+
case "specified_by":
|
|
241
|
+
return `${fromText} is specified by ${toText}`;
|
|
242
|
+
case "requires_property":
|
|
243
|
+
return `${fromText} constrains property ${toText}`;
|
|
244
|
+
case "constrains":
|
|
245
|
+
if (toEntity?.type === "fact" && toEntity.factKind === "subject") {
|
|
246
|
+
return `${fromText} is linked to fact ${toText}`;
|
|
247
|
+
}
|
|
248
|
+
return `${fromText} is linked to ${toText}`;
|
|
249
|
+
default:
|
|
250
|
+
return null;
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
function uniqueStrings(items) {
|
|
254
|
+
return [...new Set(items.filter(Boolean))];
|
|
255
|
+
}
|
|
256
|
+
function buildEntityChange(entity, relationships, graph) {
|
|
257
|
+
const label = describeEntity(entity, entity.id);
|
|
258
|
+
if (entity.removed) {
|
|
259
|
+
return `${label} was removed`;
|
|
260
|
+
}
|
|
261
|
+
if (entity.status === "superseded") {
|
|
262
|
+
return `${label} was marked superseded`;
|
|
263
|
+
}
|
|
264
|
+
const hasRelationshipChange = relationships.some((relationship) => relationship.from === entity.id || relationship.to === entity.id);
|
|
265
|
+
const hasGraphEdges = (graph?.edges.length ?? 0) > 0;
|
|
266
|
+
if (!hasRelationshipChange && !hasGraphEdges) {
|
|
267
|
+
return `${label} was created`;
|
|
268
|
+
}
|
|
269
|
+
return `${label} was updated`;
|
|
270
|
+
}
|
|
271
|
+
function summarizeTypes(entities) {
|
|
272
|
+
const counts = new Map();
|
|
273
|
+
for (const entity of entities) {
|
|
274
|
+
counts.set(entity.type, (counts.get(entity.type) ?? 0) + 1);
|
|
275
|
+
}
|
|
276
|
+
return formatList([...counts.entries()].map(([type, count]) => `${count} ${humanizeType(type, count)}`));
|
|
277
|
+
}
|
|
278
|
+
function summarizeTopChanges(domainChanges, relationshipChanges) {
|
|
279
|
+
const focus = relationshipChanges.length > 0 ? relationshipChanges : domainChanges;
|
|
280
|
+
const selected = focus.slice(0, 2);
|
|
281
|
+
return selected.length > 0 ? `Key changes: ${selected.join("; ")}.` : "";
|
|
282
|
+
}
|
|
283
|
+
function validationStatusText(checkResult) {
|
|
284
|
+
if (checkResult.count <= 0) {
|
|
285
|
+
return "All checks pass";
|
|
286
|
+
}
|
|
287
|
+
return `${checkResult.count} validation issue${checkResult.count === 1 ? "" : "s"}`;
|
|
288
|
+
}
|
|
289
|
+
async function loadEntity(sessionApi, sessionID, id) {
|
|
290
|
+
const response = await invokeTool(sessionApi, sessionID, "kb_query", { id, limit: 1 });
|
|
291
|
+
if (!Array.isArray(response)) {
|
|
292
|
+
throw new Error(`kb_query returned unsupported payload for ${id}`);
|
|
293
|
+
}
|
|
294
|
+
if (response.length === 0) {
|
|
295
|
+
return {
|
|
296
|
+
id,
|
|
297
|
+
type: inferTypeFromId(id),
|
|
298
|
+
title: id,
|
|
299
|
+
status: "removed",
|
|
300
|
+
source: "",
|
|
301
|
+
tags: [],
|
|
302
|
+
factKind: "",
|
|
303
|
+
removed: true,
|
|
304
|
+
};
|
|
305
|
+
}
|
|
306
|
+
const record = asRecord(response[0]);
|
|
307
|
+
if (!record) {
|
|
308
|
+
throw new Error(`kb_query returned invalid entity for ${id}`);
|
|
309
|
+
}
|
|
310
|
+
return normalizeEntity(record, id);
|
|
311
|
+
}
|
|
312
|
+
async function loadGraph(sessionApi, sessionID, id) {
|
|
313
|
+
const response = await invokeTool(sessionApi, sessionID, "kb_graph", {
|
|
314
|
+
seedIds: [id],
|
|
315
|
+
direction: "both",
|
|
316
|
+
depth: 2,
|
|
317
|
+
maxNodes: 100,
|
|
318
|
+
maxEdges: 200,
|
|
319
|
+
});
|
|
320
|
+
return normalizeGraph(response);
|
|
321
|
+
}
|
|
322
|
+
// implements REQ-opencode-kibi-briefing-v2
|
|
323
|
+
export async function generateGraphNarrative(client, workspaceCtx, changedEntityIds, changedRelationships, checkResult) {
|
|
324
|
+
const uniqueIds = uniqueStrings(changedEntityIds);
|
|
325
|
+
if (uniqueIds.length === 0) {
|
|
326
|
+
return null;
|
|
327
|
+
}
|
|
328
|
+
const sessionApi = getSessionApi(client);
|
|
329
|
+
if (!sessionApi) {
|
|
330
|
+
return null;
|
|
331
|
+
}
|
|
332
|
+
let sessionID = null;
|
|
333
|
+
try {
|
|
334
|
+
const worker = await sessionApi.create({
|
|
335
|
+
directory: workspaceCtx.workspaceRoot,
|
|
336
|
+
title: `Kibi Graph Narrator (${workspaceCtx.branch})`,
|
|
337
|
+
});
|
|
338
|
+
sessionID = extractSessionId(worker);
|
|
339
|
+
}
|
|
340
|
+
catch {
|
|
341
|
+
return null;
|
|
342
|
+
}
|
|
343
|
+
if (!sessionID) {
|
|
344
|
+
return null;
|
|
345
|
+
}
|
|
346
|
+
const entities = new Map();
|
|
347
|
+
const graphs = new Map();
|
|
348
|
+
for (const id of uniqueIds) {
|
|
349
|
+
let entity;
|
|
350
|
+
try {
|
|
351
|
+
entity = await loadEntity(sessionApi, sessionID, id);
|
|
352
|
+
}
|
|
353
|
+
catch {
|
|
354
|
+
continue;
|
|
355
|
+
}
|
|
356
|
+
if (!entity) {
|
|
357
|
+
continue;
|
|
358
|
+
}
|
|
359
|
+
entities.set(id, entity);
|
|
360
|
+
if (entity.removed) {
|
|
361
|
+
graphs.set(id, null);
|
|
362
|
+
continue;
|
|
363
|
+
}
|
|
364
|
+
try {
|
|
365
|
+
graphs.set(id, await loadGraph(sessionApi, sessionID, id));
|
|
366
|
+
}
|
|
367
|
+
catch {
|
|
368
|
+
graphs.set(id, null);
|
|
369
|
+
}
|
|
370
|
+
}
|
|
371
|
+
const entityList = [...entities.values()];
|
|
372
|
+
if (entityList.length === 0) {
|
|
373
|
+
return null;
|
|
374
|
+
}
|
|
375
|
+
const relationshipChanges = uniqueStrings(changedRelationships
|
|
376
|
+
.map((relationship) => classifyRelationship(relationship, entities))
|
|
377
|
+
.filter((sentence) => !!sentence));
|
|
378
|
+
const domainMap = new Map();
|
|
379
|
+
for (const entity of entityList) {
|
|
380
|
+
const domain = inferDomain(entity);
|
|
381
|
+
const changes = domainMap.get(domain) ?? [];
|
|
382
|
+
changes.push(buildEntityChange(entity, changedRelationships, graphs.get(entity.id) ?? null));
|
|
383
|
+
domainMap.set(domain, changes);
|
|
384
|
+
}
|
|
385
|
+
const domains = [...domainMap.entries()]
|
|
386
|
+
.map(([name, changes]) => ({ name, changes: uniqueStrings(changes) }))
|
|
387
|
+
.sort((left, right) => left.name.localeCompare(right.name));
|
|
388
|
+
const domainNames = domains.map((domain) => domain.name);
|
|
389
|
+
const headline = `${summarizeTypes(entityList)} changed${domainNames.length > 1 ? ` across ${formatList(domainNames)} domains` : ""}.`;
|
|
390
|
+
const allDomainChanges = domains.flatMap((domain) => domain.changes);
|
|
391
|
+
const validationStatus = validationStatusText(checkResult);
|
|
392
|
+
const tldr = [
|
|
393
|
+
headline,
|
|
394
|
+
summarizeTopChanges(allDomainChanges, relationshipChanges),
|
|
395
|
+
validationStatus === "All checks pass"
|
|
396
|
+
? "Validation remains clean."
|
|
397
|
+
: `${validationStatus}.`,
|
|
398
|
+
]
|
|
399
|
+
.filter(Boolean)
|
|
400
|
+
.join(" ");
|
|
401
|
+
return {
|
|
402
|
+
headline,
|
|
403
|
+
tldr,
|
|
404
|
+
domains,
|
|
405
|
+
relationshipChanges,
|
|
406
|
+
validationStatus,
|
|
407
|
+
};
|
|
408
|
+
}
|
|
@@ -42,7 +42,14 @@ export interface IdleBriefingResult {
|
|
|
42
42
|
regressionRisks?: IdleBriefStatement[];
|
|
43
43
|
missingEvidence?: IdleBriefStatement[];
|
|
44
44
|
}
|
|
45
|
+
type IdleBriefRelationship = {
|
|
46
|
+
from: string;
|
|
47
|
+
to: string;
|
|
48
|
+
type: string;
|
|
49
|
+
};
|
|
45
50
|
export declare function generateIdleBrief(client: unknown, workspaceCtx: BriefingWorkspaceCtx, auditDelta: AuditDelta, sessionId: string, options?: {
|
|
46
51
|
sourceFiles?: string[];
|
|
47
52
|
changedEntityIds?: string[];
|
|
53
|
+
relationships?: IdleBriefRelationship[];
|
|
48
54
|
}): Promise<IdleBriefResult>;
|
|
55
|
+
export {};
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
// implements REQ-opencode-kibi-briefing-v4
|
|
2
2
|
import { buildBriefingContext } from "./brief-intent.js";
|
|
3
3
|
import { buildDeliveryReasons } from "./brief-delivery-reasons.js";
|
|
4
|
+
import { generateGraphNarrative } from "./graph-narrator.js";
|
|
4
5
|
import { atomicWriteBrief, pruneOldBriefs, resolveBriefFilePath, } from "./idle-brief-paths.js";
|
|
5
6
|
import { computeContentHash, createBriefId, } from "./idle-brief-store.js";
|
|
6
7
|
import { reconcileAuditEntries } from "./reconcile-engine.js";
|
|
@@ -318,7 +319,7 @@ function buildChangeNarrative(auditDelta) {
|
|
|
318
319
|
}
|
|
319
320
|
return lines;
|
|
320
321
|
}
|
|
321
|
-
function buildEnvelopeParts(briefId, type, sessionId, branch, createdAt, auditDelta, summary, counts, checkResult, briefingResult, deliveryReasons) {
|
|
322
|
+
function buildEnvelopeParts(briefId, type, sessionId, branch, createdAt, auditDelta, summary, counts, checkResult, briefingResult, changeNarrative, deliveryReasons) {
|
|
322
323
|
const reconciled = reconcileAuditEntries(auditDelta.entries);
|
|
323
324
|
return {
|
|
324
325
|
schemaVersion: "2.0",
|
|
@@ -350,7 +351,7 @@ function buildEnvelopeParts(briefId, type, sessionId, branch, createdAt, auditDe
|
|
|
350
351
|
tldr: briefingResult.tldr || summary,
|
|
351
352
|
promptBlock: briefingResult.promptBlock,
|
|
352
353
|
citations: briefingResult.citations,
|
|
353
|
-
changeNarrative
|
|
354
|
+
changeNarrative,
|
|
354
355
|
...(deliveryReasons ? { deliveryReasons } : {}),
|
|
355
356
|
...(briefingResult.constraints && briefingResult.constraints.length > 0
|
|
356
357
|
? { constraints: briefingResult.constraints }
|
|
@@ -388,11 +389,15 @@ export async function generateIdleBrief(client, workspaceCtx, auditDelta, sessio
|
|
|
388
389
|
: derivedSourceFiles.length > 0
|
|
389
390
|
? derivedSourceFiles
|
|
390
391
|
: [auditDelta.entries[0]?.entityId ?? "unknown"];
|
|
392
|
+
const relationshipEntityIds = (options?.relationships ?? []).flatMap((relationship) => [relationship.from, relationship.to]);
|
|
393
|
+
const mergedChangedEntityIds = options?.changedEntityIds
|
|
394
|
+
? [...new Set([...options.changedEntityIds, ...relationshipEntityIds])]
|
|
395
|
+
: relationshipEntityIds.length > 0
|
|
396
|
+
? [...new Set(relationshipEntityIds)]
|
|
397
|
+
: undefined;
|
|
391
398
|
const briefingContext = buildBriefingContext({
|
|
392
399
|
sourceFiles,
|
|
393
|
-
...(
|
|
394
|
-
? { changedEntityIds: options.changedEntityIds }
|
|
395
|
-
: {}),
|
|
400
|
+
...(mergedChangedEntityIds ? { changedEntityIds: mergedChangedEntityIds } : {}),
|
|
396
401
|
});
|
|
397
402
|
const { seedIds } = briefingContext;
|
|
398
403
|
let checkResult;
|
|
@@ -431,6 +436,12 @@ export async function generateIdleBrief(client, workspaceCtx, auditDelta, sessio
|
|
|
431
436
|
const isSuccess = violationsCount === 0;
|
|
432
437
|
const type = isSuccess ? "success" : "warning";
|
|
433
438
|
const summary = computeSummary(counts, violationsCount);
|
|
439
|
+
const changedEntityIdsForNarrative = mergedChangedEntityIds ?? [
|
|
440
|
+
...reconciled.added.map((item) => item.id),
|
|
441
|
+
...reconciled.modified.map((item) => item.id),
|
|
442
|
+
...reconciled.removed.map((item) => item.id),
|
|
443
|
+
];
|
|
444
|
+
const changedRelationships = options?.relationships ?? [];
|
|
434
445
|
const deliveryReasons = buildDeliveryReasons({
|
|
435
446
|
entitiesAdded: reconciled.added
|
|
436
447
|
.filter((item) => item.id !== "workspace-sync")
|
|
@@ -444,6 +455,13 @@ export async function generateIdleBrief(client, workspaceCtx, auditDelta, sessio
|
|
|
444
455
|
relationshipsChanged: counts.relationshipsChanged,
|
|
445
456
|
validationCount: checkResult.count,
|
|
446
457
|
});
|
|
458
|
+
const graphNarrative = await generateGraphNarrative(client, workspaceCtx, changedEntityIdsForNarrative, changedRelationships, checkResult);
|
|
459
|
+
const changeNarrative = graphNarrative?.relationshipChanges.length || graphNarrative?.domains.length
|
|
460
|
+
? [
|
|
461
|
+
...graphNarrative.relationshipChanges,
|
|
462
|
+
...graphNarrative.domains.flatMap((domain) => domain.changes),
|
|
463
|
+
]
|
|
464
|
+
: buildChangeNarrative(auditDelta);
|
|
447
465
|
if (counts.entitiesAdded === 0 &&
|
|
448
466
|
counts.entitiesModified === 0 &&
|
|
449
467
|
counts.entitiesRemoved === 0 &&
|
|
@@ -455,7 +473,7 @@ export async function generateIdleBrief(client, workspaceCtx, auditDelta, sessio
|
|
|
455
473
|
const briefId = createBriefId();
|
|
456
474
|
const timestamp = Date.now();
|
|
457
475
|
const createdAt = new Date().toISOString();
|
|
458
|
-
const envelopeWithoutHash = buildEnvelopeParts(briefId, type, sessionId, workspaceCtx.branch, createdAt, auditDelta, summary, counts, checkResult, briefingResult, deliveryReasons);
|
|
476
|
+
const envelopeWithoutHash = buildEnvelopeParts(briefId, type, sessionId, workspaceCtx.branch, createdAt, auditDelta, summary, counts, checkResult, briefingResult, changeNarrative, deliveryReasons);
|
|
459
477
|
const contentHash = computeContentHash(envelopeWithoutHash);
|
|
460
478
|
const envelope = {
|
|
461
479
|
...envelopeWithoutHash,
|
package/dist/plugin.js
CHANGED
|
@@ -26,6 +26,7 @@ import { getSessionTracker } from "./session-tracker.js";
|
|
|
26
26
|
import { notifyStartup, } from "./startup-notifier.js";
|
|
27
27
|
import { sendToast, } from "./toast.js";
|
|
28
28
|
import { announceBriefTui, } from "./tui-brief-delivery.js";
|
|
29
|
+
import { deletePendingBriefMarkers, loadPendingBriefMarkers, } from "./utils/brief-marker.js";
|
|
29
30
|
import * as fs from "node:fs";
|
|
30
31
|
function deriveFileBucket(kind) {
|
|
31
32
|
return kind;
|
|
@@ -43,6 +44,17 @@ function resolveIdleBriefDeliveryDelayMs(worktree) {
|
|
|
43
44
|
return 0;
|
|
44
45
|
return Math.min(60_000, Math.trunc(configValue));
|
|
45
46
|
}
|
|
47
|
+
function readKibiOpencodePackageVersion() {
|
|
48
|
+
try {
|
|
49
|
+
const packageJson = JSON.parse(fs.readFileSync(new URL("../package.json", import.meta.url), "utf-8"));
|
|
50
|
+
return typeof packageJson.version === "string"
|
|
51
|
+
? packageJson.version
|
|
52
|
+
: undefined;
|
|
53
|
+
}
|
|
54
|
+
catch {
|
|
55
|
+
return undefined;
|
|
56
|
+
}
|
|
57
|
+
}
|
|
46
58
|
const startupNotifyGlobals = globalThis;
|
|
47
59
|
/**
|
|
48
60
|
* Lint requirement documents for embedded scenarios/tests and oversized content.
|
|
@@ -343,6 +355,17 @@ const kibiOpencodePlugin = async (input) => {
|
|
|
343
355
|
// Gather session edits
|
|
344
356
|
const sessionEdits = sessionEditState.getSessionEdits();
|
|
345
357
|
const sourceFiles = sessionEdits.map((e) => e.filePath);
|
|
358
|
+
const markerResult = loadPendingBriefMarkers(idleWorkspaceRoot, idleBranch);
|
|
359
|
+
for (const issue of markerResult.issues) {
|
|
360
|
+
logger.warn("idle-brief.marker-invalid", {
|
|
361
|
+
event: "idle_brief_marker_invalid",
|
|
362
|
+
branch: idleBranch,
|
|
363
|
+
filePath: issue.filePath,
|
|
364
|
+
reason: issue.reason,
|
|
365
|
+
});
|
|
366
|
+
}
|
|
367
|
+
const markerEntityIds = markerResult.entityIds;
|
|
368
|
+
const markerRelationships = markerResult.relationships;
|
|
346
369
|
const snapshotBeforeSync = getKbSnapshotFingerprint(idleWorkspaceRoot, idleBranch);
|
|
347
370
|
if (scheduler) {
|
|
348
371
|
const idleSyncBlocked = runtimeOverlay.primaryCause === "scheduler_sync_failed";
|
|
@@ -386,9 +409,33 @@ const kibiOpencodePlugin = async (input) => {
|
|
|
386
409
|
...reconciled.modified.map((e) => e.id),
|
|
387
410
|
...reconciled.removed.map((e) => e.id),
|
|
388
411
|
];
|
|
389
|
-
const
|
|
390
|
-
|
|
391
|
-
|
|
412
|
+
const mergedChangedEntityIds = [
|
|
413
|
+
...new Set([...changedEntityIds, ...markerEntityIds]),
|
|
414
|
+
];
|
|
415
|
+
const mergedSourceFiles = [...new Set([...sourceFiles, ...markerEntityIds])];
|
|
416
|
+
const result = await generateIdleBrief(input.client, workspaceCtx, auditDelta, input.sessionId ?? "unknown", mergedSourceFiles.length > 0
|
|
417
|
+
? {
|
|
418
|
+
sourceFiles: mergedSourceFiles,
|
|
419
|
+
changedEntityIds: mergedChangedEntityIds,
|
|
420
|
+
relationships: markerRelationships,
|
|
421
|
+
}
|
|
422
|
+
: mergedChangedEntityIds.length > 0
|
|
423
|
+
? {
|
|
424
|
+
changedEntityIds: mergedChangedEntityIds,
|
|
425
|
+
relationships: markerRelationships,
|
|
426
|
+
}
|
|
427
|
+
: undefined);
|
|
428
|
+
if (result.success) {
|
|
429
|
+
const deleteResult = await deletePendingBriefMarkers(markerResult.markerPaths);
|
|
430
|
+
for (const issue of deleteResult.issues) {
|
|
431
|
+
logger.warn("idle-brief.marker-delete-failed", {
|
|
432
|
+
event: "idle_brief_marker_delete_failed",
|
|
433
|
+
branch: idleBranch,
|
|
434
|
+
filePath: issue.filePath,
|
|
435
|
+
reason: issue.reason,
|
|
436
|
+
});
|
|
437
|
+
}
|
|
438
|
+
}
|
|
392
439
|
if (result.success && result.envelope) {
|
|
393
440
|
const envelope = result.envelope;
|
|
394
441
|
// Dedupe by semantic contentHash — persisted envelope is the delivery authority
|
|
@@ -961,7 +1008,7 @@ const kibiOpencodePlugin = async (input) => {
|
|
|
961
1008
|
overlay_cause: runtimeOverlay.primaryCause ?? null,
|
|
962
1009
|
});
|
|
963
1010
|
// Emit completion-reminder log only when prompt-visible reminder text is present
|
|
964
|
-
const REMINDER_TEXT = "
|
|
1011
|
+
const REMINDER_TEXT = "Kibi impact evidence is required before completion/commit: run `kb_check` before completing this task.";
|
|
965
1012
|
if (cfg.guidance.smartEnforcement.completionReminder &&
|
|
966
1013
|
!maintenanceDegraded &&
|
|
967
1014
|
guidance.includes(REMINDER_TEXT)) {
|
|
@@ -1057,9 +1104,11 @@ const kibiOpencodePlugin = async (input) => {
|
|
|
1057
1104
|
setTimeout(callback, delayMs);
|
|
1058
1105
|
});
|
|
1059
1106
|
scheduleStartupNotify(() => {
|
|
1107
|
+
const version = readKibiOpencodePackageVersion();
|
|
1060
1108
|
notifyStartup(makeStartupClient(client), {
|
|
1061
1109
|
suppressToast: cfg.ux.toastStartup === false,
|
|
1062
1110
|
directory: input.directory,
|
|
1111
|
+
...(version ? { version } : {}),
|
|
1063
1112
|
});
|
|
1064
1113
|
}, 2000);
|
|
1065
1114
|
}
|
package/dist/prompt.js
CHANGED
|
@@ -131,13 +131,15 @@ Requirement edits need policy alignment. Run kb_check with required-fields and n
|
|
|
131
131
|
|
|
132
132
|
Production code: use \`implements\` (symbol→req) for requirement ownership. Test code: use \`executable_for\` (symbol→test).
|
|
133
133
|
- \`covered_by\` is coverage evidence only
|
|
134
|
-
- Prefer scenario-first: req→scenario→test when scenarios exist
|
|
134
|
+
- Prefer scenario-first: req→scenario→test when scenarios exist
|
|
135
|
+
- Kibi impact evidence is required before completion/commit`,
|
|
135
136
|
traceability_candidate: `📝 **Code changes detected**
|
|
136
137
|
|
|
137
138
|
Production code: use \`implements\` (symbol→req) for requirement ownership. Test code: use \`executable_for\` (symbol→test).
|
|
138
139
|
- \`covered_by\` is coverage evidence only
|
|
139
140
|
- Prefer scenario-first: req→scenario→test when scenarios exist
|
|
140
|
-
- Route durable knowledge comments to KB entities, not inline comments
|
|
141
|
+
- Route durable knowledge comments to KB entities, not inline comments
|
|
142
|
+
- documentation/symbols.yaml refresh is required when extraction output changes; do not revert as scope creep`,
|
|
141
143
|
manual_kb_edit: `⚠️ **WARNING: Direct .kb/ edits bypass validation**
|
|
142
144
|
|
|
143
145
|
The Kibi knowledge base is managed through public MCP tools. Direct manual edits to .kb/** can cause inconsistencies.
|
|
@@ -426,7 +428,7 @@ The Kibi workspace is in a maintenance-degraded state. Guidance remains advisory
|
|
|
426
428
|
REMINDER_RISK_CLASSES.includes(riskClass) &&
|
|
427
429
|
posture !== "root_uninitialized" &&
|
|
428
430
|
posture !== "root_partial") {
|
|
429
|
-
finalBlock = `${finalBlock}\n-
|
|
431
|
+
finalBlock = `${finalBlock}\n- Kibi impact evidence is required before completion/commit: run \`kb_check\` before completing this task.`;
|
|
430
432
|
}
|
|
431
433
|
// Return: sentinel + one targeted block (or just sentinel if no block)
|
|
432
434
|
return finalBlock
|
|
@@ -502,6 +504,7 @@ Dogfood note for this repo: OpenCode here uses local built \`kibi-mcp\` and \`ki
|
|
|
502
504
|
4. **Document intent**: If you are about to explain code, STOP. Route that explanation to kb_upsert instead of inline comments.
|
|
503
505
|
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).
|
|
504
506
|
6. **Validate**: Run kb_check after KB mutations to catch violations early.
|
|
507
|
+
7. **Before completion/commit**: Kibi impact evidence is required before completion/commit. If extraction output changes, refresh documentation/symbols.yaml and do not revert that update as scope creep.
|
|
505
508
|
|
|
506
509
|
**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
510
|
}
|
package/dist/scheduler.js
CHANGED
|
@@ -235,6 +235,8 @@ function truncateSyncOutput(value) {
|
|
|
235
235
|
}
|
|
236
236
|
return value;
|
|
237
237
|
}
|
|
238
|
+
// Background sync runner: uses default sync (no --refresh-symbol-coordinates)
|
|
239
|
+
// to avoid writing committed coordinate artifacts during automatic background execution.
|
|
238
240
|
async function runKibiSync(worktree) {
|
|
239
241
|
return new Promise((resolve) => {
|
|
240
242
|
try {
|
package/dist/startup-notifier.js
CHANGED
|
@@ -2,10 +2,13 @@ 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";
|
|
5
|
+
const displayMessage = cfg.version
|
|
6
|
+
? `${message} (v${cfg.version})`
|
|
7
|
+
: message;
|
|
5
8
|
const toastPayload = {
|
|
6
9
|
variant: "success",
|
|
7
10
|
title: "Kibi OpenCode",
|
|
8
|
-
message,
|
|
11
|
+
message: displayMessage,
|
|
9
12
|
duration: 4000,
|
|
10
13
|
};
|
|
11
14
|
if (!cfg.suppressToast) {
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
export interface PendingBriefMarkerIssue {
|
|
2
|
+
filePath: string;
|
|
3
|
+
reason: "parse" | "schema" | "delete";
|
|
4
|
+
}
|
|
5
|
+
export interface PendingBriefMarkersResult {
|
|
6
|
+
entityIds: string[];
|
|
7
|
+
relationships: Array<{
|
|
8
|
+
from: string;
|
|
9
|
+
to: string;
|
|
10
|
+
type: string;
|
|
11
|
+
}>;
|
|
12
|
+
markerPaths: string[];
|
|
13
|
+
issues: PendingBriefMarkerIssue[];
|
|
14
|
+
}
|
|
15
|
+
export declare function loadPendingBriefMarkers(workspaceRoot: string, branch: string): PendingBriefMarkersResult;
|
|
16
|
+
export declare function deletePendingBriefMarkers(markerPaths: string[]): Promise<{
|
|
17
|
+
deletedCount: number;
|
|
18
|
+
issues: PendingBriefMarkerIssue[];
|
|
19
|
+
}>;
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
import * as fs from "node:fs";
|
|
2
|
+
import * as path from "node:path";
|
|
3
|
+
// implements REQ-opencode-kibi-briefing-v2
|
|
4
|
+
export function loadPendingBriefMarkers(workspaceRoot, branch) {
|
|
5
|
+
const pendingDir = path.join(workspaceRoot, ".kb", "briefs", "pending");
|
|
6
|
+
if (!fs.existsSync(pendingDir)) {
|
|
7
|
+
return { entityIds: [], relationships: [], markerPaths: [], issues: [] };
|
|
8
|
+
}
|
|
9
|
+
let entries;
|
|
10
|
+
try {
|
|
11
|
+
entries = fs.readdirSync(pendingDir, { withFileTypes: true });
|
|
12
|
+
}
|
|
13
|
+
catch {
|
|
14
|
+
return { entityIds: [], relationships: [], markerPaths: [], issues: [] };
|
|
15
|
+
}
|
|
16
|
+
const issues = [];
|
|
17
|
+
const markerPaths = [];
|
|
18
|
+
const entityIds = [];
|
|
19
|
+
const seenEntityIds = new Set();
|
|
20
|
+
const relationshipMap = new Map();
|
|
21
|
+
for (const entry of entries
|
|
22
|
+
.filter((dirent) => dirent.isFile() && dirent.name.endsWith(".json"))
|
|
23
|
+
.sort((a, b) => a.name.localeCompare(b.name))) {
|
|
24
|
+
const filePath = path.join(pendingDir, entry.name);
|
|
25
|
+
let payload;
|
|
26
|
+
try {
|
|
27
|
+
payload = parsePendingBriefMarker(JSON.parse(fs.readFileSync(filePath, "utf8")));
|
|
28
|
+
}
|
|
29
|
+
catch (error) {
|
|
30
|
+
issues.push({
|
|
31
|
+
filePath,
|
|
32
|
+
reason: error instanceof SyntaxError ? "parse" : "schema",
|
|
33
|
+
});
|
|
34
|
+
continue;
|
|
35
|
+
}
|
|
36
|
+
if (payload.branch !== branch) {
|
|
37
|
+
continue;
|
|
38
|
+
}
|
|
39
|
+
markerPaths.push(filePath);
|
|
40
|
+
for (const entityId of payload.entityIds) {
|
|
41
|
+
if (seenEntityIds.has(entityId)) {
|
|
42
|
+
continue;
|
|
43
|
+
}
|
|
44
|
+
seenEntityIds.add(entityId);
|
|
45
|
+
entityIds.push(entityId);
|
|
46
|
+
}
|
|
47
|
+
for (const relationship of payload.relationships) {
|
|
48
|
+
relationshipMap.set(`${relationship.type}\u0000${relationship.from}\u0000${relationship.to}`, relationship);
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
return {
|
|
52
|
+
entityIds,
|
|
53
|
+
relationships: [...relationshipMap.values()],
|
|
54
|
+
markerPaths,
|
|
55
|
+
issues,
|
|
56
|
+
};
|
|
57
|
+
}
|
|
58
|
+
// implements REQ-opencode-kibi-briefing-v2
|
|
59
|
+
export async function deletePendingBriefMarkers(markerPaths) {
|
|
60
|
+
let deletedCount = 0;
|
|
61
|
+
const issues = [];
|
|
62
|
+
for (const markerPath of markerPaths) {
|
|
63
|
+
try {
|
|
64
|
+
const existedBeforeDelete = fs.existsSync(markerPath);
|
|
65
|
+
await fs.promises.rm(markerPath, { force: true });
|
|
66
|
+
if (existedBeforeDelete && !fs.existsSync(markerPath)) {
|
|
67
|
+
deletedCount += 1;
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
catch {
|
|
71
|
+
issues.push({ filePath: markerPath, reason: "delete" });
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
return { deletedCount, issues };
|
|
75
|
+
}
|
|
76
|
+
function parsePendingBriefMarker(value) {
|
|
77
|
+
if (!value || typeof value !== "object") {
|
|
78
|
+
throw new Error("Invalid marker payload");
|
|
79
|
+
}
|
|
80
|
+
const record = value;
|
|
81
|
+
const branch = typeof record.branch === "string" ? record.branch.trim() : "";
|
|
82
|
+
const entityIds = Array.isArray(record.entityIds)
|
|
83
|
+
? record.entityIds.filter((item) => typeof item === "string" && item.length > 0)
|
|
84
|
+
: null;
|
|
85
|
+
const relationships = Array.isArray(record.relationships)
|
|
86
|
+
? record.relationships
|
|
87
|
+
.filter((item) => !!item &&
|
|
88
|
+
typeof item === "object" &&
|
|
89
|
+
typeof item.from === "string" &&
|
|
90
|
+
typeof item.to === "string" &&
|
|
91
|
+
typeof item.type === "string" &&
|
|
92
|
+
item.from.length > 0 &&
|
|
93
|
+
item.to.length > 0 &&
|
|
94
|
+
item.type.length > 0)
|
|
95
|
+
.map((item) => ({ from: item.from, to: item.to, type: item.type }))
|
|
96
|
+
: [];
|
|
97
|
+
if (!branch || !entityIds) {
|
|
98
|
+
throw new Error("Invalid marker schema");
|
|
99
|
+
}
|
|
100
|
+
return { branch, entityIds, relationships };
|
|
101
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "kibi-opencode",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.13.0",
|
|
4
4
|
"description": "Kibi OpenCode plugin - thin adapter to integrate Kibi with OpenCode sessions",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "dist/index.js",
|
|
@@ -62,7 +62,7 @@
|
|
|
62
62
|
"@opencode-ai/plugin": "^1.4.7",
|
|
63
63
|
"@opentui/core": "^0.1.99",
|
|
64
64
|
"@opentui/solid": "^0.1.99",
|
|
65
|
-
"kibi-cli": "^0.
|
|
65
|
+
"kibi-cli": "^0.11.0"
|
|
66
66
|
},
|
|
67
67
|
"devDependencies": {
|
|
68
68
|
"@types/node": "latest",
|