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,254 @@
|
|
|
1
|
+
// implements REQ-opencode-file-context-guidance-v1
|
|
2
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
3
|
+
import * as path from "node:path";
|
|
4
|
+
import { loadKbSyncPaths } from "./file-filter.js";
|
|
5
|
+
// ── Lightweight YAML parser (symbols.yaml subset) ───────────────────
|
|
6
|
+
//
|
|
7
|
+
// Handles:
|
|
8
|
+
// symbols:
|
|
9
|
+
// - id: SYM-xxx
|
|
10
|
+
// sourceFile: path/to/file
|
|
11
|
+
// links:
|
|
12
|
+
// - REQ-xxx
|
|
13
|
+
// relationships:
|
|
14
|
+
// - type: implements
|
|
15
|
+
// target: REQ-xxx
|
|
16
|
+
//
|
|
17
|
+
// And bare array format (no wrapping `symbols:` key):
|
|
18
|
+
// - id: SYM-xxx
|
|
19
|
+
// ...
|
|
20
|
+
// implements REQ-opencode-file-context-guidance-v1
|
|
21
|
+
export function parseSymbolsYaml(content) {
|
|
22
|
+
const entries = [];
|
|
23
|
+
const lines = content.split("\n");
|
|
24
|
+
let current = null;
|
|
25
|
+
let section = "none";
|
|
26
|
+
let pendingRel = null;
|
|
27
|
+
function flushRel() {
|
|
28
|
+
if (pendingRel?.type && pendingRel.target && current?.relationships) {
|
|
29
|
+
current.relationships.push({
|
|
30
|
+
type: pendingRel.type,
|
|
31
|
+
target: pendingRel.target,
|
|
32
|
+
});
|
|
33
|
+
}
|
|
34
|
+
pendingRel = null;
|
|
35
|
+
}
|
|
36
|
+
function flushEntry() {
|
|
37
|
+
flushRel();
|
|
38
|
+
if (current?.id && current?.sourceFile) {
|
|
39
|
+
entries.push(current);
|
|
40
|
+
}
|
|
41
|
+
current = null;
|
|
42
|
+
section = "none";
|
|
43
|
+
}
|
|
44
|
+
for (const raw of lines) {
|
|
45
|
+
if (raw.trim().startsWith("#"))
|
|
46
|
+
continue;
|
|
47
|
+
// New entry: " - id: ..."
|
|
48
|
+
const entryMatch = raw.match(/^\s+-\s+id:\s*(.+)$/);
|
|
49
|
+
if (entryMatch) {
|
|
50
|
+
flushEntry();
|
|
51
|
+
const entryId = entryMatch[1];
|
|
52
|
+
if (entryId === undefined)
|
|
53
|
+
continue;
|
|
54
|
+
current = { id: entryId.trim(), links: [], relationships: [] };
|
|
55
|
+
section = "none";
|
|
56
|
+
continue;
|
|
57
|
+
}
|
|
58
|
+
if (!current)
|
|
59
|
+
continue;
|
|
60
|
+
// sourceFile
|
|
61
|
+
const srcMatch = raw.match(/^\s+sourceFile:\s*(.+)$/);
|
|
62
|
+
if (srcMatch) {
|
|
63
|
+
const sourceFile = srcMatch[1];
|
|
64
|
+
if (sourceFile === undefined)
|
|
65
|
+
continue;
|
|
66
|
+
current.sourceFile = sourceFile.trim();
|
|
67
|
+
section = "none";
|
|
68
|
+
continue;
|
|
69
|
+
}
|
|
70
|
+
// links section header
|
|
71
|
+
if (/^\s+links:\s*$/.test(raw)) {
|
|
72
|
+
flushRel();
|
|
73
|
+
section = "links";
|
|
74
|
+
continue;
|
|
75
|
+
}
|
|
76
|
+
// relationships section header
|
|
77
|
+
if (/^\s+relationships:\s*$/.test(raw)) {
|
|
78
|
+
flushRel();
|
|
79
|
+
section = "relationships";
|
|
80
|
+
continue;
|
|
81
|
+
}
|
|
82
|
+
// Link item: " - REQ-xxx"
|
|
83
|
+
if (section === "links") {
|
|
84
|
+
const linkMatch = raw.match(/^\s+-\s+(REQ-[A-Za-z0-9_-]+)\s*$/);
|
|
85
|
+
if (linkMatch) {
|
|
86
|
+
const linkId = linkMatch[1];
|
|
87
|
+
if (linkId !== undefined && current.links) {
|
|
88
|
+
current.links.push(linkId);
|
|
89
|
+
}
|
|
90
|
+
continue;
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
// Relationship type: " - type: implements"
|
|
94
|
+
if (section === "relationships") {
|
|
95
|
+
const relTypeMatch = raw.match(/^\s+-\s+type:\s*(.+)$/);
|
|
96
|
+
if (relTypeMatch) {
|
|
97
|
+
flushRel();
|
|
98
|
+
const relationType = relTypeMatch[1];
|
|
99
|
+
if (relationType === undefined)
|
|
100
|
+
continue;
|
|
101
|
+
pendingRel = { type: relationType.trim() };
|
|
102
|
+
continue;
|
|
103
|
+
}
|
|
104
|
+
// Relationship target: " target: REQ-..."
|
|
105
|
+
const relTargetMatch = raw.match(/^\s+target:\s*(.+)$/);
|
|
106
|
+
if (relTargetMatch && pendingRel) {
|
|
107
|
+
const target = relTargetMatch[1];
|
|
108
|
+
if (target === undefined)
|
|
109
|
+
continue;
|
|
110
|
+
pendingRel.target = target.trim();
|
|
111
|
+
continue;
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
flushEntry();
|
|
116
|
+
return entries;
|
|
117
|
+
}
|
|
118
|
+
// ── Doc-path identity mapping ───────────────────────────────────────
|
|
119
|
+
const DOC_ENTITY_PATTERN = /^(REQ|SCEN|TEST|ADR|FLAG|EVT|FACT)-[A-Za-z0-9_-]+\.md$/;
|
|
120
|
+
// implements REQ-opencode-file-context-guidance-v1
|
|
121
|
+
function resolveDocPathIdentity(relPath, syncPaths) {
|
|
122
|
+
const basename = path.posix.basename(relPath);
|
|
123
|
+
if (!DOC_ENTITY_PATTERN.test(basename))
|
|
124
|
+
return null;
|
|
125
|
+
const entityId = basename.replace(/\.md$/, "");
|
|
126
|
+
// Check if the file lives under one of the configured doc roots
|
|
127
|
+
const docRootKeys = [
|
|
128
|
+
"requirements",
|
|
129
|
+
"scenarios",
|
|
130
|
+
"tests",
|
|
131
|
+
"adr",
|
|
132
|
+
"flags",
|
|
133
|
+
"events",
|
|
134
|
+
"facts",
|
|
135
|
+
];
|
|
136
|
+
// Normalize the relative path for matching
|
|
137
|
+
const normalizedRel = relPath.split(path.sep).join("/");
|
|
138
|
+
for (const key of docRootKeys) {
|
|
139
|
+
const pattern = syncPaths[key];
|
|
140
|
+
if (!pattern)
|
|
141
|
+
continue;
|
|
142
|
+
// Strip glob from pattern to get the root dir prefix
|
|
143
|
+
// e.g. "documentation/requirements/**/*.md" → "documentation/requirements"
|
|
144
|
+
const rootDir = pattern.replace(/\/\*\*\/.*$/, "").replace(/\/+$/, "");
|
|
145
|
+
if (normalizedRel.startsWith(rootDir + "/")) {
|
|
146
|
+
return entityId;
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
// If no specific root matched but path starts with documentation/,
|
|
150
|
+
// still accept (covers default configuration)
|
|
151
|
+
return null;
|
|
152
|
+
}
|
|
153
|
+
// ── Symbols file resolution ─────────────────────────────────────────
|
|
154
|
+
function readSymbolsManifest(worktree, syncPaths) {
|
|
155
|
+
const symbolsPathRaw = syncPaths.symbols;
|
|
156
|
+
if (!symbolsPathRaw)
|
|
157
|
+
return [];
|
|
158
|
+
const symbolsPath = path.isAbsolute(symbolsPathRaw)
|
|
159
|
+
? symbolsPathRaw
|
|
160
|
+
: path.join(worktree, symbolsPathRaw);
|
|
161
|
+
if (!existsSync(symbolsPath))
|
|
162
|
+
return [];
|
|
163
|
+
const content = readFileSync(symbolsPath, "utf8");
|
|
164
|
+
return parseSymbolsYaml(content);
|
|
165
|
+
}
|
|
166
|
+
function normalizeFilePath(worktree, filePath) {
|
|
167
|
+
// Normalize to forward-slash relative path from worktree
|
|
168
|
+
const absPath = path.isAbsolute(filePath)
|
|
169
|
+
? filePath
|
|
170
|
+
: path.resolve(worktree, filePath);
|
|
171
|
+
return path
|
|
172
|
+
.relative(worktree, absPath)
|
|
173
|
+
.split(path.sep)
|
|
174
|
+
.join("/");
|
|
175
|
+
}
|
|
176
|
+
// ── Public API ──────────────────────────────────────────────────────
|
|
177
|
+
// implements REQ-opencode-file-context-guidance-v1
|
|
178
|
+
export function getFileLinkedEntityIds(worktree, filePath) {
|
|
179
|
+
try {
|
|
180
|
+
const syncPaths = loadKbSyncPaths(worktree);
|
|
181
|
+
const relPath = normalizeFilePath(worktree, filePath);
|
|
182
|
+
// Check doc-path identity first
|
|
183
|
+
const docId = resolveDocPathIdentity(relPath, syncPaths);
|
|
184
|
+
if (docId) {
|
|
185
|
+
return { ids: [docId], source: "doc-path" };
|
|
186
|
+
}
|
|
187
|
+
// Try symbols manifest
|
|
188
|
+
const symbols = readSymbolsManifest(worktree, syncPaths);
|
|
189
|
+
const matchedRows = symbols.filter((s) => s.sourceFile === relPath);
|
|
190
|
+
if (matchedRows.length === 0) {
|
|
191
|
+
return { ids: [], source: "none" };
|
|
192
|
+
}
|
|
193
|
+
const seen = new Set();
|
|
194
|
+
const orderedIds = [];
|
|
195
|
+
// Priority order: implements → covered_by → executable_for
|
|
196
|
+
const relPriority = ["implements", "covered_by", "executable_for"];
|
|
197
|
+
// First pass: collect relationships grouped by priority type, preserving file order within each type
|
|
198
|
+
for (const priorityType of relPriority) {
|
|
199
|
+
for (const row of matchedRows) {
|
|
200
|
+
for (const r of row.relationships ?? []) {
|
|
201
|
+
if (r.type === priorityType) {
|
|
202
|
+
const id = r.target;
|
|
203
|
+
if (!seen.has(id)) {
|
|
204
|
+
seen.add(id);
|
|
205
|
+
orderedIds.push(id);
|
|
206
|
+
if (orderedIds.length >= 3)
|
|
207
|
+
return { ids: orderedIds.slice(0, 3), source: "symbols" };
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
// Second pass: fall back to static links, preserving file order
|
|
214
|
+
for (const row of matchedRows) {
|
|
215
|
+
for (const l of row.links ?? []) {
|
|
216
|
+
if (!seen.has(l)) {
|
|
217
|
+
seen.add(l);
|
|
218
|
+
orderedIds.push(l);
|
|
219
|
+
if (orderedIds.length >= 3)
|
|
220
|
+
return { ids: orderedIds.slice(0, 3), source: "symbols" };
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
return { ids: orderedIds.slice(0, 3), source: "symbols" };
|
|
225
|
+
}
|
|
226
|
+
catch {
|
|
227
|
+
return { ids: [], source: "none" };
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
// implements REQ-opencode-file-context-guidance-v1
|
|
231
|
+
export function getFileLinkedTargetsByType(worktree, filePath, relationshipTypes) {
|
|
232
|
+
try {
|
|
233
|
+
const syncPaths = loadKbSyncPaths(worktree);
|
|
234
|
+
const relPath = normalizeFilePath(worktree, filePath);
|
|
235
|
+
const symbols = readSymbolsManifest(worktree, syncPaths);
|
|
236
|
+
const matchedRows = symbols.filter((s) => s.sourceFile === relPath);
|
|
237
|
+
if (matchedRows.length === 0)
|
|
238
|
+
return [];
|
|
239
|
+
const targets = [];
|
|
240
|
+
const seen = new Set();
|
|
241
|
+
for (const row of matchedRows) {
|
|
242
|
+
for (const r of row.relationships ?? []) {
|
|
243
|
+
if (relationshipTypes.includes(r.type) && !seen.has(r.target)) {
|
|
244
|
+
seen.add(r.target);
|
|
245
|
+
targets.push(r.target);
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
return targets;
|
|
250
|
+
}
|
|
251
|
+
catch {
|
|
252
|
+
return [];
|
|
253
|
+
}
|
|
254
|
+
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import type { RepoPosture } from "./repo-posture.js";
|
|
2
|
+
import type { PathKind } from "./path-kind.js";
|
|
3
|
+
import type { RiskClass } from "./risk-classifier.js";
|
|
4
|
+
import type { ReminderKind } from "./file-operation-state.js";
|
|
5
|
+
import type { E2eCoverageSignal } from "./e2e-coverage-signals.js";
|
|
6
|
+
export interface LinkedEntityResult {
|
|
7
|
+
ids: string[];
|
|
8
|
+
source: "symbols" | "doc-path" | "none";
|
|
9
|
+
}
|
|
10
|
+
export interface DeriveFileOperationReminderParams {
|
|
11
|
+
normalizedPath: string;
|
|
12
|
+
lifecycle: "created" | "edited" | "deleted";
|
|
13
|
+
pathKind: PathKind;
|
|
14
|
+
linkedEntityResult: LinkedEntityResult;
|
|
15
|
+
e2eSignal: E2eCoverageSignal;
|
|
16
|
+
currentSemanticRisk: RiskClass;
|
|
17
|
+
posture: RepoPosture;
|
|
18
|
+
}
|
|
19
|
+
export interface DeriveFileOperationReminderResult {
|
|
20
|
+
lifecycleReminder: string | null;
|
|
21
|
+
e2eReminder: string | null;
|
|
22
|
+
reminderKindsToMark: ReminderKind[];
|
|
23
|
+
}
|
|
24
|
+
export declare function deriveFileOperationReminder(params: DeriveFileOperationReminderParams): DeriveFileOperationReminderResult;
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
// ── Lifecycle reminder text ─────────────────────────────────────
|
|
2
|
+
const NEW_FILE_REMINDER = "- New file detected. Add or update the necessary Kibi entities and traceability before completing this task.";
|
|
3
|
+
const DELETED_WITH_IDS_REMINDER = (ids) => `- Deleted file had linked Kibi entities: ${ids}. Update Kibi to keep traceability accurate.`;
|
|
4
|
+
const DELETED_NO_IDS_REMINDER = "- Deleted file had no linked Kibi entities. Update Kibi if this removal changes documented behavior or traceability.";
|
|
5
|
+
// ── Main exported function ────────────────────────────────────
|
|
6
|
+
// implements REQ-opencode-file-context-guidance-v1
|
|
7
|
+
export function deriveFileOperationReminder(params) {
|
|
8
|
+
const { lifecycle, pathKind, linkedEntityResult, e2eSignal, posture, } = params;
|
|
9
|
+
// Check if posture allows lifecycle reminders
|
|
10
|
+
const isAuthoritativePosture = posture === "root_active" || posture === "hybrid_root_plus_vendored";
|
|
11
|
+
// Derive lifecycle reminder
|
|
12
|
+
let lifecycleReminder = null;
|
|
13
|
+
const reminderKindsToMark = [];
|
|
14
|
+
if (isAuthoritativePosture) {
|
|
15
|
+
if (lifecycle === "created") {
|
|
16
|
+
// Only emit create reminder for code files (not documentation, not KB docs)
|
|
17
|
+
if (pathKind === "code") {
|
|
18
|
+
lifecycleReminder = NEW_FILE_REMINDER;
|
|
19
|
+
reminderKindsToMark.push("kibi_write");
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
else if (lifecycle === "edited") {
|
|
23
|
+
// No generic lifecycle reminder for edited files
|
|
24
|
+
// Existing semantic risk guidance remains primary
|
|
25
|
+
}
|
|
26
|
+
else if (lifecycle === "deleted") {
|
|
27
|
+
const ids = linkedEntityResult.ids;
|
|
28
|
+
if (ids.length > 0) {
|
|
29
|
+
lifecycleReminder = DELETED_WITH_IDS_REMINDER(ids.join(", "));
|
|
30
|
+
reminderKindsToMark.push("kibi_delete");
|
|
31
|
+
}
|
|
32
|
+
else {
|
|
33
|
+
lifecycleReminder = DELETED_NO_IDS_REMINDER;
|
|
34
|
+
reminderKindsToMark.push("kibi_delete");
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
// Derive e2e reminder (only when e2e signal exists)
|
|
39
|
+
// E2e reminders are NOT posture-gated - they're always relevant
|
|
40
|
+
let e2eReminder = null;
|
|
41
|
+
if (e2eSignal.level !== "none" && e2eSignal.reminderText !== null) {
|
|
42
|
+
e2eReminder = e2eSignal.reminderText;
|
|
43
|
+
if (lifecycle === "deleted") {
|
|
44
|
+
reminderKindsToMark.push("e2e_delete");
|
|
45
|
+
}
|
|
46
|
+
else {
|
|
47
|
+
reminderKindsToMark.push("e2e_write");
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
return {
|
|
51
|
+
lifecycleReminder,
|
|
52
|
+
e2eReminder,
|
|
53
|
+
reminderKindsToMark,
|
|
54
|
+
};
|
|
55
|
+
}
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
export type FileLifecycle = "created" | "edited" | "deleted";
|
|
2
|
+
export type ReminderKind = "kibi_write" | "kibi_delete" | "e2e_write" | "e2e_delete";
|
|
3
|
+
export interface PendingLifecycleEvent {
|
|
4
|
+
/** Normalized file path (relative to worktree root). */
|
|
5
|
+
normalizedPath: string;
|
|
6
|
+
/** Coalesced lifecycle event for this path. */
|
|
7
|
+
lifecycle: FileLifecycle;
|
|
8
|
+
/** Timestamp (ms) of the lifecycle event. */
|
|
9
|
+
timestamp: number;
|
|
10
|
+
}
|
|
11
|
+
export interface FileOperationState {
|
|
12
|
+
/** Normalize file path relative to worktree root. */
|
|
13
|
+
normalizePath(filePath: string): string;
|
|
14
|
+
/** Record a lifecycle event for a file, coalescing with existing events. */
|
|
15
|
+
recordLifecycle(filePath: string, lifecycle: FileLifecycle, timestamp?: number): void;
|
|
16
|
+
/** Peek at pending lifecycle event, preferring specified path if available. */
|
|
17
|
+
peekPending(preferredPath?: string): PendingLifecycleEvent | null;
|
|
18
|
+
/** Consume pending lifecycle event for a specific path. */
|
|
19
|
+
consumePending(filePath: string): void;
|
|
20
|
+
/** Check if a reminder has already been shown for a path/kind combo. */
|
|
21
|
+
hasShown(filePath: string, reminderKind: ReminderKind): boolean;
|
|
22
|
+
/** Mark a reminder as shown for a path/kind combo. */
|
|
23
|
+
markShown(filePath: string, reminderKind: ReminderKind): void;
|
|
24
|
+
}
|
|
25
|
+
export declare function createFileOperationState(opts: {
|
|
26
|
+
worktree: string;
|
|
27
|
+
/** Custom clock for testing. Defaults to Date.now. */
|
|
28
|
+
now?: () => number;
|
|
29
|
+
}): FileOperationState;
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
import * as path from "node:path";
|
|
2
|
+
// ---------------------------------------------------------------------------
|
|
3
|
+
// Factory function
|
|
4
|
+
// ---------------------------------------------------------------------------
|
|
5
|
+
export function createFileOperationState(opts) {
|
|
6
|
+
const worktree = opts.worktree;
|
|
7
|
+
const now = opts.now ?? Date.now;
|
|
8
|
+
// ---- Per-instance state (no module globals) ----
|
|
9
|
+
/**
|
|
10
|
+
* Pending lifecycle events keyed by normalized path.
|
|
11
|
+
* Each path has at most one coalesced lifecycle state.
|
|
12
|
+
*/
|
|
13
|
+
const pendingLifecycleEvents = new Map();
|
|
14
|
+
/**
|
|
15
|
+
* Reminder suppression state: (normalized path + reminder kind) -> shown flag.
|
|
16
|
+
* Keeps path-aware, kind-aware suppression separate from GuidanceCache.
|
|
17
|
+
*/
|
|
18
|
+
const reminderSuppression = new Map();
|
|
19
|
+
// ---- Internal helpers ----
|
|
20
|
+
/**
|
|
21
|
+
* Coalesce lifecycle events using precedence rules:
|
|
22
|
+
* - created + edited -> created
|
|
23
|
+
* - edited + edited -> edited
|
|
24
|
+
* - created|edited + deleted -> deleted
|
|
25
|
+
* - deleted + created|edited -> deleted
|
|
26
|
+
*/
|
|
27
|
+
function coalesceLifecycle(existing, incoming) {
|
|
28
|
+
if (existing === undefined) {
|
|
29
|
+
return incoming;
|
|
30
|
+
}
|
|
31
|
+
// created + edited -> created
|
|
32
|
+
if (existing === "created" && incoming === "edited") {
|
|
33
|
+
return "created";
|
|
34
|
+
}
|
|
35
|
+
// edited + edited -> edited
|
|
36
|
+
if (existing === "edited" && incoming === "edited") {
|
|
37
|
+
return "edited";
|
|
38
|
+
}
|
|
39
|
+
// created|edited + deleted -> deleted
|
|
40
|
+
if ((existing === "created" || existing === "edited") && incoming === "deleted") {
|
|
41
|
+
return "deleted";
|
|
42
|
+
}
|
|
43
|
+
// deleted + created|edited -> deleted
|
|
44
|
+
if (existing === "deleted" && (incoming === "created" || incoming === "edited")) {
|
|
45
|
+
return "deleted";
|
|
46
|
+
}
|
|
47
|
+
// Fallback: use incoming
|
|
48
|
+
return incoming;
|
|
49
|
+
}
|
|
50
|
+
function normalizeSessionPath(filePath) {
|
|
51
|
+
if (path.isAbsolute(filePath)) {
|
|
52
|
+
const relativePath = path.relative(worktree, filePath);
|
|
53
|
+
// Keep absolute path if it escapes worktree
|
|
54
|
+
return relativePath.startsWith("..") ? filePath : relativePath;
|
|
55
|
+
}
|
|
56
|
+
// Normalize leading ./ and trailing slashes
|
|
57
|
+
const normalized = path.normalize(filePath);
|
|
58
|
+
return normalized.startsWith("./") ? normalized.slice(2) : normalized;
|
|
59
|
+
}
|
|
60
|
+
function getSuppressionKey(filePath, kind) {
|
|
61
|
+
const normalized = normalizeSessionPath(filePath);
|
|
62
|
+
return `${normalized}:${kind}`;
|
|
63
|
+
}
|
|
64
|
+
// ---- Public API ----
|
|
65
|
+
function normalizePath(filePath) {
|
|
66
|
+
return normalizeSessionPath(filePath);
|
|
67
|
+
}
|
|
68
|
+
function recordLifecycle(filePath, lifecycle, timestamp) {
|
|
69
|
+
const normalized = normalizeSessionPath(filePath);
|
|
70
|
+
const existing = pendingLifecycleEvents.get(normalized);
|
|
71
|
+
const coalesced = coalesceLifecycle(existing?.lifecycle, lifecycle);
|
|
72
|
+
pendingLifecycleEvents.set(normalized, {
|
|
73
|
+
normalizedPath: normalized,
|
|
74
|
+
lifecycle: coalesced,
|
|
75
|
+
timestamp: timestamp ?? now(),
|
|
76
|
+
});
|
|
77
|
+
}
|
|
78
|
+
function peekPending(preferredPath) {
|
|
79
|
+
if (preferredPath !== undefined) {
|
|
80
|
+
const normalized = normalizeSessionPath(preferredPath);
|
|
81
|
+
const preferred = pendingLifecycleEvents.get(normalized);
|
|
82
|
+
return preferred ?? null;
|
|
83
|
+
}
|
|
84
|
+
// No preferred path specified, return most recent pending event
|
|
85
|
+
let mostRecent = null;
|
|
86
|
+
for (const event of pendingLifecycleEvents.values()) {
|
|
87
|
+
if (mostRecent === null || event.timestamp > mostRecent.timestamp) {
|
|
88
|
+
mostRecent = event;
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
return mostRecent;
|
|
92
|
+
}
|
|
93
|
+
function consumePending(filePath) {
|
|
94
|
+
const normalized = normalizeSessionPath(filePath);
|
|
95
|
+
pendingLifecycleEvents.delete(normalized);
|
|
96
|
+
}
|
|
97
|
+
function hasShown(filePath, reminderKind) {
|
|
98
|
+
const key = getSuppressionKey(filePath, reminderKind);
|
|
99
|
+
return reminderSuppression.get(key) ?? false;
|
|
100
|
+
}
|
|
101
|
+
function markShown(filePath, reminderKind) {
|
|
102
|
+
const key = getSuppressionKey(filePath, reminderKind);
|
|
103
|
+
reminderSuppression.set(key, true);
|
|
104
|
+
}
|
|
105
|
+
return {
|
|
106
|
+
normalizePath,
|
|
107
|
+
recordLifecycle,
|
|
108
|
+
peekPending,
|
|
109
|
+
consumePending,
|
|
110
|
+
hasShown,
|
|
111
|
+
markShown,
|
|
112
|
+
};
|
|
113
|
+
}
|
|
@@ -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;
|