kibi-cli 0.10.1 → 0.11.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.
@@ -0,0 +1,89 @@
1
+ # Relationship Directions
2
+
3
+ ## Direction Table
4
+
5
+ | Relationship | Source -> Target | Semantic Meaning |
6
+ |-------------|------------------|------------------|
7
+ | `implements` | symbol -> req | Production code symbol owns or implements requirement behavior |
8
+ | `specified_by` | req -> scenario | Requirement is specified by a BDD scenario |
9
+ | `verified_by` | req/scenario -> test | Requirement or scenario is verified by a test case |
10
+ | `validates` | test -> req/scenario | Test validates a requirement or scenario (inverse of verified_by) |
11
+ | `executable_for` | symbol -> test | Test symbol (code) is executable code for a test entity |
12
+ | `constrains` | req -> fact(subject) | Requirement constrains a strict-lane domain fact |
13
+ | `requires_property` | req -> fact(property_value) | Requirement requires a specific property value fact |
14
+ | `supersedes` | old-req -> new-req | Old requirement is formally replaced by a new requirement |
15
+ | `covered_by` | symbol -> test | Production symbol has test coverage evidence |
16
+
17
+ ## Valid Payload Examples
18
+
19
+ ### implements
20
+ ```yaml
21
+ relationships:
22
+ - type: implements
23
+ from: SYM-001
24
+ to: REQ-001
25
+ ```
26
+
27
+ ### specified_by
28
+ ```yaml
29
+ relationships:
30
+ - type: specified_by
31
+ from: REQ-001
32
+ to: SCEN-001
33
+ ```
34
+
35
+ ### verified_by
36
+ ```yaml
37
+ relationships:
38
+ - type: verified_by
39
+ from: REQ-001
40
+ to: TEST-001
41
+ ```
42
+
43
+ ### validates
44
+ ```yaml
45
+ relationships:
46
+ - type: validates
47
+ from: TEST-001
48
+ to: SCEN-001
49
+ ```
50
+
51
+ ### executable_for
52
+ ```yaml
53
+ relationships:
54
+ - type: executable_for
55
+ from: SYM-test-login
56
+ to: TEST-001
57
+ ```
58
+
59
+ ### constrains
60
+ ```yaml
61
+ relationships:
62
+ - type: constrains
63
+ from: REQ-019
64
+ to: FACT-USER-ROLE
65
+ ```
66
+
67
+ ### requires_property
68
+ ```yaml
69
+ relationships:
70
+ - type: requires_property
71
+ from: REQ-019
72
+ to: FACT-LIMIT-3
73
+ ```
74
+
75
+ ### supersedes
76
+ ```yaml
77
+ relationships:
78
+ - type: supersedes
79
+ from: REQ-001
80
+ to: REQ-001-v2
81
+ ```
82
+
83
+ ### covered_by
84
+ ```yaml
85
+ relationships:
86
+ - type: covered_by
87
+ from: SYM-handler
88
+ to: TEST-005
89
+ ```
@@ -0,0 +1,35 @@
1
+ # Workflows
2
+
3
+ ## Discovery to Validation Sequence
4
+
5
+ The canonical workflow for any KB operation follows this pattern:
6
+
7
+ 1. **Discover**: `kb_search` with focused probes
8
+ 2. **Confirm**: `kb_query` for exact IDs and state
9
+ 3. **Inspect**: `kb_status` when freshness matters
10
+ 4. **Create endpoints**: `kb_upsert` for new entities (sequential)
11
+ 5. **Link**: `kb_upsert` with relationship rows (sequential)
12
+ 6. **Validate**: `kb_check` with targeted rules during work, full check at completion
13
+
14
+ ## Creating a New Feature
15
+ ```
16
+ 1. kb_search to discover existing requirements and related knowledge
17
+ 2. kb_query to confirm exact IDs and source-linked context
18
+ 3. kb_upsert for new or updated requirements (include relationship rows)
19
+ 4. kb_check with targeted rules
20
+ ```
21
+
22
+ ## Fixing a Traceability Gap
23
+ ```
24
+ 1. kb_query --sourceFile <code-file> to find linked entities
25
+ 2. kb_find_gaps --type req --missing-rel specified_by to find orphan requirements
26
+ 3. kb_upsert to add missing relationship rows (sequential)
27
+ 4. kb_check with rules: ["no-dangling-refs", "symbol-traceability"]
28
+ ```
29
+
30
+ ## Before Risky Work
31
+ ```
32
+ 1. /brief-kibi or kb_briefing_generate for citation-backed briefing
33
+ 2. Inspect briefingState; proceed only if ready
34
+ 3. Use constraints, regressionRisks, and cited entities from the briefing
35
+ ```
@@ -0,0 +1,359 @@
1
+ import {
2
+ existsSync,
3
+ readFileSync,
4
+ readdirSync,
5
+ realpathSync,
6
+ statSync,
7
+ } from "node:fs";
8
+ import {
9
+ dirname,
10
+ isAbsolute,
11
+ join,
12
+ normalize,
13
+ relative,
14
+ resolve,
15
+ } from "node:path";
16
+ import { fileURLToPath } from "node:url";
17
+ import matter from "gray-matter";
18
+
19
+ const SKILL_MARKDOWN_MAX_BYTES = 256 * 1024;
20
+ const RESOURCE_MAX_BYTES = 128 * 1024;
21
+ const SKILL_FILE_NAME = "SKILL.md";
22
+
23
+ const moduleDir = dirname(fileURLToPath(import.meta.url));
24
+ let bundledSkillsDir = resolve(moduleDir, "skills");
25
+ const defaultBundledSkillsDir = bundledSkillsDir;
26
+
27
+ export function setBundledSkillsDir(dir: string): void {
28
+ bundledSkillsDir = dir;
29
+ }
30
+
31
+ export function resetBundledSkillsDir(): void {
32
+ bundledSkillsDir = defaultBundledSkillsDir;
33
+ }
34
+
35
+ export interface SkillManifest {
36
+ id: string;
37
+ name: string;
38
+ description: string;
39
+ version: string;
40
+ kibiCompatibility: string;
41
+ tags?: string[];
42
+ resources?: string[];
43
+ }
44
+
45
+ export interface SkillBundle {
46
+ manifest: SkillManifest;
47
+ body: string;
48
+ rootDir: string;
49
+ }
50
+
51
+ export class SkillNotFoundError extends Error {
52
+ constructor(id: string) {
53
+ super(`Skill not found: ${id}`);
54
+ this.name = "SkillNotFoundError";
55
+ }
56
+ }
57
+
58
+ export class SkillResourceNotFoundError extends Error {
59
+ constructor(id: string, resourcePath: string) {
60
+ super(`Skill resource not found: ${id}/${resourcePath}`);
61
+ this.name = "SkillResourceNotFoundError";
62
+ }
63
+ }
64
+
65
+ export class SkillResourceOutOfBoundsError extends Error {
66
+ constructor(id: string, resourcePath: string) {
67
+ super(`Skill resource escapes bundle root: ${id}/${resourcePath}`);
68
+ this.name = "SkillResourceOutOfBoundsError";
69
+ }
70
+ }
71
+
72
+ export class SkillValidationError extends Error {
73
+ readonly field: string;
74
+
75
+ constructor(field: string, message: string) {
76
+ super(message);
77
+ this.name = "SkillValidationError";
78
+ this.field = field;
79
+ }
80
+ }
81
+
82
+ export class SkillOversizeError extends Error {
83
+ readonly maxBytes: number;
84
+ readonly actualBytes: number;
85
+
86
+ constructor(pathLike: string, maxBytes: number, actualBytes: number) {
87
+ super(`Skill file exceeds ${maxBytes} bytes: ${pathLike} (${actualBytes} bytes)`);
88
+ this.name = "SkillOversizeError";
89
+ this.maxBytes = maxBytes;
90
+ this.actualBytes = actualBytes;
91
+ }
92
+ }
93
+
94
+ export function listBundledSkills(): SkillManifest[] { // implements REQ-001
95
+ if (!existsSync(bundledSkillsDir)) {
96
+ return [];
97
+ }
98
+
99
+ return readdirSync(bundledSkillsDir, { withFileTypes: true })
100
+ .filter((entry) => entry.isDirectory())
101
+ .map((entry) => join(bundledSkillsDir, entry.name))
102
+ .filter((rootDir) => existsSync(join(rootDir, SKILL_FILE_NAME)))
103
+ .map((rootDir) => parseSkillBundle(rootDir).manifest)
104
+ .sort((left, right) => left.id.localeCompare(right.id));
105
+ }
106
+
107
+ export function loadBundledSkill(id: string): SkillBundle { // implements REQ-001
108
+ const rootDir = findBundledSkillRoot(id);
109
+ if (!rootDir) {
110
+ throw new SkillNotFoundError(id);
111
+ }
112
+
113
+ return parseSkillBundle(rootDir);
114
+ }
115
+
116
+ export function readBundledSkillResource(
117
+ id: string,
118
+ resourcePath: string,
119
+ ): string { // implements REQ-001
120
+ const bundle = loadBundledSkill(id);
121
+ const declaredResource = normalizeResourcePath(resourcePath);
122
+
123
+ if (!declaredResource || isPathOutOfBounds(resourcePath)) {
124
+ throw new SkillResourceOutOfBoundsError(id, resourcePath);
125
+ }
126
+
127
+ if (!isDeclaredResource(bundle.manifest, declaredResource)) {
128
+ throw new SkillResourceNotFoundError(id, resourcePath);
129
+ }
130
+
131
+ const candidatePath = resolve(bundle.rootDir, declaredResource);
132
+ let realResourcePath: string;
133
+ let realRootDir: string;
134
+ try {
135
+ realResourcePath = realpathSync(candidatePath);
136
+ realRootDir = realpathSync(bundle.rootDir);
137
+ } catch {
138
+ throw new SkillResourceNotFoundError(id, resourcePath);
139
+ }
140
+
141
+ if (!isWithinRoot(realRootDir, realResourcePath)) {
142
+ throw new SkillResourceOutOfBoundsError(id, resourcePath);
143
+ }
144
+
145
+ assertMaxBytes(candidatePath, RESOURCE_MAX_BYTES);
146
+ return readFileSync(candidatePath, "utf8");
147
+ }
148
+
149
+ export function validateSkillBundle(
150
+ pathLike: string,
151
+ ): { valid: boolean; errors: SkillValidationError[] } { // implements REQ-001
152
+ const skillFilePath = resolveSkillFilePath(pathLike);
153
+ const errors: SkillValidationError[] = [];
154
+
155
+ if (!existsSync(skillFilePath)) {
156
+ errors.push(new SkillValidationError("SKILL.md", `Missing ${SKILL_FILE_NAME}`));
157
+ return { valid: false, errors };
158
+ }
159
+
160
+ const parsed = matter(readFileSync(skillFilePath, "utf8"));
161
+ errors.push(...validateManifestData(parsed.data));
162
+
163
+ if (errors.length === 0) {
164
+ validateBundleContents(skillFilePath, coerceManifest(parsed.data), errors);
165
+ }
166
+
167
+ return { valid: errors.length === 0, errors };
168
+ }
169
+
170
+ function validateBundleContents(
171
+ skillFilePath: string,
172
+ manifest: SkillManifest,
173
+ errors: SkillValidationError[],
174
+ ): void {
175
+ try {
176
+ assertMaxBytes(skillFilePath, SKILL_MARKDOWN_MAX_BYTES);
177
+ } catch (error) {
178
+ errors.push(new SkillValidationError("SKILL.md", error instanceof Error ? error.message : String(error)));
179
+ }
180
+
181
+ const rootDir = dirname(skillFilePath);
182
+ let realRootDir: string;
183
+ try {
184
+ realRootDir = realpathSync(rootDir);
185
+ } catch (error) {
186
+ errors.push(new SkillValidationError("SKILL.md", error instanceof Error ? error.message : String(error)));
187
+ return;
188
+ }
189
+
190
+ for (const resource of manifest.resources ?? []) {
191
+ const resourcePath = resolve(rootDir, resource);
192
+ try {
193
+ if (!existsSync(resourcePath)) {
194
+ errors.push(new SkillValidationError("resources", `Missing skill resource: ${resource}`));
195
+ continue;
196
+ }
197
+
198
+ const realResourcePath = realpathSync(resourcePath);
199
+ if (!isWithinRoot(realRootDir, realResourcePath)) {
200
+ errors.push(new SkillValidationError("resources", `Skill resource escapes bundle root: ${resource}`));
201
+ continue;
202
+ }
203
+
204
+ assertMaxBytes(resourcePath, RESOURCE_MAX_BYTES);
205
+ } catch (error) {
206
+ errors.push(new SkillValidationError("resources", error instanceof Error ? error.message : String(error)));
207
+ }
208
+ }
209
+ }
210
+
211
+ function findBundledSkillRoot(id: string): string | undefined {
212
+ if (!existsSync(bundledSkillsDir)) {
213
+ return undefined;
214
+ }
215
+
216
+ for (const entry of readdirSync(bundledSkillsDir, { withFileTypes: true })) {
217
+ if (!entry.isDirectory()) {
218
+ continue;
219
+ }
220
+
221
+ const rootDir = join(bundledSkillsDir, entry.name);
222
+ const skillFilePath = join(rootDir, SKILL_FILE_NAME);
223
+ if (!existsSync(skillFilePath)) {
224
+ continue;
225
+ }
226
+
227
+ const manifest = parseSkillBundle(rootDir).manifest;
228
+ if (manifest.id === id) {
229
+ return rootDir;
230
+ }
231
+ }
232
+
233
+ return undefined;
234
+ }
235
+
236
+ function parseSkillBundle(rootDir: string): SkillBundle {
237
+ const skillFilePath = join(rootDir, SKILL_FILE_NAME);
238
+ assertMaxBytes(skillFilePath, SKILL_MARKDOWN_MAX_BYTES);
239
+
240
+ const parsed = matter(readFileSync(skillFilePath, "utf8"));
241
+ const errors = validateManifestData(parsed.data);
242
+ if (errors.length > 0) {
243
+ throw errors[0] as SkillValidationError;
244
+ }
245
+
246
+ return {
247
+ manifest: coerceManifest(parsed.data),
248
+ body: parsed.content,
249
+ rootDir: resolve(rootDir),
250
+ };
251
+ }
252
+
253
+ function validateManifestData(data: Record<string, unknown>): SkillValidationError[] {
254
+ const errors: SkillValidationError[] = [];
255
+ const requiredFields = [
256
+ "id",
257
+ "name",
258
+ "description",
259
+ "version",
260
+ "kibiCompatibility",
261
+ ] as const;
262
+
263
+ for (const field of requiredFields) {
264
+ if (typeof data[field] !== "string" || data[field].trim() === "") {
265
+ errors.push(new SkillValidationError(field, `Missing required skill field: ${field}`));
266
+ }
267
+ }
268
+
269
+ if (typeof data.version === "string" && !/^\d+\.\d+\.\d+(?:[-+][0-9A-Za-z.-]+)?$/.test(data.version)) {
270
+ errors.push(new SkillValidationError("version", `Invalid skill version: ${data.version}`));
271
+ }
272
+
273
+ if (data.tags !== undefined && !isStringArray(data.tags)) {
274
+ errors.push(new SkillValidationError("tags", "Skill tags must be strings"));
275
+ }
276
+
277
+ if (data.resources !== undefined) {
278
+ if (!isStringArray(data.resources)) {
279
+ errors.push(new SkillValidationError("resources", "Skill resources must be strings"));
280
+ } else {
281
+ for (const resource of data.resources) {
282
+ const normalized = normalizeResourcePath(resource);
283
+ if (!normalized || isPathOutOfBounds(resource)) {
284
+ errors.push(
285
+ new SkillValidationError("resources", `Invalid skill resource: ${resource}`),
286
+ );
287
+ }
288
+ }
289
+ }
290
+ }
291
+
292
+ return errors;
293
+ }
294
+
295
+ function coerceManifest(data: Record<string, unknown>): SkillManifest {
296
+ const manifest: SkillManifest = {
297
+ id: String(data.id),
298
+ name: String(data.name),
299
+ description: String(data.description),
300
+ version: String(data.version),
301
+ kibiCompatibility: String(data.kibiCompatibility),
302
+ };
303
+
304
+ if (isStringArray(data.tags)) {
305
+ manifest.tags = data.tags;
306
+ }
307
+
308
+ if (isStringArray(data.resources)) {
309
+ manifest.resources = data.resources.map((resource) => normalizeResourcePath(resource));
310
+ }
311
+
312
+ return manifest;
313
+ }
314
+
315
+ function isStringArray(value: unknown): value is string[] {
316
+ return Array.isArray(value) && value.every((item) => typeof item === "string");
317
+ }
318
+
319
+ function normalizeResourcePath(pathLike: string): string {
320
+ return normalize(pathLike).replaceAll("\\", "/");
321
+ }
322
+
323
+ function isPathOutOfBounds(pathLike: string): boolean {
324
+ const normalized = normalizeResourcePath(pathLike);
325
+ return (
326
+ isAbsolute(pathLike) ||
327
+ normalized === ".." ||
328
+ normalized.startsWith("../") ||
329
+ normalized.includes("/../")
330
+ );
331
+ }
332
+
333
+ function isDeclaredResource(manifest: SkillManifest, resourcePath: string): boolean {
334
+ return (manifest.resources ?? []).some(
335
+ (declared) => normalizeResourcePath(declared) === resourcePath,
336
+ );
337
+ }
338
+
339
+ function isWithinRoot(rootDir: string, candidatePath: string): boolean {
340
+ const relativePath = relative(rootDir, candidatePath);
341
+ return relativePath === "" || (!relativePath.startsWith("..") && !isAbsolute(relativePath));
342
+ }
343
+
344
+ function assertMaxBytes(pathLike: string, maxBytes: number): void {
345
+ const size = statSync(pathLike).size;
346
+ if (size > maxBytes) {
347
+ throw new SkillOversizeError(pathLike, maxBytes, size);
348
+ }
349
+ }
350
+
351
+ function resolveSkillFilePath(pathLike: string): string {
352
+ const resolved = resolve(pathLike);
353
+ if (existsSync(resolved) && statSync(resolved).isDirectory()) {
354
+ return join(resolved, SKILL_FILE_NAME);
355
+ }
356
+
357
+ return resolved.endsWith(SKILL_FILE_NAME) ? resolved : join(resolved, SKILL_FILE_NAME);
358
+ }
359
+