kibi-opencode 0.9.0 → 0.10.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +36 -12
- package/dist/brief-intent.d.ts +15 -4
- package/dist/brief-intent.js +63 -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 +25 -0
- package/dist/idle-brief-reader.js +142 -0
- package/dist/idle-brief-runtime.d.ts +48 -0
- package/dist/idle-brief-runtime.js +443 -0
- package/dist/idle-brief-store.d.ts +96 -0
- package/dist/idle-brief-store.js +209 -0
- package/dist/index.d.ts +14 -1
- package/dist/index.js +626 -50
- 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.js +9 -3
- package/dist/plugin-startup.d.ts +1 -0
- package/dist/plugin-startup.js +11 -2
- package/dist/prompt.d.ts +15 -3
- package/dist/prompt.js +103 -33
- package/dist/reconcile-engine.d.ts +15 -0
- package/dist/reconcile-engine.js +112 -0
- package/dist/scheduler.d.ts +1 -0
- package/dist/scheduler.js +37 -1
- 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 +21 -22
- package/dist/toast.js +36 -14
- package/dist/tui-brief-delivery.d.ts +47 -0
- package/dist/tui-brief-delivery.js +138 -0
- package/package.json +4 -3
|
@@ -0,0 +1,186 @@
|
|
|
1
|
+
import * as crypto from "node:crypto";
|
|
2
|
+
import * as fs from "node:fs";
|
|
3
|
+
import * as path from "node:path";
|
|
4
|
+
import { parsePrologValue, parsePropertyList, splitTopLevelGeneral, } from "kibi-cli/prolog/codec";
|
|
5
|
+
import { resolveAuditLogPath } from "./idle-brief-paths.js";
|
|
6
|
+
function asOptionalString(value) {
|
|
7
|
+
return typeof value === "string" ? value : undefined;
|
|
8
|
+
}
|
|
9
|
+
// Parse a single changeset line from the audit log
|
|
10
|
+
function parseChangesetLine(line) {
|
|
11
|
+
const trimmedLine = line.trim();
|
|
12
|
+
if (!trimmedLine.startsWith("changeset(") || !trimmedLine.endsWith(").")) {
|
|
13
|
+
return null;
|
|
14
|
+
}
|
|
15
|
+
const argsLiteral = trimmedLine.slice("changeset(".length, -2);
|
|
16
|
+
const parts = splitTopLevelGeneral(argsLiteral, ",").map((part) => part.trim());
|
|
17
|
+
if (parts.length < 4) {
|
|
18
|
+
return null;
|
|
19
|
+
}
|
|
20
|
+
const timestamp = parsePrologValue(parts[0] ?? "");
|
|
21
|
+
const operation = parsePrologValue(parts[1] ?? "");
|
|
22
|
+
const entityId = parsePrologValue(parts[2] ?? "");
|
|
23
|
+
if (typeof timestamp !== "string" ||
|
|
24
|
+
typeof operation !== "string" ||
|
|
25
|
+
typeof entityId !== "string") {
|
|
26
|
+
return null;
|
|
27
|
+
}
|
|
28
|
+
const rawPayload = parts.slice(3).join(",");
|
|
29
|
+
const payload = parsePayload(rawPayload.trim());
|
|
30
|
+
return {
|
|
31
|
+
timestamp,
|
|
32
|
+
operation,
|
|
33
|
+
entityId,
|
|
34
|
+
...(payload === undefined ? {} : { payload }),
|
|
35
|
+
};
|
|
36
|
+
}
|
|
37
|
+
function parsePayload(rawPayload) {
|
|
38
|
+
if (rawPayload === "null")
|
|
39
|
+
return null;
|
|
40
|
+
const match = rawPayload.match(/^([A-Za-z0-9_]+)-(.+)$/s);
|
|
41
|
+
if (!match)
|
|
42
|
+
return null;
|
|
43
|
+
const [, payloadType = "unknown", rawProps = ""] = match;
|
|
44
|
+
const properties = parsePropertyList(rawProps);
|
|
45
|
+
if (payloadType === "rel") {
|
|
46
|
+
return {
|
|
47
|
+
kind: "relationship",
|
|
48
|
+
relationshipType: payloadType,
|
|
49
|
+
properties,
|
|
50
|
+
};
|
|
51
|
+
}
|
|
52
|
+
const title = asOptionalString(properties.title);
|
|
53
|
+
const source = asOptionalString(properties.source);
|
|
54
|
+
const textRef = asOptionalString(properties.text_ref);
|
|
55
|
+
const changeKindRaw = properties.change_kind;
|
|
56
|
+
const changeKind = changeKindRaw === "created" || changeKindRaw === "updated"
|
|
57
|
+
? changeKindRaw
|
|
58
|
+
: undefined;
|
|
59
|
+
return {
|
|
60
|
+
kind: "entity",
|
|
61
|
+
entityType: payloadType,
|
|
62
|
+
...(changeKind ? { changeKind } : {}),
|
|
63
|
+
...(title ? { title } : {}),
|
|
64
|
+
...(source ? { source } : {}),
|
|
65
|
+
...(textRef ? { textRef } : {}),
|
|
66
|
+
properties,
|
|
67
|
+
};
|
|
68
|
+
}
|
|
69
|
+
// implements REQ-opencode-kibi-briefing-v4
|
|
70
|
+
// Read audit log and compute delta since last cursor
|
|
71
|
+
export function computeAuditDelta(workspaceRoot, branch, previousCursor) {
|
|
72
|
+
const auditPath = resolveAuditLogPath(workspaceRoot, branch);
|
|
73
|
+
if (!fs.existsSync(auditPath)) {
|
|
74
|
+
return {
|
|
75
|
+
hasChanges: false,
|
|
76
|
+
entries: [],
|
|
77
|
+
newCursor: previousCursor ?? {
|
|
78
|
+
lastTimestamp: "",
|
|
79
|
+
lastOperation: "",
|
|
80
|
+
entryCount: 0,
|
|
81
|
+
fileSize: 0,
|
|
82
|
+
},
|
|
83
|
+
contentHash: "",
|
|
84
|
+
};
|
|
85
|
+
}
|
|
86
|
+
const content = fs.readFileSync(auditPath, "utf-8");
|
|
87
|
+
const lines = content
|
|
88
|
+
.split("\n")
|
|
89
|
+
.filter((l) => l.trim().includes("changeset("));
|
|
90
|
+
const fileSize = Buffer.byteLength(content, "utf-8");
|
|
91
|
+
// If no previous cursor or file hasn't grown, check if content changed
|
|
92
|
+
if (previousCursor &&
|
|
93
|
+
fileSize === previousCursor.fileSize &&
|
|
94
|
+
lines.length === previousCursor.entryCount) {
|
|
95
|
+
return {
|
|
96
|
+
hasChanges: false,
|
|
97
|
+
entries: [],
|
|
98
|
+
newCursor: previousCursor,
|
|
99
|
+
contentHash: computeSimpleHash(lines),
|
|
100
|
+
};
|
|
101
|
+
}
|
|
102
|
+
// Parse all entries
|
|
103
|
+
const entries = lines
|
|
104
|
+
.map(parseChangesetLine)
|
|
105
|
+
.filter((e) => e !== null)
|
|
106
|
+
.filter((e) => ["upsert", "upsert_rel", "delete"].includes(e.operation));
|
|
107
|
+
// If we have a previous cursor, filter to only new entries
|
|
108
|
+
let newEntries = entries;
|
|
109
|
+
if (previousCursor?.lastTimestamp) {
|
|
110
|
+
const lastIdx = entries.findIndex((e) => e.timestamp === previousCursor.lastTimestamp &&
|
|
111
|
+
e.operation === previousCursor.lastOperation);
|
|
112
|
+
if (lastIdx >= 0) {
|
|
113
|
+
newEntries = entries.slice(lastIdx + 1);
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
const lastEntry = entries[entries.length - 1];
|
|
117
|
+
const newCursor = {
|
|
118
|
+
lastTimestamp: lastEntry?.timestamp ?? "",
|
|
119
|
+
lastOperation: lastEntry?.operation ?? "",
|
|
120
|
+
entryCount: lines.length,
|
|
121
|
+
fileSize,
|
|
122
|
+
};
|
|
123
|
+
return {
|
|
124
|
+
hasChanges: newEntries.length > 0,
|
|
125
|
+
entries: newEntries,
|
|
126
|
+
newCursor,
|
|
127
|
+
contentHash: computeSimpleHash(lines),
|
|
128
|
+
};
|
|
129
|
+
}
|
|
130
|
+
function computeSimpleHash(lines) {
|
|
131
|
+
return crypto
|
|
132
|
+
.createHash("sha256")
|
|
133
|
+
.update(lines.join("\n"))
|
|
134
|
+
.digest("hex")
|
|
135
|
+
.slice(0, 16);
|
|
136
|
+
}
|
|
137
|
+
// implements REQ-opencode-kibi-briefing-v4
|
|
138
|
+
// Extract the latest audit cursor from the most recent brief for this branch
|
|
139
|
+
export function getLatestAuditCursor(workspaceRoot, branch) {
|
|
140
|
+
// Read .kb/briefs/ directory and find the latest brief for this branch
|
|
141
|
+
const briefsDir = path.join(workspaceRoot, ".kb", "briefs");
|
|
142
|
+
if (!fs.existsSync(briefsDir))
|
|
143
|
+
return null;
|
|
144
|
+
const files = fs
|
|
145
|
+
.readdirSync(briefsDir)
|
|
146
|
+
.filter((f) => f.endsWith("_brief.json") && !f.endsWith(".tmp"))
|
|
147
|
+
.map((f) => {
|
|
148
|
+
const fullPath = path.join(briefsDir, f);
|
|
149
|
+
const [rawTimestamp = "0"] = f.split("_");
|
|
150
|
+
const timestamp = Number.parseInt(rawTimestamp, 10);
|
|
151
|
+
return {
|
|
152
|
+
path: fullPath,
|
|
153
|
+
timestamp: Number.isNaN(timestamp) ? 0 : timestamp,
|
|
154
|
+
};
|
|
155
|
+
})
|
|
156
|
+
.sort((a, b) => b.timestamp - a.timestamp);
|
|
157
|
+
for (const file of files) {
|
|
158
|
+
try {
|
|
159
|
+
const brief = JSON.parse(fs.readFileSync(file.path, "utf-8"));
|
|
160
|
+
if (brief.branch === branch && brief.auditCursor) {
|
|
161
|
+
return brief.auditCursor;
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
catch {
|
|
165
|
+
// skip invalid JSON
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
return null;
|
|
169
|
+
}
|
|
170
|
+
export function getAuditTailCursor(
|
|
171
|
+
// implements REQ-opencode-kibi-briefing-v6
|
|
172
|
+
workspaceRoot, branch) {
|
|
173
|
+
const auditPath = resolveAuditLogPath(workspaceRoot, branch);
|
|
174
|
+
if (!fs.existsSync(auditPath)) {
|
|
175
|
+
return null;
|
|
176
|
+
}
|
|
177
|
+
const delta = computeAuditDelta(workspaceRoot, branch, null);
|
|
178
|
+
return delta.newCursor.entryCount > 0 || delta.newCursor.fileSize > 0
|
|
179
|
+
? delta.newCursor
|
|
180
|
+
: null;
|
|
181
|
+
}
|
|
182
|
+
// implements REQ-opencode-kibi-briefing-v4
|
|
183
|
+
// Guard: abort if branch changed since idle-start
|
|
184
|
+
export function guardBranchChanged(startBranch, currentBranch) {
|
|
185
|
+
return startBranch !== currentBranch;
|
|
186
|
+
}
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
export declare function resolveBriefsDir(workspaceRoot: string): string;
|
|
2
|
+
export declare function resolveAuditLogPath(workspaceRoot: string, branch: string): string;
|
|
3
|
+
export declare function resolveBriefFilePath(workspaceRoot: string, timestamp: number): string;
|
|
4
|
+
export declare function resolveTempBriefPath(workspaceRoot: string, timestamp: number): string;
|
|
5
|
+
export declare function atomicWriteBrief(workspaceRoot: string, timestamp: number, content: string): void;
|
|
6
|
+
export declare function pruneOldBriefs(workspaceRoot: string, branch: string): void;
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
import * as path from "node:path";
|
|
2
|
+
import * as fs from "node:fs";
|
|
3
|
+
import { loadBriefConfig } from "kibi-cli/brief-config";
|
|
4
|
+
const TUI_SEEN_FILE = ".tui-seen.json";
|
|
5
|
+
export function resolveBriefsDir(workspaceRoot) {
|
|
6
|
+
return path.join(workspaceRoot, ".kb", "briefs");
|
|
7
|
+
}
|
|
8
|
+
export function resolveAuditLogPath(workspaceRoot, branch) {
|
|
9
|
+
return path.join(workspaceRoot, ".kb", "branches", branch, "audit.log");
|
|
10
|
+
}
|
|
11
|
+
export function resolveBriefFilePath(workspaceRoot, timestamp) {
|
|
12
|
+
return path.join(resolveBriefsDir(workspaceRoot), `${timestamp}_brief.json`);
|
|
13
|
+
}
|
|
14
|
+
export function resolveTempBriefPath(workspaceRoot, timestamp) {
|
|
15
|
+
return path.join(resolveBriefsDir(workspaceRoot), `${timestamp}_brief.json.tmp`);
|
|
16
|
+
}
|
|
17
|
+
export function atomicWriteBrief(workspaceRoot, timestamp, content) {
|
|
18
|
+
const briefsDir = resolveBriefsDir(workspaceRoot);
|
|
19
|
+
if (!fs.existsSync(briefsDir)) {
|
|
20
|
+
fs.mkdirSync(briefsDir, { recursive: true });
|
|
21
|
+
}
|
|
22
|
+
const tempPath = resolveTempBriefPath(workspaceRoot, timestamp);
|
|
23
|
+
const finalPath = resolveBriefFilePath(workspaceRoot, timestamp);
|
|
24
|
+
fs.writeFileSync(tempPath, content, "utf-8");
|
|
25
|
+
fs.renameSync(tempPath, finalPath);
|
|
26
|
+
}
|
|
27
|
+
function extractTimestamp(fileName) {
|
|
28
|
+
const match = /^(\d+)_brief\.json$/.exec(fileName);
|
|
29
|
+
if (!match)
|
|
30
|
+
return null;
|
|
31
|
+
const n = Number(match[1]);
|
|
32
|
+
return Number.isFinite(n) ? n : null;
|
|
33
|
+
}
|
|
34
|
+
export function pruneOldBriefs(workspaceRoot, branch) {
|
|
35
|
+
const briefsDir = resolveBriefsDir(workspaceRoot);
|
|
36
|
+
if (!fs.existsSync(briefsDir))
|
|
37
|
+
return;
|
|
38
|
+
const shared = loadBriefConfig(workspaceRoot);
|
|
39
|
+
const maxPerBranch = Math.max(1, Number(shared.retention?.maxPerBranch ?? 200));
|
|
40
|
+
const maxAgeDays = Math.max(1, Number(shared.retention?.maxAgeDays ?? 14));
|
|
41
|
+
const keepUnread = shared.retention?.keepUnread ?? true;
|
|
42
|
+
const maxAgeMs = maxAgeDays * 24 * 60 * 60 * 1000;
|
|
43
|
+
const now = Date.now();
|
|
44
|
+
const branchFiles = [];
|
|
45
|
+
for (const file of fs.readdirSync(briefsDir)) {
|
|
46
|
+
if (!file.endsWith("_brief.json") || file.endsWith(".tmp"))
|
|
47
|
+
continue;
|
|
48
|
+
const ts = extractTimestamp(file);
|
|
49
|
+
if (ts === null)
|
|
50
|
+
continue;
|
|
51
|
+
const fullPath = path.join(briefsDir, file);
|
|
52
|
+
let parsed = {};
|
|
53
|
+
try {
|
|
54
|
+
parsed = JSON.parse(fs.readFileSync(fullPath, "utf-8"));
|
|
55
|
+
}
|
|
56
|
+
catch {
|
|
57
|
+
continue;
|
|
58
|
+
}
|
|
59
|
+
if (parsed.branch !== branch)
|
|
60
|
+
continue;
|
|
61
|
+
const nextItem = {
|
|
62
|
+
fullPath,
|
|
63
|
+
timestamp: ts,
|
|
64
|
+
unread: parsed.unread === true,
|
|
65
|
+
};
|
|
66
|
+
if (typeof parsed.contentHash === "string") {
|
|
67
|
+
nextItem.contentHash = parsed.contentHash;
|
|
68
|
+
}
|
|
69
|
+
branchFiles.push(nextItem);
|
|
70
|
+
}
|
|
71
|
+
branchFiles.sort((a, b) => b.timestamp - a.timestamp);
|
|
72
|
+
const keepSet = new Set();
|
|
73
|
+
for (const item of branchFiles.slice(0, maxPerBranch)) {
|
|
74
|
+
keepSet.add(item.fullPath);
|
|
75
|
+
}
|
|
76
|
+
if (keepUnread) {
|
|
77
|
+
for (const item of branchFiles) {
|
|
78
|
+
if (item.unread)
|
|
79
|
+
keepSet.add(item.fullPath);
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
for (const item of branchFiles) {
|
|
83
|
+
const olderThanThreshold = now - item.timestamp > maxAgeMs;
|
|
84
|
+
if (olderThanThreshold && !(keepUnread && item.unread)) {
|
|
85
|
+
keepSet.delete(item.fullPath);
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
for (const item of branchFiles) {
|
|
89
|
+
const shouldDelete = !keepSet.has(item.fullPath);
|
|
90
|
+
if (!shouldDelete)
|
|
91
|
+
continue;
|
|
92
|
+
try {
|
|
93
|
+
fs.unlinkSync(item.fullPath);
|
|
94
|
+
}
|
|
95
|
+
catch {
|
|
96
|
+
// best-effort cleanup
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
const remainingHashes = new Set(branchFiles
|
|
100
|
+
.filter((item) => keepSet.has(item.fullPath))
|
|
101
|
+
.map((item) => item.contentHash)
|
|
102
|
+
.filter((hash) => typeof hash === "string"));
|
|
103
|
+
const seenPath = path.join(briefsDir, TUI_SEEN_FILE);
|
|
104
|
+
try {
|
|
105
|
+
const parsed = JSON.parse(fs.readFileSync(seenPath, "utf-8"));
|
|
106
|
+
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed))
|
|
107
|
+
return;
|
|
108
|
+
const byBranch = parsed;
|
|
109
|
+
const existing = byBranch[branch];
|
|
110
|
+
if (!Array.isArray(existing))
|
|
111
|
+
return;
|
|
112
|
+
byBranch[branch] = existing.filter((entry) => typeof entry === "string" && remainingHashes.has(entry));
|
|
113
|
+
const tempPath = `${seenPath}.tmp`;
|
|
114
|
+
fs.writeFileSync(tempPath, JSON.stringify(byBranch, null, 2), "utf-8");
|
|
115
|
+
fs.renameSync(tempPath, seenPath);
|
|
116
|
+
}
|
|
117
|
+
catch {
|
|
118
|
+
// best-effort cleanup
|
|
119
|
+
}
|
|
120
|
+
}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import { type IdleBriefEnvelope } from "./idle-brief-store.js";
|
|
2
|
+
export declare function hasTuiSeenBrief(workspaceRoot: string, branch: string, contentHash: string): boolean;
|
|
3
|
+
export declare function markBriefTuiSeen(workspaceRoot: string, branch: string, contentHash: string): void;
|
|
4
|
+
/**
|
|
5
|
+
* Select the latest unread brief for the given branch.
|
|
6
|
+
*
|
|
7
|
+
* Scans `.kb/briefs/` for `{timestamp}_brief.json` files, ignoring `.tmp` files
|
|
8
|
+
* and invalid JSON. Filters by `branch`, supported schema version, and
|
|
9
|
+
* `unread === true`. Returns the brief with the highest filename timestamp,
|
|
10
|
+
* or null if no unread briefs exist.
|
|
11
|
+
*/
|
|
12
|
+
export declare function selectLatestUnreadBrief(workspaceRoot: string, branch: string): {
|
|
13
|
+
envelope: IdleBriefEnvelope;
|
|
14
|
+
filePath: string;
|
|
15
|
+
} | null;
|
|
16
|
+
/**
|
|
17
|
+
* Atomically mark a brief as read by setting `unread` to false.
|
|
18
|
+
*
|
|
19
|
+
* Uses the write-to-temp-then-rename pattern to ensure atomicity.
|
|
20
|
+
* Preserves ALL other envelope fields (contentHash, auditCursor, etc.).
|
|
21
|
+
*
|
|
22
|
+
* @param workspaceRoot - The root of the workspace
|
|
23
|
+
* @param briefPath - Absolute path to the brief file to mark as read
|
|
24
|
+
*/
|
|
25
|
+
export declare function markBriefRead(workspaceRoot: string, briefPath: string): void;
|
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
import * as fs from "node:fs";
|
|
2
|
+
import * as path from "node:path";
|
|
3
|
+
import { resolveBriefsDir } from "./idle-brief-paths.js";
|
|
4
|
+
import { isIdleBriefEnvelope, } from "./idle-brief-store.js";
|
|
5
|
+
const BRIEF_FILENAME_RE = /^(\d+)_brief\.json$/;
|
|
6
|
+
const TUI_SEEN_FILE = ".tui-seen.json";
|
|
7
|
+
function resolveTuiSeenPath(workspaceRoot) {
|
|
8
|
+
return path.join(resolveBriefsDir(workspaceRoot), TUI_SEEN_FILE);
|
|
9
|
+
}
|
|
10
|
+
function readTuiSeenHashes(workspaceRoot, branch) {
|
|
11
|
+
const seenPath = resolveTuiSeenPath(workspaceRoot);
|
|
12
|
+
try {
|
|
13
|
+
const parsed = JSON.parse(fs.readFileSync(seenPath, "utf-8"));
|
|
14
|
+
if (!parsed || typeof parsed !== "object")
|
|
15
|
+
return new Set();
|
|
16
|
+
const byBranch = parsed;
|
|
17
|
+
const values = byBranch[branch];
|
|
18
|
+
if (!Array.isArray(values))
|
|
19
|
+
return new Set();
|
|
20
|
+
return new Set(values.filter((entry) => typeof entry === "string"));
|
|
21
|
+
}
|
|
22
|
+
catch {
|
|
23
|
+
return new Set();
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
export function hasTuiSeenBrief(workspaceRoot, branch, contentHash) {
|
|
27
|
+
return readTuiSeenHashes(workspaceRoot, branch).has(contentHash);
|
|
28
|
+
}
|
|
29
|
+
export function markBriefTuiSeen(workspaceRoot, branch, contentHash) {
|
|
30
|
+
const briefsDir = resolveBriefsDir(workspaceRoot);
|
|
31
|
+
fs.mkdirSync(briefsDir, { recursive: true });
|
|
32
|
+
const seenPath = resolveTuiSeenPath(workspaceRoot);
|
|
33
|
+
let parsed = {};
|
|
34
|
+
try {
|
|
35
|
+
const raw = JSON.parse(fs.readFileSync(seenPath, "utf-8"));
|
|
36
|
+
if (raw && typeof raw === "object" && !Array.isArray(raw)) {
|
|
37
|
+
parsed = raw;
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
catch { }
|
|
41
|
+
const existing = Array.isArray(parsed[branch]) ? parsed[branch] : [];
|
|
42
|
+
const next = [contentHash, ...existing.filter((entry) => entry !== contentHash)].slice(0, 100);
|
|
43
|
+
parsed[branch] = next;
|
|
44
|
+
const tempPath = `${seenPath}.tmp`;
|
|
45
|
+
fs.writeFileSync(tempPath, JSON.stringify(parsed, null, 2), "utf-8");
|
|
46
|
+
fs.renameSync(tempPath, seenPath);
|
|
47
|
+
}
|
|
48
|
+
/**
|
|
49
|
+
* Extract the numeric timestamp prefix from a brief filename.
|
|
50
|
+
* Returns null if the filename does not match the expected pattern.
|
|
51
|
+
*/
|
|
52
|
+
function extractTimestamp(filename) {
|
|
53
|
+
const match = filename.match(BRIEF_FILENAME_RE);
|
|
54
|
+
if (!match)
|
|
55
|
+
return null;
|
|
56
|
+
return Number(match[1]);
|
|
57
|
+
}
|
|
58
|
+
/**
|
|
59
|
+
* Select the latest unread brief for the given branch.
|
|
60
|
+
*
|
|
61
|
+
* Scans `.kb/briefs/` for `{timestamp}_brief.json` files, ignoring `.tmp` files
|
|
62
|
+
* and invalid JSON. Filters by `branch`, supported schema version, and
|
|
63
|
+
* `unread === true`. Returns the brief with the highest filename timestamp,
|
|
64
|
+
* or null if no unread briefs exist.
|
|
65
|
+
*/
|
|
66
|
+
export function selectLatestUnreadBrief(
|
|
67
|
+
// implements REQ-opencode-kibi-briefing-v4
|
|
68
|
+
workspaceRoot, branch) {
|
|
69
|
+
const briefsDir = resolveBriefsDir(workspaceRoot);
|
|
70
|
+
if (!fs.existsSync(briefsDir)) {
|
|
71
|
+
return null;
|
|
72
|
+
}
|
|
73
|
+
const files = fs.readdirSync(briefsDir);
|
|
74
|
+
const candidates = [];
|
|
75
|
+
for (const file of files) {
|
|
76
|
+
// Ignore .tmp files
|
|
77
|
+
if (file.endsWith(".tmp"))
|
|
78
|
+
continue;
|
|
79
|
+
const timestamp = extractTimestamp(file);
|
|
80
|
+
if (timestamp === null)
|
|
81
|
+
continue;
|
|
82
|
+
const filePath = path.join(briefsDir, file);
|
|
83
|
+
let envelope;
|
|
84
|
+
try {
|
|
85
|
+
const raw = fs.readFileSync(filePath, "utf-8");
|
|
86
|
+
const parsed = JSON.parse(raw);
|
|
87
|
+
if (!isIdleBriefEnvelope(parsed)) {
|
|
88
|
+
continue;
|
|
89
|
+
}
|
|
90
|
+
envelope = parsed;
|
|
91
|
+
}
|
|
92
|
+
catch {
|
|
93
|
+
// Skip invalid JSON
|
|
94
|
+
continue;
|
|
95
|
+
}
|
|
96
|
+
// Filter by branch, schemaVersion, and unread status
|
|
97
|
+
if (envelope.branch === branch &&
|
|
98
|
+
(envelope.schemaVersion === "1.0" || envelope.schemaVersion === "2.0") &&
|
|
99
|
+
envelope.unread === true) {
|
|
100
|
+
candidates.push({ timestamp, envelope, filePath });
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
if (candidates.length === 0) {
|
|
104
|
+
return null;
|
|
105
|
+
}
|
|
106
|
+
// Sort by filename timestamp descending — latest first
|
|
107
|
+
candidates.sort((a, b) => b.timestamp - a.timestamp);
|
|
108
|
+
const latest = candidates[0];
|
|
109
|
+
if (!latest) {
|
|
110
|
+
return null;
|
|
111
|
+
}
|
|
112
|
+
return {
|
|
113
|
+
envelope: latest.envelope,
|
|
114
|
+
filePath: latest.filePath,
|
|
115
|
+
};
|
|
116
|
+
}
|
|
117
|
+
/**
|
|
118
|
+
* Atomically mark a brief as read by setting `unread` to false.
|
|
119
|
+
*
|
|
120
|
+
* Uses the write-to-temp-then-rename pattern to ensure atomicity.
|
|
121
|
+
* Preserves ALL other envelope fields (contentHash, auditCursor, etc.).
|
|
122
|
+
*
|
|
123
|
+
* @param workspaceRoot - The root of the workspace
|
|
124
|
+
* @param briefPath - Absolute path to the brief file to mark as read
|
|
125
|
+
*/
|
|
126
|
+
export function markBriefRead(
|
|
127
|
+
// implements REQ-opencode-kibi-briefing-v4
|
|
128
|
+
workspaceRoot, briefPath) {
|
|
129
|
+
const briefsDir = resolveBriefsDir(workspaceRoot);
|
|
130
|
+
const resolvedBriefPath = path.resolve(briefPath);
|
|
131
|
+
const resolvedBriefsDir = path.resolve(briefsDir);
|
|
132
|
+
// Security: ensure the brief path is within the expected briefs directory
|
|
133
|
+
if (!resolvedBriefPath.startsWith(resolvedBriefsDir + path.sep)) {
|
|
134
|
+
throw new Error(`Invalid brief path: ${briefPath} is not inside ${briefsDir}`);
|
|
135
|
+
}
|
|
136
|
+
const raw = fs.readFileSync(briefPath, "utf-8");
|
|
137
|
+
const brief = JSON.parse(raw);
|
|
138
|
+
brief.unread = false;
|
|
139
|
+
const tempPath = `${briefPath}.tmp`;
|
|
140
|
+
fs.writeFileSync(tempPath, JSON.stringify(brief, null, 2), "utf-8");
|
|
141
|
+
fs.renameSync(tempPath, briefPath);
|
|
142
|
+
}
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import type { BriefingWorkspaceCtx } from "./briefing-runtime.js";
|
|
2
|
+
import type { AuditDelta } from "./idle-brief-audit.js";
|
|
3
|
+
import { type IdleBriefEnvelope } from "./idle-brief-store.js";
|
|
4
|
+
export interface IdleBriefResult {
|
|
5
|
+
success: boolean;
|
|
6
|
+
briefPath: string | null;
|
|
7
|
+
envelope: IdleBriefEnvelope | null;
|
|
8
|
+
}
|
|
9
|
+
export interface CheckResult {
|
|
10
|
+
violations: Array<{
|
|
11
|
+
rule: string;
|
|
12
|
+
entityId: string;
|
|
13
|
+
description: string;
|
|
14
|
+
suggestion?: string;
|
|
15
|
+
source?: string;
|
|
16
|
+
}>;
|
|
17
|
+
count: number;
|
|
18
|
+
diagnostics: Array<{
|
|
19
|
+
category: string;
|
|
20
|
+
severity: string;
|
|
21
|
+
message: string;
|
|
22
|
+
file?: string;
|
|
23
|
+
suggestion?: string;
|
|
24
|
+
}>;
|
|
25
|
+
}
|
|
26
|
+
export interface IdleBriefStatement {
|
|
27
|
+
statement: string;
|
|
28
|
+
citationIds: string[];
|
|
29
|
+
}
|
|
30
|
+
export interface IdleBriefingResult {
|
|
31
|
+
briefingState: string;
|
|
32
|
+
tldr: string;
|
|
33
|
+
promptBlock: string;
|
|
34
|
+
citations: Array<{
|
|
35
|
+
id: string;
|
|
36
|
+
type?: string;
|
|
37
|
+
title?: string;
|
|
38
|
+
source?: string;
|
|
39
|
+
textRef?: string;
|
|
40
|
+
}>;
|
|
41
|
+
constraints?: IdleBriefStatement[];
|
|
42
|
+
regressionRisks?: IdleBriefStatement[];
|
|
43
|
+
missingEvidence?: IdleBriefStatement[];
|
|
44
|
+
}
|
|
45
|
+
export declare function generateIdleBrief(client: unknown, workspaceCtx: BriefingWorkspaceCtx, auditDelta: AuditDelta, sessionId: string, options?: {
|
|
46
|
+
sourceFiles?: string[];
|
|
47
|
+
changedEntityIds?: string[];
|
|
48
|
+
}): Promise<IdleBriefResult>;
|