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,186 @@
1
+ // implements REQ-opencode-file-context-guidance-v1
2
+ import { existsSync, readFileSync } from "node:fs";
3
+ import * as path from "node:path";
4
+ import { getFileLinkedTargetsByType } from "./file-entity-links.js";
5
+ // ── TEST doc reader ──────────────────────────────────────────────
6
+ //
7
+ // Reads a TEST-*.md file from documentation/tests/ and extracts
8
+ // frontmatter tags, source, and body.
9
+ function readTestDoc(worktree, testId) {
10
+ // Try common locations for TEST docs
11
+ const candidates = [
12
+ `documentation/tests/${testId}.md`,
13
+ `documentation/tests/${testId.toLowerCase()}.md`,
14
+ ];
15
+ for (const rel of candidates) {
16
+ const fullPath = path.join(worktree, rel);
17
+ if (existsSync(fullPath)) {
18
+ try {
19
+ const content = readFileSync(fullPath, "utf8");
20
+ return parseTestDoc(content, testId);
21
+ }
22
+ catch {
23
+ continue;
24
+ }
25
+ }
26
+ }
27
+ return null;
28
+ }
29
+ function parseTestDoc(content, id) {
30
+ const result = { id, title: id };
31
+ // Extract frontmatter
32
+ const fmMatch = content.match(/^---\n([\s\S]*?)\n---/);
33
+ if (!fmMatch || fmMatch[1] === undefined) {
34
+ result.body = content;
35
+ return result;
36
+ }
37
+ const frontmatter = fmMatch[1];
38
+ // Parse title
39
+ const titleMatch = frontmatter.match(/^title:\s*(.+)$/m);
40
+ if (titleMatch && titleMatch[1] !== undefined) {
41
+ result.title = titleMatch[1].trim();
42
+ }
43
+ // Parse status
44
+ const statusMatch = frontmatter.match(/^status:\s*(.+)$/m);
45
+ if (statusMatch && statusMatch[1] !== undefined) {
46
+ result.status = statusMatch[1].trim();
47
+ }
48
+ // Parse source
49
+ const sourceMatch = frontmatter.match(/^source:\s*(.+)$/m);
50
+ if (sourceMatch && sourceMatch[1] !== undefined) {
51
+ result.source = sourceMatch[1].trim();
52
+ }
53
+ // Parse tags
54
+ const tagsMatch = frontmatter.match(/^tags:\s*$/m);
55
+ if (tagsMatch) {
56
+ const afterTags = frontmatter.slice(frontmatter.indexOf("tags:") + "tags:".length);
57
+ const tagLines = afterTags.match(/^\s+-\s+(.+)$/gm);
58
+ if (tagLines) {
59
+ result.tags = tagLines.map((l) => l.replace(/^\s+-\s+/, "").trim());
60
+ }
61
+ }
62
+ // Extract body (after frontmatter)
63
+ const bodyMatch = content.match(/^---\n[\s\S]*?\n---\n([\s\S]*)$/);
64
+ if (bodyMatch && bodyMatch[1] !== undefined) {
65
+ result.body = bodyMatch[1];
66
+ }
67
+ return result;
68
+ }
69
+ // ── E2e detection predicates ─────────────────────────────────────
70
+ const E2E_SOURCE_PREFIXES = [
71
+ "documentation/tests/e2e/",
72
+ "documentation/tests/e2e/packed/",
73
+ ];
74
+ function isExactE2eEvidence(doc) {
75
+ // (a) has e2e tag
76
+ if (doc.tags?.includes("e2e"))
77
+ return true;
78
+ // (b) source points into e2e directories
79
+ if (doc.source) {
80
+ for (const prefix of E2E_SOURCE_PREFIXES) {
81
+ if (doc.source.startsWith(prefix))
82
+ return true;
83
+ }
84
+ }
85
+ return false;
86
+ }
87
+ function isPackageLevelUmbrellaDoc(testId) {
88
+ // Package-level umbrella docs like TEST-opencode-kibi-plugin-v1
89
+ // These are broad test manifests, not file-specific e2e evidence
90
+ return /^TEST-opencode-.*-plugin-v\d+$/.test(testId);
91
+ }
92
+ function docNamesPath(doc, queryRelPath, distRelPath, srcCorrespondingPath) {
93
+ const body = doc.body ?? "";
94
+ return (body.includes(queryRelPath) ||
95
+ (distRelPath !== null && body.includes(distRelPath)) ||
96
+ (srcCorrespondingPath !== null && body.includes(srcCorrespondingPath)));
97
+ }
98
+ // ── Main exported function ───────────────────────────────────────
99
+ const EXACT_REMINDER = "- This file has existing e2e coverage. Check whether the e2e tests and linked TEST entities need updates.";
100
+ const HEURISTIC_REMINDER = "- This file may have related e2e coverage. Check the linked e2e tests if this change affects behavior.";
101
+ // implements REQ-opencode-file-context-guidance-v1
102
+ export function getE2eCoverageSignal(worktree, filePath) {
103
+ // Compute relative paths for heuristic matching
104
+ const srcRelPath = path
105
+ .relative(worktree, filePath)
106
+ .split(path.sep)
107
+ .join("/");
108
+ // For dist/ files, compute the matching src/ path
109
+ let distRelPath = null;
110
+ let srcCorrespondingPath = null;
111
+ if (srcRelPath.startsWith("packages/opencode/dist/")) {
112
+ distRelPath = srcRelPath;
113
+ // Derive the src/ path: packages/opencode/dist/toast.js → packages/opencode/src/toast.ts
114
+ const distSuffix = srcRelPath.slice("packages/opencode/dist/".length);
115
+ const baseName = distSuffix.replace(/\.js$/, ".ts");
116
+ srcCorrespondingPath = `packages/opencode/src/${baseName}`;
117
+ }
118
+ // Step 1: Get linked TEST-* targets via symbols.yaml relationships
119
+ // Try the actual file path first, then also try the src/ corresponding path for dist/ files
120
+ let linkedTargets = getFileLinkedTargetsByType(worktree, filePath, [
121
+ "covered_by",
122
+ "executable_for",
123
+ ]);
124
+ if (linkedTargets.length === 0 && srcCorrespondingPath) {
125
+ const srcAbsPath = path.join(worktree, srcCorrespondingPath);
126
+ linkedTargets = getFileLinkedTargetsByType(worktree, srcAbsPath, [
127
+ "covered_by",
128
+ "executable_for",
129
+ ]);
130
+ }
131
+ // Track exact and heuristic evidence
132
+ const exactEvidence = [];
133
+ const heuristicEvidence = [];
134
+ for (const targetId of linkedTargets) {
135
+ if (!targetId.startsWith("TEST-"))
136
+ continue;
137
+ const doc = readTestDoc(worktree, targetId);
138
+ if (!doc)
139
+ continue;
140
+ const isUmbrella = isPackageLevelUmbrellaDoc(targetId);
141
+ const hasExactE2e = isExactE2eEvidence(doc);
142
+ const namesPath = docNamesPath(doc, srcRelPath, distRelPath, srcCorrespondingPath);
143
+ if (isUmbrella) {
144
+ // Package-level umbrella docs are demoted to heuristic at most
145
+ // and only if they explicitly name the path
146
+ if (namesPath) {
147
+ heuristicEvidence.push(`${targetId} (umbrella doc names path: ${srcRelPath})`);
148
+ }
149
+ // Never exact for umbrella docs
150
+ continue;
151
+ }
152
+ if (hasExactE2e) {
153
+ exactEvidence.push(targetId);
154
+ }
155
+ else if (namesPath) {
156
+ // Heuristic: non-e2e doc that explicitly names the source path
157
+ heuristicEvidence.push(`${targetId} (doc names path: ${srcRelPath})`);
158
+ }
159
+ }
160
+ // Step 2: Also check heuristic path rules when no exact evidence
161
+ if (exactEvidence.length === 0 && heuristicEvidence.length === 0) {
162
+ // Narrow heuristic: file under packages/opencode/src/ and a test doc body names it
163
+ // This is already covered by the linked targets loop above since we check docNamesPath
164
+ // No additional scanning needed - we only inspect linked docs
165
+ }
166
+ // Step 3: Resolve level
167
+ if (exactEvidence.length > 0) {
168
+ return {
169
+ level: "exact",
170
+ evidence: exactEvidence,
171
+ reminderText: EXACT_REMINDER,
172
+ };
173
+ }
174
+ if (heuristicEvidence.length > 0) {
175
+ return {
176
+ level: "heuristic",
177
+ evidence: heuristicEvidence,
178
+ reminderText: HEURISTIC_REMINDER,
179
+ };
180
+ }
181
+ return {
182
+ level: "none",
183
+ evidence: [],
184
+ reminderText: null,
185
+ };
186
+ }
@@ -0,0 +1,15 @@
1
+ export type SymbolsManifestRow = {
2
+ id?: string;
3
+ sourceFile?: string;
4
+ links?: string[];
5
+ relationships?: Array<{
6
+ type: string;
7
+ target: string;
8
+ }>;
9
+ };
10
+ export declare function parseSymbolsYaml(content: string): SymbolsManifestRow[];
11
+ export declare function getFileLinkedEntityIds(worktree: string, filePath: string): {
12
+ ids: string[];
13
+ source: "symbols" | "doc-path" | "none";
14
+ };
15
+ export declare function getFileLinkedTargetsByType(worktree: string, filePath: string, relationshipTypes: string[]): string[];
@@ -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
+ }