@jmtrin/kevin-core 1.3.0 → 1.5.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/LICENSE +21 -0
- package/README.md +667 -0
- package/dist/ArtifactWriter.js +1 -1
- package/dist/InjectionLedger.d.ts +5 -2
- package/dist/InjectionLedger.js +132 -113
- 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 +10 -0
- package/dist/Store.d.ts +1 -0
- package/dist/Store.js +15 -2
- package/dist/contract.d.ts +8 -0
- package/dist/contract.js +31 -2
- package/dist/import-host.d.ts +41 -0
- package/dist/import-host.js +286 -0
- package/dist/index.d.ts +6 -2
- package/dist/index.js +12 -1
- package/dist/kevin_audit.d.ts +31 -0
- package/dist/kevin_audit.js +94 -0
- package/dist/metrics.d.ts +1 -1
- package/dist/metrics.js +10 -0
- package/dist/mif.d.ts +32 -0
- package/dist/mif.js +112 -0
- package/dist/migrations/013_v14_bridge.sql +46 -0
- package/dist/perf.d.ts +1 -1
- package/dist/perf.js +2 -0
- 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/sqlite-adapter.js +1 -1
- package/package.json +1 -1
package/dist/mif.js
ADDED
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
// K15-008 — MIF codec (plan §4.4)
|
|
2
|
+
// Envelope {id, content, type, timestamp, source, metadata} + vendor extensions preserved + PII redaction + content-hash dedup (import side)
|
|
3
|
+
import { fingerprint as computeFingerprint } from "./fingerprint.js";
|
|
4
|
+
const SECRET_PATTERNS = [
|
|
5
|
+
/\b(API_KEY|SECRET|PASSWORD|TOKEN)\b\s*[=:]\s*\S+/gi,
|
|
6
|
+
/\bBearer\s+\S+/gi,
|
|
7
|
+
/\b(access_?token|auth_?token|api_?token)\b\s*[=:]\s*\S+/gi,
|
|
8
|
+
/\btoken\s*[=:]\s*\S+/gi,
|
|
9
|
+
/\baws_secret_access_key\b\s*[=:]\s*\S+/gi,
|
|
10
|
+
/\bghp_[A-Za-z0-9_]+/g,
|
|
11
|
+
/\bsk-[A-Za-z0-9_\-]+/g,
|
|
12
|
+
/\bgithub_pat_[A-Za-z0-9_]+/g,
|
|
13
|
+
];
|
|
14
|
+
function redactSecrets(text) {
|
|
15
|
+
let out = text;
|
|
16
|
+
for (const pat of SECRET_PATTERNS) {
|
|
17
|
+
out = out.replace(pat, (m) => {
|
|
18
|
+
const eq = m.indexOf("=");
|
|
19
|
+
const colon = m.indexOf(":");
|
|
20
|
+
const sep = eq !== -1 ? "=" : colon !== -1 ? ":" : " ";
|
|
21
|
+
const prefix = m.slice(0, m.indexOf(sep) + 1);
|
|
22
|
+
return `${prefix}<redacted>`;
|
|
23
|
+
});
|
|
24
|
+
}
|
|
25
|
+
// fallback: if pattern didn't match sep, replace whole token
|
|
26
|
+
return out;
|
|
27
|
+
}
|
|
28
|
+
function toIso(ts) {
|
|
29
|
+
try {
|
|
30
|
+
const iso = ts.includes("T") ? ts : `${ts.replace(" ", "T")}Z`;
|
|
31
|
+
return new Date(iso).toISOString();
|
|
32
|
+
}
|
|
33
|
+
catch {
|
|
34
|
+
return new Date().toISOString();
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
export function toMif(rows, opts) {
|
|
38
|
+
const memories = rows.map((r) => {
|
|
39
|
+
const originalContent = r.content;
|
|
40
|
+
let content = originalContent;
|
|
41
|
+
if (opts.redactPii) {
|
|
42
|
+
content = redactSecrets(content);
|
|
43
|
+
}
|
|
44
|
+
const meta = {
|
|
45
|
+
scope: String(r.scope ?? "project"),
|
|
46
|
+
fingerprint: String(r.fingerprint ?? computeFingerprint(originalContent)),
|
|
47
|
+
confidence: String(r.confidence ?? ""),
|
|
48
|
+
evidence_count: String(r.evidenceCount ?? 0),
|
|
49
|
+
};
|
|
50
|
+
const base = {
|
|
51
|
+
id: r.id,
|
|
52
|
+
content,
|
|
53
|
+
type: r.type,
|
|
54
|
+
timestamp: toIso(r.createdAt ?? new Date().toISOString()),
|
|
55
|
+
source: "opencode-kevin",
|
|
56
|
+
metadata: meta,
|
|
57
|
+
};
|
|
58
|
+
// preserve unknown fields from original row that are not part of standard mapping
|
|
59
|
+
// standard keys: id, content, type, createdAt, scope, fingerprint, confidence, evidenceCount, etc.
|
|
60
|
+
// unknown vendor extensions stored under `mif_vendor` in metadata if present
|
|
61
|
+
const mifVendor = r.mif_vendor;
|
|
62
|
+
if (mifVendor && typeof mifVendor === "object") {
|
|
63
|
+
for (const [k, v] of Object.entries(mifVendor)) {
|
|
64
|
+
if (!(k in base))
|
|
65
|
+
base[k] = v;
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
// also check if row has extra top-level keys beyond Memory standard (for codec-level preservation)
|
|
69
|
+
const extraKeys = Object.keys(r).filter((k) => !["id", "content", "type", "scope", "createdAt", "updatedAt", "fingerprint", "confidence", "evidenceCount", "recurrenceCount", "projectId", "repoId", "layer", "status", "metadata", "origin", "sourceTool", "sourceSession", "relevanceScore", "truthPenalty"].includes(k));
|
|
70
|
+
for (const k of extraKeys) {
|
|
71
|
+
if (k === "mif_vendor")
|
|
72
|
+
continue;
|
|
73
|
+
if (!(k in base))
|
|
74
|
+
base[k] = r[k];
|
|
75
|
+
}
|
|
76
|
+
return base;
|
|
77
|
+
});
|
|
78
|
+
return { format: "mif", version: 1, memories };
|
|
79
|
+
}
|
|
80
|
+
export function fromMif(env) {
|
|
81
|
+
if (!env || env.format !== "mif" || env.version !== 1 || !Array.isArray(env.memories)) {
|
|
82
|
+
throw new Error("invalid MIF envelope: expected {format:'mif', version:1, memories:[]}");
|
|
83
|
+
}
|
|
84
|
+
const candidates = [];
|
|
85
|
+
const preserved = new Set();
|
|
86
|
+
for (const m of env.memories) {
|
|
87
|
+
const known = new Set(["id", "content", "type", "timestamp", "source", "metadata", "format", "version"]);
|
|
88
|
+
const unknown = {};
|
|
89
|
+
for (const k of Object.keys(m)) {
|
|
90
|
+
if (!known.has(k)) {
|
|
91
|
+
unknown[k] = m[k];
|
|
92
|
+
preserved.add(k);
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
// also collect vendorExtensions top-level unknown?
|
|
96
|
+
candidates.push({
|
|
97
|
+
id: String(m.id),
|
|
98
|
+
content: String(m.content),
|
|
99
|
+
type: String(m.type),
|
|
100
|
+
timestamp: String(m.timestamp),
|
|
101
|
+
source: String(m.source ?? "opencode-kevin"),
|
|
102
|
+
metadata: { ...(m.metadata ?? {}) },
|
|
103
|
+
unknownFields: unknown,
|
|
104
|
+
});
|
|
105
|
+
}
|
|
106
|
+
// top-level vendorExtensions unknown
|
|
107
|
+
if (env.vendorExtensions) {
|
|
108
|
+
for (const k of Object.keys(env.vendorExtensions))
|
|
109
|
+
preserved.add(k);
|
|
110
|
+
}
|
|
111
|
+
return { candidates, unknownFieldsPreserved: [...preserved] };
|
|
112
|
+
}
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
-- =============================================================
|
|
2
|
+
-- Kevin v1.4.0 "Bridge" — migration 013
|
|
3
|
+
-- Adds: channel column on kevin_injections, 5 MCP metric seeds,
|
|
4
|
+
-- expands hook CHECK to include pull_mcp (D14-04).
|
|
5
|
+
-- =============================================================
|
|
6
|
+
|
|
7
|
+
ALTER TABLE kevin_injections ADD COLUMN channel TEXT NOT NULL DEFAULT 'plugin';
|
|
8
|
+
|
|
9
|
+
-- SQLite cannot ALTER a CHECK constraint, so rebuild to widen hook.
|
|
10
|
+
CREATE TABLE IF NOT EXISTS kevin_injections_new (
|
|
11
|
+
id TEXT PRIMARY KEY,
|
|
12
|
+
memory_id TEXT NOT NULL,
|
|
13
|
+
fingerprint TEXT NOT NULL,
|
|
14
|
+
session_id TEXT NOT NULL,
|
|
15
|
+
hook TEXT NOT NULL CHECK (hook IN ('pre_prompt','compacting','pull_mcp')),
|
|
16
|
+
tokens INTEGER NOT NULL,
|
|
17
|
+
injected_at TEXT NOT NULL DEFAULT (datetime('now')),
|
|
18
|
+
outcome TEXT NOT NULL DEFAULT 'unmeasured'
|
|
19
|
+
CHECK (outcome IN ('unmeasured','effective','ineffective','inconclusive')),
|
|
20
|
+
injected_at_ms INTEGER,
|
|
21
|
+
channel TEXT NOT NULL DEFAULT 'plugin'
|
|
22
|
+
);
|
|
23
|
+
|
|
24
|
+
INSERT INTO kevin_injections_new
|
|
25
|
+
(id, memory_id, fingerprint, session_id, hook, tokens, injected_at, outcome, injected_at_ms, channel)
|
|
26
|
+
SELECT
|
|
27
|
+
id, memory_id, fingerprint, session_id, hook, tokens, injected_at, outcome, injected_at_ms, channel
|
|
28
|
+
FROM kevin_injections;
|
|
29
|
+
|
|
30
|
+
DROP TABLE kevin_injections;
|
|
31
|
+
ALTER TABLE kevin_injections_new RENAME TO kevin_injections;
|
|
32
|
+
|
|
33
|
+
CREATE INDEX IF NOT EXISTS idx_injections_fp ON kevin_injections(fingerprint);
|
|
34
|
+
CREATE INDEX IF NOT EXISTS idx_injections_session ON kevin_injections(session_id);
|
|
35
|
+
CREATE INDEX IF NOT EXISTS idx_injections_outcome ON kevin_injections(outcome);
|
|
36
|
+
CREATE INDEX IF NOT EXISTS idx_injections_channel ON kevin_injections(channel);
|
|
37
|
+
CREATE INDEX IF NOT EXISTS idx_tool_calls_ts_ms ON tool_calls(ts_ms);
|
|
38
|
+
CREATE INDEX IF NOT EXISTS idx_injections_injected_ms ON kevin_injections(injected_at_ms);
|
|
39
|
+
|
|
40
|
+
INSERT OR IGNORE INTO kevin_metrics (key, value) VALUES ('mcp_requests_total', 0);
|
|
41
|
+
INSERT OR IGNORE INTO kevin_metrics (key, value) VALUES ('mcp_reads_served', 0);
|
|
42
|
+
INSERT OR IGNORE INTO kevin_metrics (key, value) VALUES ('mcp_writes_accepted', 0);
|
|
43
|
+
INSERT OR IGNORE INTO kevin_metrics (key, value) VALUES ('mcp_writes_refused', 0);
|
|
44
|
+
INSERT OR IGNORE INTO kevin_metrics (key, value) VALUES ('mcp_errors_total', 0);
|
|
45
|
+
|
|
46
|
+
INSERT INTO schema_version (version) VALUES ('013');
|
package/dist/perf.d.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import type { Store } from "./Store.js";
|
|
2
|
-
export type PerfScope = "tool.execute.before" | "tool.execute.after" | "chat.message" | "chat.system.transform" | "session.compacting" | "event" | "session.idle" | "dispose";
|
|
2
|
+
export type PerfScope = "tool.execute.before" | "tool.execute.after" | "chat.message" | "chat.system.transform" | "session.compacting" | "event" | "session.idle" | "dispose" | "mcp.read" | "mcp.write";
|
|
3
3
|
export interface Budget {
|
|
4
4
|
readonly scope: PerfScope;
|
|
5
5
|
readonly p95Ms: number;
|
package/dist/perf.js
CHANGED
|
@@ -7,6 +7,8 @@ export const BUDGETS = [
|
|
|
7
7
|
{ scope: "event", p95Ms: 5, maxMs: 25 },
|
|
8
8
|
{ scope: "session.idle", p95Ms: 150, maxMs: 600 },
|
|
9
9
|
{ scope: "dispose", p95Ms: 50, maxMs: 250 },
|
|
10
|
+
{ scope: "mcp.read", p95Ms: 25, maxMs: 100 },
|
|
11
|
+
{ scope: "mcp.write", p95Ms: 50, maxMs: 250 },
|
|
10
12
|
];
|
|
11
13
|
function clampCapacity(raw) {
|
|
12
14
|
if (raw === null || raw === undefined || raw === "")
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import { type KevinEnv } from "./env.js";
|
|
2
|
+
export interface TopicBundle {
|
|
3
|
+
topic: string;
|
|
4
|
+
content: string;
|
|
5
|
+
/** optional precomputed summary; derived from content if omitted */
|
|
6
|
+
summary?: string;
|
|
7
|
+
}
|
|
8
|
+
export interface SkillEmitInput {
|
|
9
|
+
projectRoot: string;
|
|
10
|
+
canonicalDir: string;
|
|
11
|
+
mirrors: Array<"claude" | "cursor">;
|
|
12
|
+
topics: TopicBundle[];
|
|
13
|
+
repoId: string;
|
|
14
|
+
/** injectable for tests; defaults to ~/.opencode-kevin/skills-manifest.json */
|
|
15
|
+
manifestPath?: string;
|
|
16
|
+
env?: KevinEnv;
|
|
17
|
+
metrics?: {
|
|
18
|
+
incr: (key: string, by?: number) => void;
|
|
19
|
+
};
|
|
20
|
+
}
|
|
21
|
+
export interface EmitReport {
|
|
22
|
+
written: string[];
|
|
23
|
+
skipped_external: string[];
|
|
24
|
+
noop: string[];
|
|
25
|
+
removed_orphan_manifest: string[];
|
|
26
|
+
external_edits: string[];
|
|
27
|
+
}
|
|
28
|
+
declare function sha256Hex(s: string): string;
|
|
29
|
+
declare function escaped(text: string): string;
|
|
30
|
+
declare function buildSkillMd(repoId: string, bundles: TopicBundle[]): string;
|
|
31
|
+
export declare function emitSkillBundle(input: SkillEmitInput): EmitReport;
|
|
32
|
+
export declare function refreshSkillBundle(input: SkillEmitInput): EmitReport;
|
|
33
|
+
export declare const _internal: {
|
|
34
|
+
buildSkillMd: typeof buildSkillMd;
|
|
35
|
+
sha256Hex: typeof sha256Hex;
|
|
36
|
+
escaped: typeof escaped;
|
|
37
|
+
};
|
|
38
|
+
export {};
|
|
@@ -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;
|