@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.
Files changed (45) hide show
  1. package/README.md +711 -667
  2. package/dist/ArtifactWriter.js +1 -1
  3. package/dist/InjectionLedger.js +112 -110
  4. package/dist/Materializer.d.ts +5 -0
  5. package/dist/Materializer.js +11 -0
  6. package/dist/MemoryService.js +93 -91
  7. package/dist/RepoIdentity.js +1 -1
  8. package/dist/Retrospective.js +8 -0
  9. package/dist/Store.d.ts +1 -0
  10. package/dist/Store.js +14 -2
  11. package/dist/contract.d.ts +58 -1
  12. package/dist/contract.js +215 -3
  13. package/dist/import-host.d.ts +41 -0
  14. package/dist/import-host.js +286 -0
  15. package/dist/index.d.ts +9 -2
  16. package/dist/index.js +16 -1
  17. package/dist/kevin_audit.d.ts +16 -0
  18. package/dist/kevin_audit.js +37 -0
  19. package/dist/metrics.d.ts +1 -1
  20. package/dist/metrics.js +22 -0
  21. package/dist/mif.d.ts +32 -0
  22. package/dist/mif.js +112 -0
  23. package/dist/migrations/014_v2_commonwealth.sql +50 -0
  24. package/dist/okf-shards.d.ts +10 -0
  25. package/dist/okf-shards.js +103 -0
  26. package/dist/okf.d.ts +5 -1
  27. package/dist/okf.js +11 -3
  28. package/dist/skills-emit.d.ts +38 -0
  29. package/dist/skills-emit.js +421 -0
  30. package/dist/skills-validate.d.ts +7 -0
  31. package/dist/skills-validate.js +236 -0
  32. package/dist/sources/ClaudeMemorySource.d.ts +10 -0
  33. package/dist/sources/ClaudeMemorySource.js +36 -0
  34. package/dist/sources/CodexMemoriesSource.d.ts +10 -0
  35. package/dist/sources/CodexMemoriesSource.js +37 -0
  36. package/dist/sources/IdleSync.d.ts +10 -0
  37. package/dist/sources/IdleSync.js +49 -0
  38. package/dist/sources/MemorySource.d.ts +19 -0
  39. package/dist/sources/MemorySource.js +1 -0
  40. package/dist/sources/OpencodeNativeSource.d.ts +10 -0
  41. package/dist/sources/OpencodeNativeSource.js +33 -0
  42. package/dist/sources/OpencodePluginSource.d.ts +9 -0
  43. package/dist/sources/OpencodePluginSource.js +13 -0
  44. package/dist/sqlite-adapter.js +1 -1
  45. 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
+ }