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.
- package/README.md +37 -12
- package/dist/brief-intent.d.ts +41 -0
- package/dist/brief-intent.js +127 -0
- package/dist/briefing-runtime.d.ts +24 -0
- package/dist/briefing-runtime.js +277 -0
- 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 +15 -1
- package/dist/index.js +645 -22
- 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 +18 -3
- package/dist/prompt.js +176 -50
- 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.d.ts +3 -18
- package/dist/startup-notifier.js +42 -36
- package/dist/toast.d.ts +31 -0
- package/dist/toast.js +40 -0
- 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
|
+
// 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
|
+
}
|