kibi-opencode 0.5.4 → 0.6.1
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 +27 -14
- package/dist/config.d.ts +9 -0
- package/dist/config.js +31 -0
- package/dist/guidance-cache.d.ts +75 -0
- package/dist/guidance-cache.js +145 -0
- package/dist/index.d.ts +3 -0
- package/dist/index.js +428 -56
- package/dist/logger.d.ts +4 -3
- package/dist/logger.js +14 -18
- package/dist/path-kind.d.ts +2 -2
- package/dist/path-kind.js +12 -4
- package/dist/prompt.d.ts +22 -1
- package/dist/prompt.js +297 -121
- package/dist/repo-posture.d.ts +12 -0
- package/dist/repo-posture.js +196 -0
- package/dist/risk-classifier.d.ts +39 -0
- package/dist/risk-classifier.js +111 -0
- package/dist/smart-enforcement.d.ts +41 -0
- package/dist/smart-enforcement.js +48 -0
- package/dist/source-linked-guidance.d.ts +10 -0
- package/dist/source-linked-guidance.js +164 -0
- package/dist/workspace-health.d.ts +2 -0
- package/dist/workspace-health.js +14 -6
- package/package.json +1 -1
|
@@ -0,0 +1,196 @@
|
|
|
1
|
+
// implements REQ-opencode-smart-enforcement-v1, REQ-opencode-kibi-plugin-v1
|
|
2
|
+
import { existsSync, readFileSync, readdirSync } from "node:fs";
|
|
3
|
+
import { join, resolve } from "node:path";
|
|
4
|
+
// Default sync paths — must stay in sync with file-filter.ts DEFAULT_SYNC_PATHS
|
|
5
|
+
const DEFAULT_SYNC_PATHS = {
|
|
6
|
+
requirements: "documentation/requirements/**/*.md",
|
|
7
|
+
scenarios: "documentation/scenarios/**/*.md",
|
|
8
|
+
tests: "documentation/tests/**/*.md",
|
|
9
|
+
adr: "documentation/adr/**/*.md",
|
|
10
|
+
flags: "documentation/flags/**/*.md",
|
|
11
|
+
events: "documentation/events/**/*.md",
|
|
12
|
+
facts: "documentation/facts/**/*.md",
|
|
13
|
+
symbols: "documentation/symbols.yaml",
|
|
14
|
+
};
|
|
15
|
+
// ── helpers ────────────────────────────────────────────────────────────
|
|
16
|
+
function findVendoredTrees(cwd) {
|
|
17
|
+
const results = [];
|
|
18
|
+
const vendoredMarkers = [
|
|
19
|
+
["kibi", "opencode.json"],
|
|
20
|
+
["kibi", "package.json"],
|
|
21
|
+
["kibi", "packages", "mcp"],
|
|
22
|
+
["kibi", "documentation"],
|
|
23
|
+
];
|
|
24
|
+
for (const marker of vendoredMarkers) {
|
|
25
|
+
const markerPath = join(cwd, ...marker);
|
|
26
|
+
if (existsSync(markerPath)) {
|
|
27
|
+
results.push(marker.join("/"));
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
const nodeModules = join(cwd, "node_modules");
|
|
31
|
+
if (existsSync(nodeModules)) {
|
|
32
|
+
for (const entry of readdirSync(nodeModules)) {
|
|
33
|
+
if (entry === "kibi" || entry.startsWith("kibi-")) {
|
|
34
|
+
results.push(`node_modules/${entry}`);
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
return [...new Set(results)];
|
|
39
|
+
}
|
|
40
|
+
/** Check if .kb/config.json exists at root. */
|
|
41
|
+
function rootKbConfigExists(cwd) {
|
|
42
|
+
return existsSync(join(cwd, ".kb", "config.json"));
|
|
43
|
+
}
|
|
44
|
+
/** Read and parse .kb/config.json. Returns null on failure. */
|
|
45
|
+
function readRootConfig(cwd) {
|
|
46
|
+
try {
|
|
47
|
+
return (JSON.parse(readFileSync(join(cwd, ".kb", "config.json"), "utf8")) || null);
|
|
48
|
+
}
|
|
49
|
+
catch {
|
|
50
|
+
return null;
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
/** Check if all configured KB targets resolve (directories / files exist). */
|
|
54
|
+
function rootTargetsAllResolve(cwd) {
|
|
55
|
+
const configPath = join(cwd, ".kb", "config.json");
|
|
56
|
+
let config = {};
|
|
57
|
+
try {
|
|
58
|
+
config = JSON.parse(readFileSync(configPath, "utf8")) || {};
|
|
59
|
+
}
|
|
60
|
+
catch {
|
|
61
|
+
return false;
|
|
62
|
+
}
|
|
63
|
+
const paths = config.paths;
|
|
64
|
+
const defaultKeys = [
|
|
65
|
+
"requirements",
|
|
66
|
+
"scenarios",
|
|
67
|
+
"tests",
|
|
68
|
+
"adr",
|
|
69
|
+
"flags",
|
|
70
|
+
"events",
|
|
71
|
+
"facts",
|
|
72
|
+
"symbols",
|
|
73
|
+
];
|
|
74
|
+
for (const key of defaultKeys) {
|
|
75
|
+
const raw = paths?.[key] ?? DEFAULT_SYNC_PATHS[key];
|
|
76
|
+
// Normalize: strip trailing slashes and glob patterns to get the root dir/file path
|
|
77
|
+
const normalized = raw.replace(/\/+$/, "");
|
|
78
|
+
const isFile = normalized.endsWith(".yaml") || normalized.endsWith(".yml");
|
|
79
|
+
if (isFile) {
|
|
80
|
+
if (!existsSync(resolve(cwd, normalized)))
|
|
81
|
+
return false;
|
|
82
|
+
}
|
|
83
|
+
else {
|
|
84
|
+
// Strip first glob segment
|
|
85
|
+
const segments = normalized.split("/");
|
|
86
|
+
const rootSegments = [];
|
|
87
|
+
for (const seg of segments) {
|
|
88
|
+
if (seg.includes("*") || seg.includes("?") || seg.includes("["))
|
|
89
|
+
break;
|
|
90
|
+
rootSegments.push(seg);
|
|
91
|
+
}
|
|
92
|
+
const dirPath = rootSegments.join("/") || ".";
|
|
93
|
+
if (!existsSync(resolve(cwd, dirPath)))
|
|
94
|
+
return false;
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
return true;
|
|
98
|
+
}
|
|
99
|
+
/** Detect root-level Kibi intent (plugin config present but no .kb). */
|
|
100
|
+
function hasRootKibiIntent(cwd) {
|
|
101
|
+
if (existsSync(join(cwd, ".opencode", "kibi.json"))) {
|
|
102
|
+
return true;
|
|
103
|
+
}
|
|
104
|
+
try {
|
|
105
|
+
const oc = JSON.parse(readFileSync(join(cwd, "opencode.json"), "utf8"));
|
|
106
|
+
if (oc &&
|
|
107
|
+
Array.isArray(oc.plugin) &&
|
|
108
|
+
oc.plugin.some((p) => typeof p === "string" && p.includes("kibi"))) {
|
|
109
|
+
return true;
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
catch { }
|
|
113
|
+
for (const guidanceFile of [
|
|
114
|
+
"AGENTS.md",
|
|
115
|
+
join(".github", "copilot-instructions.md"),
|
|
116
|
+
]) {
|
|
117
|
+
try {
|
|
118
|
+
const content = readFileSync(join(cwd, guidanceFile), "utf8");
|
|
119
|
+
if (content.includes("kb_query") || content.includes("kb_search")) {
|
|
120
|
+
return true;
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
catch { }
|
|
124
|
+
}
|
|
125
|
+
return false;
|
|
126
|
+
}
|
|
127
|
+
// ── main classifier ────────────────────────────────────────────────────
|
|
128
|
+
export function detectPosture(cwd) {
|
|
129
|
+
const hasRootConfig = rootKbConfigExists(cwd);
|
|
130
|
+
const vendoredTrees = findVendoredTrees(cwd);
|
|
131
|
+
// 1. hybrid_root_plus_vendored — root config AND vendored subtrees
|
|
132
|
+
if (hasRootConfig && vendoredTrees.length > 0) {
|
|
133
|
+
return {
|
|
134
|
+
state: "hybrid_root_plus_vendored",
|
|
135
|
+
needsBootstrap: false,
|
|
136
|
+
reason: `Root .kb/config.json exists alongside vendored tree(s): ${vendoredTrees.join(", ")}`,
|
|
137
|
+
maintenanceDegraded: false,
|
|
138
|
+
};
|
|
139
|
+
}
|
|
140
|
+
// 2-3. root config exists (check maintenance mode and target resolution)
|
|
141
|
+
if (hasRootConfig) {
|
|
142
|
+
const config = readRootConfig(cwd);
|
|
143
|
+
const maint = config?.maintenance;
|
|
144
|
+
const maintenanceDisabled = maint !== undefined && maint.enabled === false;
|
|
145
|
+
// maintenance_degraded overlay: maintenance explicitly disabled → root_active
|
|
146
|
+
if (maintenanceDisabled) {
|
|
147
|
+
return {
|
|
148
|
+
state: "root_active",
|
|
149
|
+
needsBootstrap: false,
|
|
150
|
+
reason: "Root .kb/config.json exists; maintenance mode explicitly disabled",
|
|
151
|
+
maintenanceDegraded: true,
|
|
152
|
+
};
|
|
153
|
+
}
|
|
154
|
+
const allResolve = rootTargetsAllResolve(cwd);
|
|
155
|
+
if (allResolve) {
|
|
156
|
+
return {
|
|
157
|
+
state: "root_active",
|
|
158
|
+
needsBootstrap: false,
|
|
159
|
+
reason: "Root .kb/config.json exists and all configured KB targets resolve",
|
|
160
|
+
maintenanceDegraded: false,
|
|
161
|
+
};
|
|
162
|
+
}
|
|
163
|
+
// 3. root_partial — root config exists but targets are missing
|
|
164
|
+
return {
|
|
165
|
+
state: "root_partial",
|
|
166
|
+
needsBootstrap: true,
|
|
167
|
+
reason: "Root .kb/config.json exists but some configured KB targets are missing",
|
|
168
|
+
maintenanceDegraded: false,
|
|
169
|
+
};
|
|
170
|
+
}
|
|
171
|
+
// 4. vendored_only — no root config, but vendored markers found
|
|
172
|
+
if (vendoredTrees.length > 0) {
|
|
173
|
+
return {
|
|
174
|
+
state: "vendored_only",
|
|
175
|
+
needsBootstrap: false,
|
|
176
|
+
reason: `No root .kb/config.json, but vendored Kibi tree(s) detected: ${vendoredTrees.join(", ")}`,
|
|
177
|
+
maintenanceDegraded: false,
|
|
178
|
+
};
|
|
179
|
+
}
|
|
180
|
+
// 5. root_uninitialized — no root .kb, no vendored trees, but root declares intent
|
|
181
|
+
if (hasRootKibiIntent(cwd)) {
|
|
182
|
+
return {
|
|
183
|
+
state: "root_uninitialized",
|
|
184
|
+
needsBootstrap: true,
|
|
185
|
+
reason: "No root .kb and no vendored trees, but Kibi plugin intent detected at root",
|
|
186
|
+
maintenanceDegraded: false,
|
|
187
|
+
};
|
|
188
|
+
}
|
|
189
|
+
// Fallback: treat as uninitialized (no kibi presence at all)
|
|
190
|
+
return {
|
|
191
|
+
state: "root_uninitialized",
|
|
192
|
+
needsBootstrap: true,
|
|
193
|
+
reason: "No Kibi presence detected in workspace",
|
|
194
|
+
maintenanceDegraded: false,
|
|
195
|
+
};
|
|
196
|
+
}
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import type { PathKind } from "./path-kind.js";
|
|
2
|
+
/**
|
|
3
|
+
* Risk classification for file edits.
|
|
4
|
+
* Ordered from safest to riskiest for consumer convenience.
|
|
5
|
+
*/
|
|
6
|
+
export type RiskClass = "safe_docs_only" | "safe_test_only" | "kb_doc_structural" | "req_policy_candidate" | "behavior_candidate" | "traceability_candidate" | "manual_kb_edit";
|
|
7
|
+
/**
|
|
8
|
+
* Input parameters for risk classification.
|
|
9
|
+
* All fields are cheaply computable — no AST parsing or async required.
|
|
10
|
+
* pathKind already encodes path-based classification from analyzePath().
|
|
11
|
+
*/
|
|
12
|
+
export interface ClassifyRiskParams {
|
|
13
|
+
pathKind: PathKind;
|
|
14
|
+
isUnderKb: boolean;
|
|
15
|
+
hasMustPriority: boolean;
|
|
16
|
+
hasDurableComment: boolean;
|
|
17
|
+
fileContent: string;
|
|
18
|
+
}
|
|
19
|
+
/**
|
|
20
|
+
* Result of risk classification with the determined class and human-readable reasons.
|
|
21
|
+
*/
|
|
22
|
+
export interface RiskClassification {
|
|
23
|
+
riskClass: RiskClass;
|
|
24
|
+
reasons: string[];
|
|
25
|
+
}
|
|
26
|
+
/**
|
|
27
|
+
* Classify a file edit into a deterministic risk class.
|
|
28
|
+
*
|
|
29
|
+
* Classification order (first match wins):
|
|
30
|
+
* 1. manual_kb_edit — file is under .kb/
|
|
31
|
+
* 2. safe_test_only — pathKind is "test"
|
|
32
|
+
* 3. req_policy_candidate — pathKind is "requirement"
|
|
33
|
+
* 4. kb_doc_structural — pathKind is "scenario", "adr", or "fact"
|
|
34
|
+
* 5. safe_docs_only — pathKind is "unknown" (markdown/config/etc.)
|
|
35
|
+
* 6. traceability_candidate — code with exports AND (hasDurableComment OR missing traceability)
|
|
36
|
+
* 7. behavior_candidate — code with exports, already has traceability, no durable comment
|
|
37
|
+
* 8. safe_docs_only — code without exports, or fallback
|
|
38
|
+
*/
|
|
39
|
+
export declare function classifyRisk(params: ClassifyRiskParams): RiskClassification;
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
// implements REQ-opencode-smart-enforcement-v1, REQ-opencode-kibi-plugin-v1
|
|
2
|
+
/**
|
|
3
|
+
* Regex to detect exportable code constructs (functions, classes, variables).
|
|
4
|
+
*/
|
|
5
|
+
const BEHAVIOR_PATTERN = /(?:export\s+(?:function|class|const|let|var)|\bclass\s+\w+|\bfunction\s+\w+|\bdef\s+\w+)/;
|
|
6
|
+
/**
|
|
7
|
+
* Regex to detect traceability annotations.
|
|
8
|
+
*/
|
|
9
|
+
const TRACEABILITY_PATTERN = /implements\s+REQ-[A-Za-z0-9_-]+/i;
|
|
10
|
+
/**
|
|
11
|
+
* Classify a file edit into a deterministic risk class.
|
|
12
|
+
*
|
|
13
|
+
* Classification order (first match wins):
|
|
14
|
+
* 1. manual_kb_edit — file is under .kb/
|
|
15
|
+
* 2. safe_test_only — pathKind is "test"
|
|
16
|
+
* 3. req_policy_candidate — pathKind is "requirement"
|
|
17
|
+
* 4. kb_doc_structural — pathKind is "scenario", "adr", or "fact"
|
|
18
|
+
* 5. safe_docs_only — pathKind is "unknown" (markdown/config/etc.)
|
|
19
|
+
* 6. traceability_candidate — code with exports AND (hasDurableComment OR missing traceability)
|
|
20
|
+
* 7. behavior_candidate — code with exports, already has traceability, no durable comment
|
|
21
|
+
* 8. safe_docs_only — code without exports, or fallback
|
|
22
|
+
*/
|
|
23
|
+
export function classifyRisk(params) {
|
|
24
|
+
const { pathKind, isUnderKb, hasMustPriority, hasDurableComment, fileContent, } = params;
|
|
25
|
+
// 1. manual_kb_edit — direct KB manipulation is always risky
|
|
26
|
+
if (isUnderKb) {
|
|
27
|
+
return {
|
|
28
|
+
riskClass: "manual_kb_edit",
|
|
29
|
+
reasons: ["File is under .kb/ — manual edits bypass KB validation"],
|
|
30
|
+
};
|
|
31
|
+
}
|
|
32
|
+
// 2. safe_test_only — test files are low-risk
|
|
33
|
+
if (pathKind === "test") {
|
|
34
|
+
return {
|
|
35
|
+
riskClass: "safe_test_only",
|
|
36
|
+
reasons: ["File is a test file — edits are low risk"],
|
|
37
|
+
};
|
|
38
|
+
}
|
|
39
|
+
// 3. req_policy_candidate — requirement docs need policy checks
|
|
40
|
+
if (pathKind === "requirement") {
|
|
41
|
+
const reasons = [
|
|
42
|
+
"File is a requirement document — policy checks recommended",
|
|
43
|
+
];
|
|
44
|
+
if (hasMustPriority) {
|
|
45
|
+
reasons.push("Requirement has priority:must — elevated validation required");
|
|
46
|
+
}
|
|
47
|
+
return {
|
|
48
|
+
riskClass: "req_policy_candidate",
|
|
49
|
+
reasons,
|
|
50
|
+
};
|
|
51
|
+
}
|
|
52
|
+
// 4. kb_doc_structural — structural KB documentation
|
|
53
|
+
if (pathKind === "scenario" ||
|
|
54
|
+
pathKind === "adr" ||
|
|
55
|
+
pathKind === "fact" ||
|
|
56
|
+
pathKind === "flag" ||
|
|
57
|
+
pathKind === "event" ||
|
|
58
|
+
pathKind === "symbol") {
|
|
59
|
+
return {
|
|
60
|
+
riskClass: "kb_doc_structural",
|
|
61
|
+
reasons: [
|
|
62
|
+
"File is structural KB documentation — relationship and field validation recommended",
|
|
63
|
+
],
|
|
64
|
+
};
|
|
65
|
+
}
|
|
66
|
+
// 5. safe_docs_only — non-code, non-kb-doc files (markdown, config, etc.)
|
|
67
|
+
if (pathKind === "unknown") {
|
|
68
|
+
return {
|
|
69
|
+
riskClass: "safe_docs_only",
|
|
70
|
+
reasons: ["File is not a code or KB document — low risk"],
|
|
71
|
+
};
|
|
72
|
+
}
|
|
73
|
+
// pathKind === "code" from here on
|
|
74
|
+
if (pathKind === "code") {
|
|
75
|
+
const hasBehavior = BEHAVIOR_PATTERN.test(fileContent);
|
|
76
|
+
const hasTraceability = TRACEABILITY_PATTERN.test(fileContent);
|
|
77
|
+
if (hasBehavior) {
|
|
78
|
+
// 6. traceability_candidate — needs traceability attention
|
|
79
|
+
if (hasDurableComment || !hasTraceability) {
|
|
80
|
+
const reasons = [];
|
|
81
|
+
if (!hasTraceability) {
|
|
82
|
+
reasons.push("Code file contains exports without // implements REQ-xxx annotation");
|
|
83
|
+
}
|
|
84
|
+
if (hasDurableComment) {
|
|
85
|
+
reasons.push("Durable knowledge comment detected — traceability review recommended");
|
|
86
|
+
}
|
|
87
|
+
return {
|
|
88
|
+
riskClass: "traceability_candidate",
|
|
89
|
+
reasons,
|
|
90
|
+
};
|
|
91
|
+
}
|
|
92
|
+
// 7. behavior_candidate — has exports and traceability, no durable comment
|
|
93
|
+
return {
|
|
94
|
+
riskClass: "behavior_candidate",
|
|
95
|
+
reasons: [
|
|
96
|
+
"Code file contains exportable constructs — discovery guidance applies",
|
|
97
|
+
],
|
|
98
|
+
};
|
|
99
|
+
}
|
|
100
|
+
// Code file without export patterns — treat as safe
|
|
101
|
+
return {
|
|
102
|
+
riskClass: "safe_docs_only",
|
|
103
|
+
reasons: ["Code file has no detected export patterns — low risk"],
|
|
104
|
+
};
|
|
105
|
+
}
|
|
106
|
+
// Fallback — should not be reached since all PathKind values are handled
|
|
107
|
+
return {
|
|
108
|
+
riskClass: "safe_docs_only",
|
|
109
|
+
reasons: ["File type is unhandled — defaulting to safe classification"],
|
|
110
|
+
};
|
|
111
|
+
}
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import type { RepoPosture } from "./repo-posture.js";
|
|
2
|
+
/**
|
|
3
|
+
* The effective enforcement mode after resolving config + posture + maintenance state.
|
|
4
|
+
* - "advisory": plugin emits guidance, logs, reminders — never blocks
|
|
5
|
+
* - "strict": plugin may escalate targeted checks, completion reminders, and
|
|
6
|
+
* structured logging. Hooks/checks remain the hard enforcement boundary
|
|
7
|
+
* regardless of mode.
|
|
8
|
+
*/
|
|
9
|
+
export type EffectiveMode = "advisory" | "strict";
|
|
10
|
+
/**
|
|
11
|
+
* Inputs required to determine the effective smart-enforcement mode.
|
|
12
|
+
*/
|
|
13
|
+
export interface ModeInputs {
|
|
14
|
+
/** Configured smart-enforcement mode. */
|
|
15
|
+
mode: "advisory" | "strict";
|
|
16
|
+
/** When true, strict mode only activates for authoritative root KB postures. */
|
|
17
|
+
requireRootKbForStrict: boolean;
|
|
18
|
+
/** Current repository posture from detectPosture(). */
|
|
19
|
+
posture: RepoPosture;
|
|
20
|
+
/** Whether the maintenance subsystem is in a degraded state. */
|
|
21
|
+
maintenanceDegraded: boolean;
|
|
22
|
+
}
|
|
23
|
+
/**
|
|
24
|
+
* Determine whether the current posture qualifies for strict enforcement
|
|
25
|
+
* when `requireRootKbForStrict` is true.
|
|
26
|
+
*
|
|
27
|
+
* Only root_active and hybrid_root_plus_vendored are considered authoritative.
|
|
28
|
+
*/
|
|
29
|
+
export declare function isStrictEligible(inputs: ModeInputs): boolean;
|
|
30
|
+
/**
|
|
31
|
+
* Compute the effective smart-enforcement mode.
|
|
32
|
+
*
|
|
33
|
+
* Decision matrix:
|
|
34
|
+
* - advisory config → always advisory
|
|
35
|
+
* - strict config + requireRootKbForStrict=true → strict only for root_active
|
|
36
|
+
* and hybrid_root_plus_vendored postures
|
|
37
|
+
* - strict config + requireRootKbForStrict=false → strict may apply to all
|
|
38
|
+
* postures (but hooks/checks remain hard gate regardless)
|
|
39
|
+
* - maintenance-degraded → advisory regardless of config
|
|
40
|
+
*/
|
|
41
|
+
export declare function computeEffectiveMode(inputs: ModeInputs): EffectiveMode;
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
/** Postures considered authoritative for strict enforcement. */
|
|
2
|
+
const STRICT_ELIGIBLE_POSTURES = new Set([
|
|
3
|
+
"root_active",
|
|
4
|
+
"hybrid_root_plus_vendored",
|
|
5
|
+
]);
|
|
6
|
+
/**
|
|
7
|
+
* Determine whether the current posture qualifies for strict enforcement
|
|
8
|
+
* when `requireRootKbForStrict` is true.
|
|
9
|
+
*
|
|
10
|
+
* Only root_active and hybrid_root_plus_vendored are considered authoritative.
|
|
11
|
+
*/
|
|
12
|
+
export function isStrictEligible(inputs) {
|
|
13
|
+
if (inputs.maintenanceDegraded)
|
|
14
|
+
return false;
|
|
15
|
+
if (inputs.requireRootKbForStrict) {
|
|
16
|
+
return STRICT_ELIGIBLE_POSTURES.has(inputs.posture);
|
|
17
|
+
}
|
|
18
|
+
// When requireRootKbForStrict is false, strict may apply to all postures
|
|
19
|
+
// (but still subject to maintenance-degraded override in computeEffectiveMode).
|
|
20
|
+
return true;
|
|
21
|
+
}
|
|
22
|
+
/**
|
|
23
|
+
* Compute the effective smart-enforcement mode.
|
|
24
|
+
*
|
|
25
|
+
* Decision matrix:
|
|
26
|
+
* - advisory config → always advisory
|
|
27
|
+
* - strict config + requireRootKbForStrict=true → strict only for root_active
|
|
28
|
+
* and hybrid_root_plus_vendored postures
|
|
29
|
+
* - strict config + requireRootKbForStrict=false → strict may apply to all
|
|
30
|
+
* postures (but hooks/checks remain hard gate regardless)
|
|
31
|
+
* - maintenance-degraded → advisory regardless of config
|
|
32
|
+
*/
|
|
33
|
+
export function computeEffectiveMode(inputs) {
|
|
34
|
+
// Maintenance-degraded always forces advisory
|
|
35
|
+
if (inputs.maintenanceDegraded) {
|
|
36
|
+
return "advisory";
|
|
37
|
+
}
|
|
38
|
+
// Advisory config always produces advisory behavior
|
|
39
|
+
if (inputs.mode === "advisory") {
|
|
40
|
+
return "advisory";
|
|
41
|
+
}
|
|
42
|
+
// Strict config: check posture eligibility
|
|
43
|
+
if (isStrictEligible(inputs)) {
|
|
44
|
+
return "strict";
|
|
45
|
+
}
|
|
46
|
+
// Strict config but not eligible → fall back to advisory
|
|
47
|
+
return "advisory";
|
|
48
|
+
}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Resolve the configured symbols manifest path using loadKbSyncPaths(worktree),
|
|
3
|
+
* read the YAML synchronously, and return up to 3 deduped REQ IDs linked to
|
|
4
|
+
* the edited file path. Preference is given to relationships[type=implements].target
|
|
5
|
+
* (in file order) then static links as a fallback, preserving file order.
|
|
6
|
+
*
|
|
7
|
+
* Supports both YAML formats: top-level array and { symbols: [...] } object.
|
|
8
|
+
* This function is purely synchronous and makes no runtime KB queries.
|
|
9
|
+
*/
|
|
10
|
+
export declare function getSourceLinkedRequirementIds(worktree: string, editedAbsolutePath: string): string[];
|
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
// implements REQ-opencode-smart-enforcement-v1
|
|
2
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
3
|
+
import * as path from "node:path";
|
|
4
|
+
import { loadKbSyncPaths } from "./file-filter.js";
|
|
5
|
+
/**
|
|
6
|
+
* Resolve the configured symbols manifest path using loadKbSyncPaths(worktree),
|
|
7
|
+
* read the YAML synchronously, and return up to 3 deduped REQ IDs linked to
|
|
8
|
+
* the edited file path. Preference is given to relationships[type=implements].target
|
|
9
|
+
* (in file order) then static links as a fallback, preserving file order.
|
|
10
|
+
*
|
|
11
|
+
* Supports both YAML formats: top-level array and { symbols: [...] } object.
|
|
12
|
+
* This function is purely synchronous and makes no runtime KB queries.
|
|
13
|
+
*/
|
|
14
|
+
// implements REQ-opencode-smart-enforcement-v1
|
|
15
|
+
export function getSourceLinkedRequirementIds(worktree, editedAbsolutePath) {
|
|
16
|
+
try {
|
|
17
|
+
const paths = loadKbSyncPaths(worktree);
|
|
18
|
+
const symbolsPathRaw = paths.symbols;
|
|
19
|
+
if (!symbolsPathRaw)
|
|
20
|
+
return [];
|
|
21
|
+
const symbolsPath = path.isAbsolute(symbolsPathRaw)
|
|
22
|
+
? symbolsPathRaw
|
|
23
|
+
: path.join(worktree, symbolsPathRaw);
|
|
24
|
+
if (!existsSync(symbolsPath))
|
|
25
|
+
return [];
|
|
26
|
+
const content = readFileSync(symbolsPath, "utf8");
|
|
27
|
+
const symbols = parseSymbolsYaml(content);
|
|
28
|
+
const relEdited = path
|
|
29
|
+
.relative(worktree, editedAbsolutePath)
|
|
30
|
+
.split(path.sep)
|
|
31
|
+
.join("/");
|
|
32
|
+
const matchedRows = symbols.filter((s) => s.sourceFile === relEdited);
|
|
33
|
+
if (matchedRows.length === 0)
|
|
34
|
+
return [];
|
|
35
|
+
const seen = new Set();
|
|
36
|
+
const orderedIds = [];
|
|
37
|
+
// First pass: collect implements relationships in file order
|
|
38
|
+
for (const row of matchedRows) {
|
|
39
|
+
for (const r of row.relationships ?? []) {
|
|
40
|
+
if (r.type === "implements") {
|
|
41
|
+
const id = r.target;
|
|
42
|
+
if (!seen.has(id)) {
|
|
43
|
+
seen.add(id);
|
|
44
|
+
orderedIds.push(id);
|
|
45
|
+
if (orderedIds.length >= 3)
|
|
46
|
+
return orderedIds.slice(0, 3);
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
// Second pass: fall back to static links, preserving file order
|
|
52
|
+
for (const row of matchedRows) {
|
|
53
|
+
for (const l of row.links ?? []) {
|
|
54
|
+
if (!seen.has(l)) {
|
|
55
|
+
seen.add(l);
|
|
56
|
+
orderedIds.push(l);
|
|
57
|
+
if (orderedIds.length >= 3)
|
|
58
|
+
return orderedIds.slice(0, 3);
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
return orderedIds.slice(0, 3);
|
|
63
|
+
}
|
|
64
|
+
catch {
|
|
65
|
+
return [];
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
// ── Lightweight YAML parser (symbols.yaml subset) ────────────────────
|
|
69
|
+
//
|
|
70
|
+
// Handles:
|
|
71
|
+
// symbols:
|
|
72
|
+
// - id: SYM-xxx
|
|
73
|
+
// sourceFile: path/to/file
|
|
74
|
+
// links:
|
|
75
|
+
// - REQ-xxx
|
|
76
|
+
// relationships:
|
|
77
|
+
// - type: implements
|
|
78
|
+
// target: REQ-xxx
|
|
79
|
+
//
|
|
80
|
+
// And bare array format (no wrapping `symbols:` key):
|
|
81
|
+
// - id: SYM-xxx
|
|
82
|
+
// ...
|
|
83
|
+
function parseSymbolsYaml(content) {
|
|
84
|
+
const entries = [];
|
|
85
|
+
const lines = content.split("\n");
|
|
86
|
+
let current = null;
|
|
87
|
+
let section = "none";
|
|
88
|
+
let pendingRel = null;
|
|
89
|
+
function flushRel() {
|
|
90
|
+
if (pendingRel?.type && pendingRel.target && current) {
|
|
91
|
+
current.relationships.push({
|
|
92
|
+
type: pendingRel.type,
|
|
93
|
+
target: pendingRel.target,
|
|
94
|
+
});
|
|
95
|
+
}
|
|
96
|
+
pendingRel = null;
|
|
97
|
+
}
|
|
98
|
+
function flushEntry() {
|
|
99
|
+
flushRel();
|
|
100
|
+
if (current?.id && current?.sourceFile) {
|
|
101
|
+
entries.push(current);
|
|
102
|
+
}
|
|
103
|
+
current = null;
|
|
104
|
+
section = "none";
|
|
105
|
+
}
|
|
106
|
+
for (const raw of lines) {
|
|
107
|
+
if (raw.trim().startsWith("#"))
|
|
108
|
+
continue;
|
|
109
|
+
// New entry: " - id: ..."
|
|
110
|
+
const entryMatch = raw.match(/^\s+-\s+id:\s*(.+)$/);
|
|
111
|
+
if (entryMatch) {
|
|
112
|
+
flushEntry();
|
|
113
|
+
current = { id: entryMatch[1].trim(), links: [], relationships: [] };
|
|
114
|
+
section = "none";
|
|
115
|
+
continue;
|
|
116
|
+
}
|
|
117
|
+
if (!current)
|
|
118
|
+
continue;
|
|
119
|
+
// sourceFile
|
|
120
|
+
const srcMatch = raw.match(/^\s+sourceFile:\s*(.+)$/);
|
|
121
|
+
if (srcMatch) {
|
|
122
|
+
current.sourceFile = srcMatch[1].trim();
|
|
123
|
+
section = "none";
|
|
124
|
+
continue;
|
|
125
|
+
}
|
|
126
|
+
// links section header
|
|
127
|
+
if (/^\s+links:\s*$/.test(raw)) {
|
|
128
|
+
flushRel();
|
|
129
|
+
section = "links";
|
|
130
|
+
continue;
|
|
131
|
+
}
|
|
132
|
+
// relationships section header
|
|
133
|
+
if (/^\s+relationships:\s*$/.test(raw)) {
|
|
134
|
+
flushRel();
|
|
135
|
+
section = "relationships";
|
|
136
|
+
continue;
|
|
137
|
+
}
|
|
138
|
+
// Link item: " - REQ-xxx"
|
|
139
|
+
if (section === "links") {
|
|
140
|
+
const linkMatch = raw.match(/^\s+-\s+(REQ-[A-Za-z0-9_-]+)\s*$/);
|
|
141
|
+
if (linkMatch) {
|
|
142
|
+
current.links.push(linkMatch[1]);
|
|
143
|
+
continue;
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
// Relationship type: " - type: implements"
|
|
147
|
+
if (section === "relationships") {
|
|
148
|
+
const relTypeMatch = raw.match(/^\s+-\s+type:\s*(.+)$/);
|
|
149
|
+
if (relTypeMatch) {
|
|
150
|
+
flushRel();
|
|
151
|
+
pendingRel = { type: relTypeMatch[1].trim() };
|
|
152
|
+
continue;
|
|
153
|
+
}
|
|
154
|
+
// Relationship target: " target: REQ-..."
|
|
155
|
+
const relTargetMatch = raw.match(/^\s+target:\s*(.+)$/);
|
|
156
|
+
if (relTargetMatch && pendingRel) {
|
|
157
|
+
pendingRel.target = relTargetMatch[1].trim();
|
|
158
|
+
continue;
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
flushEntry();
|
|
163
|
+
return entries;
|
|
164
|
+
}
|
|
@@ -6,5 +6,7 @@ export interface WorkspaceHealth {
|
|
|
6
6
|
}
|
|
7
7
|
/**
|
|
8
8
|
* Analyze workspace health for Kibi bootstrap and initialization.
|
|
9
|
+
* Uses detectPosture() for root-scoped classification and delegates
|
|
10
|
+
* bootstrap-needs to the posture result.
|
|
9
11
|
*/
|
|
10
12
|
export declare function checkWorkspaceHealth(cwd: string): WorkspaceHealth;
|