@jmtrin/kevin-core 1.4.0 → 2.0.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 +711 -667
- package/dist/ArtifactWriter.js +1 -1
- package/dist/InjectionLedger.js +112 -110
- package/dist/Materializer.d.ts +5 -0
- package/dist/Materializer.js +11 -0
- package/dist/MemoryService.js +93 -91
- package/dist/RepoIdentity.js +1 -1
- package/dist/Retrospective.js +8 -0
- package/dist/Store.d.ts +1 -0
- package/dist/Store.js +14 -2
- package/dist/contract.d.ts +58 -1
- package/dist/contract.js +215 -3
- package/dist/import-host.d.ts +41 -0
- package/dist/import-host.js +286 -0
- package/dist/index.d.ts +9 -2
- package/dist/index.js +16 -1
- package/dist/kevin_audit.d.ts +16 -0
- package/dist/kevin_audit.js +37 -0
- package/dist/metrics.d.ts +1 -1
- package/dist/metrics.js +22 -0
- package/dist/mif.d.ts +32 -0
- package/dist/mif.js +112 -0
- package/dist/migrations/014_v2_commonwealth.sql +50 -0
- package/dist/okf-shards.d.ts +10 -0
- package/dist/okf-shards.js +103 -0
- package/dist/okf.d.ts +5 -1
- package/dist/okf.js +11 -3
- package/dist/skills-emit.d.ts +38 -0
- package/dist/skills-emit.js +421 -0
- package/dist/skills-validate.d.ts +7 -0
- package/dist/skills-validate.js +236 -0
- package/dist/sources/ClaudeMemorySource.d.ts +10 -0
- package/dist/sources/ClaudeMemorySource.js +36 -0
- package/dist/sources/CodexMemoriesSource.d.ts +10 -0
- package/dist/sources/CodexMemoriesSource.js +37 -0
- package/dist/sources/IdleSync.d.ts +10 -0
- package/dist/sources/IdleSync.js +49 -0
- package/dist/sources/MemorySource.d.ts +19 -0
- package/dist/sources/MemorySource.js +1 -0
- package/dist/sources/OpencodeNativeSource.d.ts +10 -0
- package/dist/sources/OpencodeNativeSource.js +33 -0
- package/dist/sources/OpencodePluginSource.d.ts +9 -0
- package/dist/sources/OpencodePluginSource.js +13 -0
- package/dist/sqlite-adapter.js +1 -1
- package/package.json +24 -26
|
@@ -0,0 +1,421 @@
|
|
|
1
|
+
// K15-003 — Skill bundle emitter (plan §4.1)
|
|
2
|
+
// Writes <projectRoot>/<canonicalDir>/kevin-knowledge/SKILL.md + references/<topic>.md
|
|
3
|
+
// Every emitted byte passes through escape helpers (C-09, K15-004).
|
|
4
|
+
import { createHash, randomBytes } from "node:crypto";
|
|
5
|
+
import { existsSync, mkdirSync, readFileSync, renameSync, unlinkSync, writeFileSync } from "node:fs";
|
|
6
|
+
import { dirname, isAbsolute, join, resolve } from "node:path";
|
|
7
|
+
import { escapeForFence, escapeForMarkerBlock } from "./escape.js";
|
|
8
|
+
import { firstSentence } from "./Curator.js";
|
|
9
|
+
import { resolveEnv } from "./env.js";
|
|
10
|
+
import { KEVIN_VERSION } from "./index.js";
|
|
11
|
+
function sha256Hex(s) {
|
|
12
|
+
return createHash("sha256").update(s, "utf8").digest("hex");
|
|
13
|
+
}
|
|
14
|
+
function escaped(text) {
|
|
15
|
+
// C-09 funnel: every byte through escape helpers. Apply both fence + marker (orthogonal).
|
|
16
|
+
return escapeForMarkerBlock(escapeForFence(text));
|
|
17
|
+
}
|
|
18
|
+
function atomicWrite(target, content) {
|
|
19
|
+
mkdirSync(dirname(target), { recursive: true });
|
|
20
|
+
const tmp = `${target}.tmp.${process.pid}.${randomBytes(4).toString("hex")}`;
|
|
21
|
+
writeFileSync(tmp, content, "utf8");
|
|
22
|
+
try {
|
|
23
|
+
renameSync(tmp, target);
|
|
24
|
+
}
|
|
25
|
+
catch (e) {
|
|
26
|
+
try {
|
|
27
|
+
unlinkSync(tmp);
|
|
28
|
+
}
|
|
29
|
+
catch { }
|
|
30
|
+
throw e;
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
function buildSkillMd(repoId, bundles) {
|
|
34
|
+
const header = [
|
|
35
|
+
"---",
|
|
36
|
+
"name: kevin-knowledge",
|
|
37
|
+
"description: >-",
|
|
38
|
+
" Project knowledge curated by opencode-kevin: conventions, decisions and verified",
|
|
39
|
+
" fixes for this repository. Load when working in this repo and unsure about local",
|
|
40
|
+
" rules, past failures or team decisions.",
|
|
41
|
+
"metadata:",
|
|
42
|
+
` generator: opencode-kevin/${KEVIN_VERSION}`,
|
|
43
|
+
` repo_id: ${escaped(repoId)}`,
|
|
44
|
+
"---",
|
|
45
|
+
"",
|
|
46
|
+
].join("\n");
|
|
47
|
+
let body;
|
|
48
|
+
if (bundles.length === 0) {
|
|
49
|
+
body = "No knowledge yet — Kevin has not curated any memories for this repository.\n\nSee `references/` when topics appear.\n";
|
|
50
|
+
}
|
|
51
|
+
else {
|
|
52
|
+
// index ≤80 lines: one per topic + header
|
|
53
|
+
const indexLines = [];
|
|
54
|
+
indexLines.push("# Kevin Knowledge");
|
|
55
|
+
indexLines.push("");
|
|
56
|
+
indexLines.push("Topics curated for this repository:");
|
|
57
|
+
indexLines.push("");
|
|
58
|
+
for (const b of bundles) {
|
|
59
|
+
const rawSummary = b.summary ?? firstSentence(b.content.split("\n").find((l) => l.trim().startsWith("-"))?.replace(/^-+\s*/, "") ?? b.content.slice(0, 120));
|
|
60
|
+
const summary = escaped(rawSummary.trim().slice(0, 140));
|
|
61
|
+
// relative link from SKILL.md to references/<topic>.md
|
|
62
|
+
indexLines.push(`- **${escaped(b.topic)}**: ${summary} — [references/${escaped(b.topic)}.md](references/${escaped(b.topic)}.md)`);
|
|
63
|
+
}
|
|
64
|
+
const MAX_INDEX = 78;
|
|
65
|
+
if (indexLines.length > MAX_INDEX) {
|
|
66
|
+
// cap at 78: truncate excess topics, reserve footer lines
|
|
67
|
+
indexLines.splice(MAX_INDEX);
|
|
68
|
+
}
|
|
69
|
+
indexLines.push("");
|
|
70
|
+
indexLines.push("See `references/` for detailed topic files.");
|
|
71
|
+
indexLines.push("");
|
|
72
|
+
body = indexLines.join("\n");
|
|
73
|
+
}
|
|
74
|
+
const full = header + body;
|
|
75
|
+
// enforce <150 lines total; if exceeds, truncate body tail via paragraph truncation
|
|
76
|
+
const lines = full.split("\n");
|
|
77
|
+
if (lines.length >= 150) {
|
|
78
|
+
const headerLines = header.split("\n").length;
|
|
79
|
+
const allowedBody = 149 - headerLines;
|
|
80
|
+
const bodyLines = body.split("\n");
|
|
81
|
+
const truncatedBody = bodyLines.slice(0, Math.max(0, allowedBody)).join("\n");
|
|
82
|
+
const truncated = header + truncatedBody;
|
|
83
|
+
return truncated.endsWith("\n") ? truncated : truncated + "\n";
|
|
84
|
+
}
|
|
85
|
+
return full;
|
|
86
|
+
}
|
|
87
|
+
function buildToWrite(input) {
|
|
88
|
+
const canonicalRaw = (input.canonicalDir && input.canonicalDir.trim() !== "") ? input.canonicalDir.trim() : ".agents/skills";
|
|
89
|
+
// H-01: validate canonicalDir is relative and does not escape projectRoot
|
|
90
|
+
if (isAbsolute(canonicalRaw) || canonicalRaw.includes("..")) {
|
|
91
|
+
throw new Error(`unsafe canonicalDir: ${canonicalRaw}`);
|
|
92
|
+
}
|
|
93
|
+
const base = resolve(join(input.projectRoot, canonicalRaw, "kevin-knowledge"));
|
|
94
|
+
const projectRootResolved = resolve(input.projectRoot);
|
|
95
|
+
if (base !== projectRootResolved && !base.startsWith(projectRootResolved + "/") && !base.startsWith(projectRootResolved + "\\")) {
|
|
96
|
+
throw new Error(`canonicalDir escapes projectRoot: ${canonicalRaw}`);
|
|
97
|
+
}
|
|
98
|
+
const skillPath = join(base, "SKILL.md");
|
|
99
|
+
const refsDir = join(base, "references");
|
|
100
|
+
const bundles = [...input.topics].sort((a, b) => a.topic.localeCompare(b.topic));
|
|
101
|
+
const skillContent = buildSkillMd(input.repoId, bundles);
|
|
102
|
+
const referenceContents = new Map();
|
|
103
|
+
for (const b of bundles) {
|
|
104
|
+
let body = b.content;
|
|
105
|
+
if (body.length > 4000)
|
|
106
|
+
body = body.slice(0, 4000);
|
|
107
|
+
const esc = escaped(body);
|
|
108
|
+
referenceContents.set(b.topic, esc + (esc.endsWith("\n") ? "" : "\n"));
|
|
109
|
+
}
|
|
110
|
+
const toWrite = [];
|
|
111
|
+
toWrite.push({ path: skillPath, content: skillContent });
|
|
112
|
+
for (const [topic, content] of referenceContents) {
|
|
113
|
+
// C-01 sanitize topic by replacing [/\\:]/g with "-", replacing ".." and validating
|
|
114
|
+
let safe = topic.replace(/[/\\:]/g, "-").replace(/\.\./g, "-");
|
|
115
|
+
if (safe.includes("/") || safe.includes("\\") || safe.includes("..")) {
|
|
116
|
+
throw new Error(`unsafe topic: ${topic}`);
|
|
117
|
+
}
|
|
118
|
+
if (safe.trim() === "") {
|
|
119
|
+
throw new Error(`unsafe topic: ${topic}`);
|
|
120
|
+
}
|
|
121
|
+
toWrite.push({ path: join(refsDir, `${safe}.md`), content });
|
|
122
|
+
}
|
|
123
|
+
return { base, skillPath, refsDir, toWrite, referenceContents, skillContent, bundles };
|
|
124
|
+
}
|
|
125
|
+
export function emitSkillBundle(input) {
|
|
126
|
+
const { base, toWrite } = buildToWrite(input);
|
|
127
|
+
const manifestPath = input.manifestPath ?? join(resolveEnv(input.env).dataRoot, "skills-manifest.json");
|
|
128
|
+
let manifest = {};
|
|
129
|
+
let manifestExists = false;
|
|
130
|
+
let manifestCorrupt = false;
|
|
131
|
+
if (existsSync(manifestPath)) {
|
|
132
|
+
try {
|
|
133
|
+
manifest = JSON.parse(readFileSync(manifestPath, "utf8"));
|
|
134
|
+
manifestExists = true;
|
|
135
|
+
}
|
|
136
|
+
catch {
|
|
137
|
+
manifestCorrupt = true;
|
|
138
|
+
try {
|
|
139
|
+
input.metrics?.incr("skills_manifest_corrupt_total", 1);
|
|
140
|
+
}
|
|
141
|
+
catch { }
|
|
142
|
+
try {
|
|
143
|
+
const corrupt = readFileSync(manifestPath, "utf8");
|
|
144
|
+
writeFileSync(`${manifestPath}.corrupt.${Date.now()}`, corrupt, "utf8");
|
|
145
|
+
}
|
|
146
|
+
catch { }
|
|
147
|
+
manifest = {};
|
|
148
|
+
manifestExists = false;
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
const written = [];
|
|
152
|
+
const skipped_external = [];
|
|
153
|
+
const noop = [];
|
|
154
|
+
const external_edits = [];
|
|
155
|
+
const removed_orphan_manifest = [];
|
|
156
|
+
for (const w of toWrite) {
|
|
157
|
+
const freshHash = sha256Hex(w.content);
|
|
158
|
+
const manifestHash = manifest[w.path];
|
|
159
|
+
const diskExists = existsSync(w.path);
|
|
160
|
+
let diskHash = null;
|
|
161
|
+
if (diskExists) {
|
|
162
|
+
try {
|
|
163
|
+
diskHash = sha256Hex(readFileSync(w.path, "utf8"));
|
|
164
|
+
}
|
|
165
|
+
catch {
|
|
166
|
+
diskHash = null;
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
if (!diskExists) {
|
|
170
|
+
atomicWrite(w.path, w.content);
|
|
171
|
+
written.push(w.path);
|
|
172
|
+
manifest[w.path] = freshHash;
|
|
173
|
+
continue;
|
|
174
|
+
}
|
|
175
|
+
if (manifestCorrupt) {
|
|
176
|
+
if (diskHash !== null && freshHash === diskHash) {
|
|
177
|
+
noop.push(w.path);
|
|
178
|
+
manifest[w.path] = freshHash;
|
|
179
|
+
}
|
|
180
|
+
else {
|
|
181
|
+
atomicWrite(w.path, w.content);
|
|
182
|
+
written.push(w.path);
|
|
183
|
+
manifest[w.path] = freshHash;
|
|
184
|
+
}
|
|
185
|
+
continue;
|
|
186
|
+
}
|
|
187
|
+
if (manifestExists) {
|
|
188
|
+
if (manifestHash === undefined) {
|
|
189
|
+
skipped_external.push(w.path);
|
|
190
|
+
external_edits.push(w.path);
|
|
191
|
+
continue;
|
|
192
|
+
}
|
|
193
|
+
if (diskHash !== manifestHash) {
|
|
194
|
+
skipped_external.push(w.path);
|
|
195
|
+
external_edits.push(w.path);
|
|
196
|
+
continue;
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
if (diskHash !== null && freshHash === diskHash) {
|
|
200
|
+
noop.push(w.path);
|
|
201
|
+
manifest[w.path] = freshHash;
|
|
202
|
+
}
|
|
203
|
+
else {
|
|
204
|
+
atomicWrite(w.path, w.content);
|
|
205
|
+
written.push(w.path);
|
|
206
|
+
manifest[w.path] = freshHash;
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
// orphan manifest cleanup: entries under base not in current toWrite
|
|
210
|
+
for (const key of Object.keys({ ...manifest })) {
|
|
211
|
+
if (key.startsWith(base) && !toWrite.some((w) => w.path === key)) {
|
|
212
|
+
removed_orphan_manifest.push(key);
|
|
213
|
+
delete manifest[key];
|
|
214
|
+
try {
|
|
215
|
+
unlinkSync(key);
|
|
216
|
+
}
|
|
217
|
+
catch { }
|
|
218
|
+
for (const mirror of input.mirrors) {
|
|
219
|
+
const mirrorBase = mirror === "claude"
|
|
220
|
+
? join(input.projectRoot, ".claude", "skills", "kevin-knowledge")
|
|
221
|
+
: join(input.projectRoot, ".cursor", "skills", "kevin-knowledge");
|
|
222
|
+
const rel = key.slice(base.length);
|
|
223
|
+
const mirrorPath = join(mirrorBase, rel);
|
|
224
|
+
try {
|
|
225
|
+
unlinkSync(mirrorPath);
|
|
226
|
+
}
|
|
227
|
+
catch { }
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
// mirror handling — follow canonical state
|
|
232
|
+
for (const mirror of input.mirrors) {
|
|
233
|
+
const mirrorBase = mirror === "claude"
|
|
234
|
+
? join(input.projectRoot, ".claude", "skills", "kevin-knowledge")
|
|
235
|
+
: join(input.projectRoot, ".cursor", "skills", "kevin-knowledge");
|
|
236
|
+
for (const w of toWrite) {
|
|
237
|
+
const rel = w.path.slice(base.length);
|
|
238
|
+
const mirrorPath = join(mirrorBase, rel);
|
|
239
|
+
const wasWritten = written.includes(w.path);
|
|
240
|
+
const wasNoop = noop.includes(w.path);
|
|
241
|
+
const wasSkipped = skipped_external.includes(w.path);
|
|
242
|
+
if (wasSkipped)
|
|
243
|
+
continue;
|
|
244
|
+
if (wasWritten) {
|
|
245
|
+
atomicWrite(mirrorPath, w.content);
|
|
246
|
+
}
|
|
247
|
+
else if (wasNoop) {
|
|
248
|
+
if (!existsSync(mirrorPath)) {
|
|
249
|
+
atomicWrite(mirrorPath, w.content);
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
try {
|
|
255
|
+
// manifest already built; write LAST
|
|
256
|
+
mkdirSync(dirname(manifestPath), { recursive: true });
|
|
257
|
+
atomicWrite(manifestPath, JSON.stringify(manifest, null, 2) + "\n");
|
|
258
|
+
}
|
|
259
|
+
catch { }
|
|
260
|
+
try {
|
|
261
|
+
input.metrics?.incr("skills_emitted_total", 1);
|
|
262
|
+
}
|
|
263
|
+
catch { }
|
|
264
|
+
return { written, skipped_external, noop, removed_orphan_manifest, external_edits };
|
|
265
|
+
}
|
|
266
|
+
export function refreshSkillBundle(input) {
|
|
267
|
+
const { base, refsDir, toWrite, referenceContents } = buildToWrite(input);
|
|
268
|
+
const manifestPath = input.manifestPath ?? join(resolveEnv(input.env).dataRoot, "skills-manifest.json");
|
|
269
|
+
let manifest = {};
|
|
270
|
+
let manifestExists = false;
|
|
271
|
+
let manifestCorrupt = false;
|
|
272
|
+
if (existsSync(manifestPath)) {
|
|
273
|
+
try {
|
|
274
|
+
manifest = JSON.parse(readFileSync(manifestPath, "utf8"));
|
|
275
|
+
manifestExists = true;
|
|
276
|
+
}
|
|
277
|
+
catch {
|
|
278
|
+
manifestCorrupt = true;
|
|
279
|
+
try {
|
|
280
|
+
input.metrics?.incr("skills_manifest_corrupt_total", 1);
|
|
281
|
+
}
|
|
282
|
+
catch { }
|
|
283
|
+
try {
|
|
284
|
+
const corrupt = readFileSync(manifestPath, "utf8");
|
|
285
|
+
writeFileSync(`${manifestPath}.corrupt.${Date.now()}`, corrupt, "utf8");
|
|
286
|
+
}
|
|
287
|
+
catch { }
|
|
288
|
+
manifest = {};
|
|
289
|
+
manifestExists = false;
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
const written = [];
|
|
293
|
+
const skipped_external = [];
|
|
294
|
+
const noop = [];
|
|
295
|
+
const external_edits = [];
|
|
296
|
+
const removed_orphan_manifest = [];
|
|
297
|
+
// three-state per managed path
|
|
298
|
+
for (const w of toWrite) {
|
|
299
|
+
const freshHash = sha256Hex(w.content);
|
|
300
|
+
const manifestHash = manifest[w.path];
|
|
301
|
+
const diskExists = existsSync(w.path);
|
|
302
|
+
let diskHash = null;
|
|
303
|
+
if (diskExists) {
|
|
304
|
+
try {
|
|
305
|
+
diskHash = sha256Hex(readFileSync(w.path, "utf8"));
|
|
306
|
+
}
|
|
307
|
+
catch {
|
|
308
|
+
diskHash = null;
|
|
309
|
+
}
|
|
310
|
+
}
|
|
311
|
+
if (!diskExists) {
|
|
312
|
+
// deleted-file reconciliation: manifest entry without disk file → rewrite (STALE)
|
|
313
|
+
atomicWrite(w.path, w.content);
|
|
314
|
+
written.push(w.path);
|
|
315
|
+
manifest[w.path] = freshHash;
|
|
316
|
+
continue;
|
|
317
|
+
}
|
|
318
|
+
// disk exists
|
|
319
|
+
if (manifestHash === undefined) {
|
|
320
|
+
if (manifestExists && !manifestCorrupt) {
|
|
321
|
+
// missing manifest + existing file = EXTERNAL domain → skip
|
|
322
|
+
skipped_external.push(w.path);
|
|
323
|
+
external_edits.push(w.path);
|
|
324
|
+
continue;
|
|
325
|
+
}
|
|
326
|
+
// if manifest corrupt or missing (bootstrap), treat as stale: allow write below
|
|
327
|
+
if (!manifestExists && !manifestCorrupt) {
|
|
328
|
+
// no manifest yet - bootstrap case: if we are in refresh with no manifest but file exists,
|
|
329
|
+
// original behavior was EXTERNAL. Keep external for refresh when not corrupt.
|
|
330
|
+
// But for corrupt we already handled; for bootstrap we should still consider external.
|
|
331
|
+
// To preserve original spec for refresh: when manifest missing, existing file is EXTERNAL
|
|
332
|
+
if (!manifestCorrupt) {
|
|
333
|
+
skipped_external.push(w.path);
|
|
334
|
+
external_edits.push(w.path);
|
|
335
|
+
continue;
|
|
336
|
+
}
|
|
337
|
+
}
|
|
338
|
+
}
|
|
339
|
+
if (manifestExists && diskHash !== manifestHash) {
|
|
340
|
+
// EXTERNAL_EDIT: disk ≠ manifest → skip
|
|
341
|
+
skipped_external.push(w.path);
|
|
342
|
+
external_edits.push(w.path);
|
|
343
|
+
continue;
|
|
344
|
+
}
|
|
345
|
+
// disk == manifest (or manifest corrupt/missing and we allowed)
|
|
346
|
+
if (diskHash !== null && freshHash === diskHash) {
|
|
347
|
+
noop.push(w.path);
|
|
348
|
+
manifest[w.path] = freshHash;
|
|
349
|
+
}
|
|
350
|
+
else {
|
|
351
|
+
// STALE: disk == manifest but inputs changed → rewrite
|
|
352
|
+
// Also for corrupt bootstrap, rewrite
|
|
353
|
+
atomicWrite(w.path, w.content);
|
|
354
|
+
written.push(w.path);
|
|
355
|
+
manifest[w.path] = freshHash;
|
|
356
|
+
}
|
|
357
|
+
}
|
|
358
|
+
// orphan manifest cleanup: entries under base not in current toWrite
|
|
359
|
+
for (const key of Object.keys({ ...manifest })) {
|
|
360
|
+
if (key.startsWith(base) && !toWrite.some((w) => w.path === key)) {
|
|
361
|
+
removed_orphan_manifest.push(key);
|
|
362
|
+
delete manifest[key];
|
|
363
|
+
try {
|
|
364
|
+
unlinkSync(key);
|
|
365
|
+
}
|
|
366
|
+
catch { }
|
|
367
|
+
for (const mirror of input.mirrors) {
|
|
368
|
+
const mirrorBase = mirror === "claude"
|
|
369
|
+
? join(input.projectRoot, ".claude", "skills", "kevin-knowledge")
|
|
370
|
+
: join(input.projectRoot, ".cursor", "skills", "kevin-knowledge");
|
|
371
|
+
const rel = key.slice(base.length);
|
|
372
|
+
const mirrorPath = join(mirrorBase, rel);
|
|
373
|
+
try {
|
|
374
|
+
unlinkSync(mirrorPath);
|
|
375
|
+
}
|
|
376
|
+
catch { }
|
|
377
|
+
}
|
|
378
|
+
}
|
|
379
|
+
}
|
|
380
|
+
// mirror handling — follow canonical state
|
|
381
|
+
for (const mirror of input.mirrors) {
|
|
382
|
+
const mirrorBase = mirror === "claude"
|
|
383
|
+
? join(input.projectRoot, ".claude", "skills", "kevin-knowledge")
|
|
384
|
+
: join(input.projectRoot, ".cursor", "skills", "kevin-knowledge");
|
|
385
|
+
for (const w of toWrite) {
|
|
386
|
+
const rel = w.path.slice(base.length);
|
|
387
|
+
const mirrorPath = join(mirrorBase, rel);
|
|
388
|
+
const wasWritten = written.includes(w.path);
|
|
389
|
+
const wasNoop = noop.includes(w.path);
|
|
390
|
+
const wasSkipped = skipped_external.includes(w.path);
|
|
391
|
+
if (wasSkipped)
|
|
392
|
+
continue;
|
|
393
|
+
if (wasWritten) {
|
|
394
|
+
// canonical changed → mirror must match (discard external edits on mirror)
|
|
395
|
+
atomicWrite(mirrorPath, w.content);
|
|
396
|
+
}
|
|
397
|
+
else if (wasNoop) {
|
|
398
|
+
// canonical unchanged → don't touch mirror (preserve if externally edited, per spec)
|
|
399
|
+
// but if mirror missing, create it (stale projection)
|
|
400
|
+
if (!existsSync(mirrorPath)) {
|
|
401
|
+
atomicWrite(mirrorPath, w.content);
|
|
402
|
+
}
|
|
403
|
+
}
|
|
404
|
+
}
|
|
405
|
+
}
|
|
406
|
+
// manifest written LAST
|
|
407
|
+
try {
|
|
408
|
+
mkdirSync(dirname(manifestPath), { recursive: true });
|
|
409
|
+
atomicWrite(manifestPath, JSON.stringify(manifest, null, 2) + "\n");
|
|
410
|
+
}
|
|
411
|
+
catch { }
|
|
412
|
+
if (written.length > 0) {
|
|
413
|
+
try {
|
|
414
|
+
input.metrics?.incr("skills_emitted_total", 1);
|
|
415
|
+
}
|
|
416
|
+
catch { }
|
|
417
|
+
}
|
|
418
|
+
return { written, skipped_external, noop, removed_orphan_manifest, external_edits };
|
|
419
|
+
}
|
|
420
|
+
// Utility for tests: expose header builder and escaping
|
|
421
|
+
export const _internal = { buildSkillMd, sha256Hex, escaped };
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
export interface SkillValidateResult {
|
|
2
|
+
ok: boolean;
|
|
3
|
+
errors: string[];
|
|
4
|
+
warnings: string[];
|
|
5
|
+
}
|
|
6
|
+
export declare function validateSkill(content: string, dirname?: string): SkillValidateResult;
|
|
7
|
+
export declare function validateSkillFile(filePath: string, content: string): SkillValidateResult;
|
|
@@ -0,0 +1,236 @@
|
|
|
1
|
+
// K15-002 — spec-subset validator (plan §4.3, D15-07)
|
|
2
|
+
// Naive YAML subset: `key: value` + one-level map for metadata + folded scalars (>-, >, |)
|
|
3
|
+
// Returns {ok, errors[], warnings[]} — hard rules -> errors, soft -> warnings.
|
|
4
|
+
const NAME_RE = /^[a-z0-9]([a-z0-9-]*[a-z0-9])?$/;
|
|
5
|
+
const MAX_NAME_LEN = 64;
|
|
6
|
+
const MIN_DESC = 1;
|
|
7
|
+
const MAX_DESC = 1024;
|
|
8
|
+
const MAX_BODY_LINES = 500;
|
|
9
|
+
export function validateSkill(content, dirname) {
|
|
10
|
+
const errors = [];
|
|
11
|
+
const warnings = [];
|
|
12
|
+
const normalized = content.replace(/\r\n/g, "\n");
|
|
13
|
+
const lines = normalized.split("\n");
|
|
14
|
+
// frontmatter must start with ---
|
|
15
|
+
if (lines.length === 0 || lines[0].trim() !== "---") {
|
|
16
|
+
errors.push("frontmatter: missing opening '---'");
|
|
17
|
+
return { ok: false, errors, warnings };
|
|
18
|
+
}
|
|
19
|
+
// find closing ---
|
|
20
|
+
let closeIdx = -1;
|
|
21
|
+
for (let i = 1; i < lines.length; i++) {
|
|
22
|
+
if (lines[i].trim() === "---") {
|
|
23
|
+
closeIdx = i;
|
|
24
|
+
break;
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
if (closeIdx === -1) {
|
|
28
|
+
errors.push("frontmatter: missing closing '---'");
|
|
29
|
+
return { ok: false, errors, warnings };
|
|
30
|
+
}
|
|
31
|
+
const fmLines = lines.slice(1, closeIdx);
|
|
32
|
+
const bodyLines = lines.slice(closeIdx + 1);
|
|
33
|
+
const body = bodyLines.join("\n");
|
|
34
|
+
const bodyTrimmed = body.trim();
|
|
35
|
+
// parse frontmatter naive
|
|
36
|
+
const fm = {};
|
|
37
|
+
let currentParent = null;
|
|
38
|
+
let foldedKey = null;
|
|
39
|
+
let foldedBuffer = [];
|
|
40
|
+
// helper to flush folded
|
|
41
|
+
function flushFolded() {
|
|
42
|
+
if (foldedKey !== null) {
|
|
43
|
+
const joined = foldedBuffer.join(" ").trim();
|
|
44
|
+
if (currentParent === "metadata" && foldedKey) {
|
|
45
|
+
// inside metadata? folded not expected, but handle
|
|
46
|
+
const meta = fm["metadata"];
|
|
47
|
+
meta[foldedKey] = joined;
|
|
48
|
+
}
|
|
49
|
+
else if (foldedKey) {
|
|
50
|
+
fm[foldedKey] = joined;
|
|
51
|
+
}
|
|
52
|
+
foldedKey = null;
|
|
53
|
+
foldedBuffer = [];
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
for (let idx = 0; idx < fmLines.length; idx++) {
|
|
57
|
+
const raw = fmLines[idx];
|
|
58
|
+
// if we are in folded collection, indented lines are continuation
|
|
59
|
+
if (foldedKey !== null) {
|
|
60
|
+
if (/^\s+/.test(raw) && raw.trim() !== "") {
|
|
61
|
+
foldedBuffer.push(raw.trim());
|
|
62
|
+
continue;
|
|
63
|
+
}
|
|
64
|
+
else {
|
|
65
|
+
// end of folded block
|
|
66
|
+
flushFolded();
|
|
67
|
+
// fall through to parse current line normally
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
if (raw.trim() === "")
|
|
71
|
+
continue;
|
|
72
|
+
// indented means metadata child or continuation already handled
|
|
73
|
+
const indentMatch = raw.match(/^(\s+)(.*)$/);
|
|
74
|
+
if (indentMatch && indentMatch[1].length >= 2) {
|
|
75
|
+
const inner = indentMatch[2];
|
|
76
|
+
if (currentParent !== "metadata") {
|
|
77
|
+
errors.push(`frontmatter: unexpected indent at line ${idx + 2}`);
|
|
78
|
+
continue;
|
|
79
|
+
}
|
|
80
|
+
const m = inner.match(/^([A-Za-z0-9_-]+):\s*(.*)$/);
|
|
81
|
+
if (!m) {
|
|
82
|
+
errors.push(`frontmatter: invalid metadata line '${raw.trim()}'`);
|
|
83
|
+
continue;
|
|
84
|
+
}
|
|
85
|
+
const k = m[1];
|
|
86
|
+
const v = m[2].trim();
|
|
87
|
+
// strip surrounding quotes if present for storage but keep raw for type check
|
|
88
|
+
const stored = stripQuotes(v);
|
|
89
|
+
// detect folded for metadata? not needed
|
|
90
|
+
const meta = fm["metadata"];
|
|
91
|
+
// keep raw detection for non-string check: if v is unquoted number/boolean
|
|
92
|
+
// we still store but flag later; store raw trimmed
|
|
93
|
+
meta[k] = stored;
|
|
94
|
+
// also keep raw for validation via separate map
|
|
95
|
+
// we store raw in a hidden map
|
|
96
|
+
if (!fm.__metaRaw)
|
|
97
|
+
fm.__metaRaw = {};
|
|
98
|
+
fm.__metaRaw[k] = v;
|
|
99
|
+
continue;
|
|
100
|
+
}
|
|
101
|
+
// top-level key: value
|
|
102
|
+
const m = raw.match(/^([A-Za-z0-9_-]+):\s*(.*)$/);
|
|
103
|
+
if (!m) {
|
|
104
|
+
errors.push(`frontmatter: invalid line '${raw.trim()}'`);
|
|
105
|
+
continue;
|
|
106
|
+
}
|
|
107
|
+
const key = m[1];
|
|
108
|
+
const val = m[2].trim();
|
|
109
|
+
// handle folded scalar indicators
|
|
110
|
+
if (val === ">-" || val === ">" || val === "|" || val === "|-") {
|
|
111
|
+
foldedKey = key;
|
|
112
|
+
foldedBuffer = [];
|
|
113
|
+
// initialize parent tracking if key is metadata (but folded metadata not expected)
|
|
114
|
+
if (key === "metadata") {
|
|
115
|
+
// metadata with folded? treat as empty then parent
|
|
116
|
+
fm[key] = {};
|
|
117
|
+
currentParent = "metadata";
|
|
118
|
+
}
|
|
119
|
+
else {
|
|
120
|
+
currentParent = null;
|
|
121
|
+
fm[key] = ""; // placeholder, will be filled on flush
|
|
122
|
+
}
|
|
123
|
+
continue;
|
|
124
|
+
}
|
|
125
|
+
if (key === "metadata") {
|
|
126
|
+
// metadata: may be empty (map follows) or inline? We support only map form.
|
|
127
|
+
const normalizedVal = val.trim();
|
|
128
|
+
const compactVal = normalizedVal.replace(/\s/g, "");
|
|
129
|
+
if (normalizedVal === "" || normalizedVal === "{}" || compactVal === "{}") {
|
|
130
|
+
fm[key] = {};
|
|
131
|
+
currentParent = "metadata";
|
|
132
|
+
// init raw map
|
|
133
|
+
fm.__metaRaw = {};
|
|
134
|
+
}
|
|
135
|
+
else {
|
|
136
|
+
errors.push("frontmatter: metadata must be a map");
|
|
137
|
+
fm[key] = val;
|
|
138
|
+
currentParent = null;
|
|
139
|
+
}
|
|
140
|
+
continue;
|
|
141
|
+
}
|
|
142
|
+
// normal key
|
|
143
|
+
currentParent = null;
|
|
144
|
+
flushFolded();
|
|
145
|
+
fm[key] = stripQuotes(val);
|
|
146
|
+
// store raw for description length? raw stripped is fine
|
|
147
|
+
}
|
|
148
|
+
flushFolded();
|
|
149
|
+
// --- validation rules ---
|
|
150
|
+
// name
|
|
151
|
+
const name = fm["name"];
|
|
152
|
+
if (name === undefined || (typeof name === "string" && name.trim() === "")) {
|
|
153
|
+
errors.push("name: missing");
|
|
154
|
+
}
|
|
155
|
+
else if (typeof name !== "string") {
|
|
156
|
+
errors.push("name: must be a string");
|
|
157
|
+
}
|
|
158
|
+
else {
|
|
159
|
+
const n = name.trim();
|
|
160
|
+
if (n.length < 1 || n.length > MAX_NAME_LEN) {
|
|
161
|
+
errors.push(`name: length must be 1-${MAX_NAME_LEN} (got ${n.length})`);
|
|
162
|
+
}
|
|
163
|
+
if (!NAME_RE.test(n)) {
|
|
164
|
+
errors.push(`name: invalid format '${n}' (must match ^[a-z0-9]([a-z0-9-]*[a-z0-9])?$)`);
|
|
165
|
+
}
|
|
166
|
+
if (n.includes("--")) {
|
|
167
|
+
errors.push("name: must not contain '--'");
|
|
168
|
+
}
|
|
169
|
+
if (dirname !== undefined && n !== dirname) {
|
|
170
|
+
errors.push(`name: must equal directory name '${dirname}' (got '${n}')`);
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
// description
|
|
174
|
+
const desc = fm["description"];
|
|
175
|
+
if (desc === undefined || (typeof desc === "string" && desc.trim() === "")) {
|
|
176
|
+
errors.push("description: missing or empty");
|
|
177
|
+
}
|
|
178
|
+
else if (typeof desc !== "string") {
|
|
179
|
+
errors.push("description: must be a string");
|
|
180
|
+
}
|
|
181
|
+
else {
|
|
182
|
+
const d = desc.trim();
|
|
183
|
+
if (d.length < MIN_DESC || d.length > MAX_DESC) {
|
|
184
|
+
errors.push(`description: length must be 1-${MAX_DESC} (got ${d.length})`);
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
// metadata values all strings - check raw entries for non-string literals
|
|
188
|
+
if (fm["metadata"] !== undefined) {
|
|
189
|
+
const meta = fm["metadata"];
|
|
190
|
+
const rawMap = fm.__metaRaw ?? {};
|
|
191
|
+
if (typeof meta !== "object" || meta === null || Array.isArray(meta)) {
|
|
192
|
+
errors.push("metadata: must be a map of strings");
|
|
193
|
+
}
|
|
194
|
+
else {
|
|
195
|
+
for (const [k, v] of Object.entries(meta)) {
|
|
196
|
+
if (typeof v !== "string") {
|
|
197
|
+
errors.push(`metadata: value for '${k}' must be a string`);
|
|
198
|
+
}
|
|
199
|
+
else {
|
|
200
|
+
const raw = rawMap[k] ?? v;
|
|
201
|
+
// raw is the stored trimmed value without quotes as appears after colon
|
|
202
|
+
// If raw is numeric / boolean / null without quotes, treat as non-string
|
|
203
|
+
if (/^-?\d+(\.\d+)?$/.test(raw) || raw === "true" || raw === "false" || raw === "null") {
|
|
204
|
+
errors.push(`metadata: value for '${k}' must be a string (got '${raw}')`);
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
// body present
|
|
211
|
+
if (bodyTrimmed === "") {
|
|
212
|
+
errors.push("body: missing");
|
|
213
|
+
}
|
|
214
|
+
else {
|
|
215
|
+
const bodyLineCount = bodyTrimmed.split("\n").length;
|
|
216
|
+
if (bodyLineCount > MAX_BODY_LINES) {
|
|
217
|
+
warnings.push(`body: exceeds ${MAX_BODY_LINES} lines (got ${bodyLineCount})`);
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
const ok = errors.length === 0;
|
|
221
|
+
return { ok, errors, warnings };
|
|
222
|
+
}
|
|
223
|
+
function stripQuotes(s) {
|
|
224
|
+
const t = s.trim();
|
|
225
|
+
if ((t.startsWith('"') && t.endsWith('"')) || (t.startsWith("'") && t.endsWith("'"))) {
|
|
226
|
+
return t.slice(1, -1);
|
|
227
|
+
}
|
|
228
|
+
return t;
|
|
229
|
+
}
|
|
230
|
+
// helper for file-based validation (reads file and passes dirname)
|
|
231
|
+
export function validateSkillFile(filePath, content) {
|
|
232
|
+
const parts = filePath.replace(/\\/g, "/").split("/");
|
|
233
|
+
// dirname is parent dir name: .../<dirname>/SKILL.md
|
|
234
|
+
const dir = parts.length >= 2 ? parts[parts.length - 2] : "";
|
|
235
|
+
return validateSkill(content, dir);
|
|
236
|
+
}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import type { MemorySource, SourceEntry } from "./MemorySource.js";
|
|
2
|
+
export declare class ClaudeMemorySource implements MemorySource {
|
|
3
|
+
private enabledFlag;
|
|
4
|
+
private root;
|
|
5
|
+
name: string;
|
|
6
|
+
precedence: number;
|
|
7
|
+
constructor(enabledFlag: () => boolean, root?: string);
|
|
8
|
+
enabled(): boolean;
|
|
9
|
+
fetch(): Promise<SourceEntry[]>;
|
|
10
|
+
}
|