kibi-opencode 0.8.0 → 0.10.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (53) hide show
  1. package/README.md +37 -12
  2. package/dist/brief-intent.d.ts +41 -0
  3. package/dist/brief-intent.js +127 -0
  4. package/dist/briefing-runtime.d.ts +24 -0
  5. package/dist/briefing-runtime.js +277 -0
  6. package/dist/config.d.ts +3 -0
  7. package/dist/config.js +9 -0
  8. package/dist/e2e-coverage-signals.d.ts +6 -0
  9. package/dist/e2e-coverage-signals.js +186 -0
  10. package/dist/file-entity-links.d.ts +15 -0
  11. package/dist/file-entity-links.js +254 -0
  12. package/dist/file-operation-reminders.d.ts +24 -0
  13. package/dist/file-operation-reminders.js +55 -0
  14. package/dist/file-operation-state.d.ts +29 -0
  15. package/dist/file-operation-state.js +113 -0
  16. package/dist/idle-brief-audit.d.ts +36 -0
  17. package/dist/idle-brief-audit.js +186 -0
  18. package/dist/idle-brief-paths.d.ts +6 -0
  19. package/dist/idle-brief-paths.js +120 -0
  20. package/dist/idle-brief-reader.d.ts +25 -0
  21. package/dist/idle-brief-reader.js +142 -0
  22. package/dist/idle-brief-runtime.d.ts +48 -0
  23. package/dist/idle-brief-runtime.js +443 -0
  24. package/dist/idle-brief-store.d.ts +96 -0
  25. package/dist/idle-brief-store.js +209 -0
  26. package/dist/index.d.ts +15 -1
  27. package/dist/index.js +645 -22
  28. package/dist/init-kibi-alias.d.ts +14 -0
  29. package/dist/init-kibi-alias.js +38 -0
  30. package/dist/init-kibi-capability.d.ts +32 -0
  31. package/dist/init-kibi-capability.js +202 -0
  32. package/dist/logger.js +9 -3
  33. package/dist/plugin-startup.d.ts +1 -0
  34. package/dist/plugin-startup.js +11 -2
  35. package/dist/prompt.d.ts +18 -3
  36. package/dist/prompt.js +176 -50
  37. package/dist/reconcile-engine.d.ts +15 -0
  38. package/dist/reconcile-engine.js +112 -0
  39. package/dist/scheduler.d.ts +1 -0
  40. package/dist/scheduler.js +37 -1
  41. package/dist/session-edit-state.d.ts +25 -0
  42. package/dist/session-edit-state.js +177 -0
  43. package/dist/session-fingerprint.d.ts +11 -0
  44. package/dist/session-fingerprint.js +21 -0
  45. package/dist/source-linked-guidance.d.ts +1 -2
  46. package/dist/source-linked-guidance.js +5 -168
  47. package/dist/startup-notifier.d.ts +3 -18
  48. package/dist/startup-notifier.js +42 -36
  49. package/dist/toast.d.ts +31 -0
  50. package/dist/toast.js +40 -0
  51. package/dist/tui-brief-delivery.d.ts +47 -0
  52. package/dist/tui-brief-delivery.js +138 -0
  53. package/package.json +4 -3
@@ -0,0 +1,36 @@
1
+ export interface AuditCursor {
2
+ lastTimestamp: string;
3
+ lastOperation: string;
4
+ entryCount: number;
5
+ fileSize: number;
6
+ }
7
+ export interface AuditDelta {
8
+ hasChanges: boolean;
9
+ entries: AuditEntry[];
10
+ newCursor: AuditCursor;
11
+ contentHash: string;
12
+ }
13
+ export interface AuditEntry {
14
+ timestamp: string;
15
+ operation: string;
16
+ entityId: string;
17
+ payload?: AuditEntityPayload | AuditRelationshipPayload | null;
18
+ }
19
+ export interface AuditEntityPayload {
20
+ kind: "entity";
21
+ entityType: string;
22
+ changeKind?: "created" | "updated";
23
+ title?: string;
24
+ source?: string;
25
+ textRef?: string;
26
+ properties: Record<string, unknown>;
27
+ }
28
+ export interface AuditRelationshipPayload {
29
+ kind: "relationship";
30
+ relationshipType: string;
31
+ properties: Record<string, unknown>;
32
+ }
33
+ export declare function computeAuditDelta(workspaceRoot: string, branch: string, previousCursor: AuditCursor | null): AuditDelta;
34
+ export declare function getLatestAuditCursor(workspaceRoot: string, branch: string): AuditCursor | null;
35
+ export declare function getAuditTailCursor(workspaceRoot: string, branch: string): AuditCursor | null;
36
+ export declare function guardBranchChanged(startBranch: string, currentBranch: string): boolean;
@@ -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>;