@kontextmind/kxm 0.6.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 (227) hide show
  1. package/.claude-plugin/marketplace.json +19 -0
  2. package/.kxm/README.md +14 -0
  3. package/.kxm/assets/README.md +5 -0
  4. package/.kxm/assets/retrospectives/README.md +5 -0
  5. package/.kxm/config/README.md +5 -0
  6. package/.kxm/config/agents.json +43 -0
  7. package/.kxm/config/env.example +56 -0
  8. package/.kxm/config/update.example.yaml +9 -0
  9. package/.kxm/config/workflows/fix.json +160 -0
  10. package/.kxm/config/workflows/jira-development.json +116 -0
  11. package/.kxm/config/workflows/provenance-quorum.json +150 -0
  12. package/.kxm/config/workflows/v04-dogfood.json +72 -0
  13. package/CHANGELOG.md +465 -0
  14. package/LICENSE +21 -0
  15. package/README.md +306 -0
  16. package/SECURITY.md +72 -0
  17. package/docs/README.md +48 -0
  18. package/docs/agent-communication-envelopes-and-gates.md +553 -0
  19. package/docs/architecture.md +242 -0
  20. package/docs/assignment-runner.md +241 -0
  21. package/docs/configuration.md +361 -0
  22. package/docs/continuous-improvement.md +114 -0
  23. package/docs/getting-started.md +253 -0
  24. package/docs/kxm-handbook.md +1090 -0
  25. package/docs/operations.md +205 -0
  26. package/docs/provenance-gates.md +291 -0
  27. package/docs/skills.md +45 -0
  28. package/docs/templates/README.md +95 -0
  29. package/docs/templates/adr.md +88 -0
  30. package/docs/templates/architecture.md +120 -0
  31. package/docs/templates/bug-fix.md +109 -0
  32. package/docs/templates/feature.md +108 -0
  33. package/docs/templates/handoff.md +72 -0
  34. package/docs/templates/postmortem.md +77 -0
  35. package/docs/templates/research.md +100 -0
  36. package/docs/templates/review.md +85 -0
  37. package/docs/templates/runbook.md +73 -0
  38. package/docs/templates/test-plan.md +87 -0
  39. package/docs/templates/test-report.md +72 -0
  40. package/docs/test-matrix.md +121 -0
  41. package/docs/troubleshooting.md +249 -0
  42. package/docs/vnext/README.md +62 -0
  43. package/docs/vnext/architecture.md +185 -0
  44. package/docs/vnext/effects-and-recovery.md +172 -0
  45. package/docs/vnext/lifecycles.md +235 -0
  46. package/docs/vnext/migration.md +220 -0
  47. package/docs/vnext/routing.md +184 -0
  48. package/docs/vnext/synchronization.md +172 -0
  49. package/docs/vnext/terminology.md +240 -0
  50. package/docs/vnext/validation.md +335 -0
  51. package/docs/webhook-workflows.md +240 -0
  52. package/docs/workflow-guide.md +1150 -0
  53. package/examples/README.md +102 -0
  54. package/examples/provenance-workflow.json +40 -0
  55. package/examples/requester.ts +30 -0
  56. package/examples/reviewer-agent.ts +29 -0
  57. package/examples/roundtrip.ts +46 -0
  58. package/examples/vnext/.kxm/agents/coordinator.yaml +16 -0
  59. package/examples/vnext/.kxm/agents/critic-1.yaml +16 -0
  60. package/examples/vnext/.kxm/agents/critic-2.yaml +15 -0
  61. package/examples/vnext/.kxm/agents/critic-3.yaml +15 -0
  62. package/examples/vnext/.kxm/agents/implementer.yaml +15 -0
  63. package/examples/vnext/.kxm/agents/planner.yaml +13 -0
  64. package/examples/vnext/.kxm/agents/reproducer.yaml +15 -0
  65. package/examples/vnext/.kxm/agents/reviewer.yaml +15 -0
  66. package/examples/vnext/.kxm/gates.yaml +8 -0
  67. package/examples/vnext/.kxm/models/critic-claude.yaml +11 -0
  68. package/examples/vnext/.kxm/models/critic-gemini.yaml +11 -0
  69. package/examples/vnext/.kxm/models/critic-grok.yaml +12 -0
  70. package/examples/vnext/.kxm/models/implementation.yaml +14 -0
  71. package/examples/vnext/.kxm/models/primary.yaml +17 -0
  72. package/examples/vnext/.kxm/prices.yaml +111 -0
  73. package/examples/vnext/.kxm/project/env.yaml +7 -0
  74. package/examples/vnext/.kxm/project.yaml +32 -0
  75. package/examples/vnext/.kxm/repo/repo.yaml +8 -0
  76. package/examples/vnext/.kxm/workflows/default.yaml +92 -0
  77. package/examples/vnext/.kxm/workflows/fix.yaml +376 -0
  78. package/examples/vnext/.kxm/workflows/improve.yaml +57 -0
  79. package/examples/vnext/README.md +53 -0
  80. package/examples/vnext/records/assignment-result-recorded.json +63 -0
  81. package/examples/vnext/records/assignment-result.json +46 -0
  82. package/examples/vnext/records/context-candidate.json +42 -0
  83. package/examples/vnext/records/delivery-manifest.json +66 -0
  84. package/examples/vnext/records/effect-uncertainty-resolved-sync.json +67 -0
  85. package/examples/vnext/records/effect-uncertainty-resolved.json +62 -0
  86. package/examples/vnext/records/run-created.json +54 -0
  87. package/examples/vnext/records/sync-event.json +65 -0
  88. package/examples/vnext/repositories/api/.kxm/repo/env.yaml +7 -0
  89. package/examples/vnext/repositories/api/.kxm/repo/repo.yaml +8 -0
  90. package/examples/vnext/repositories/web/.kxm/repo/repo.yaml +8 -0
  91. package/examples/workflow-signal.ts +63 -0
  92. package/package.json +129 -0
  93. package/plugins/kxm/.claude-plugin/plugin.json +73 -0
  94. package/plugins/kxm/.mcp.json +19 -0
  95. package/plugins/kxm/README.md +93 -0
  96. package/plugins/kxm/dist/cli.js +42853 -0
  97. package/plugins/kxm/dist/client.js +416 -0
  98. package/plugins/kxm/dist/core.js +1823 -0
  99. package/plugins/kxm/dist/extension.js +3797 -0
  100. package/plugins/kxm/dist/mcp-server.js +17104 -0
  101. package/plugins/kxm/dist/runtime.js +23361 -0
  102. package/plugins/kxm/dist/server.js +13640 -0
  103. package/plugins/kxm/dist/vnext-runtime-supervisor.js +21109 -0
  104. package/plugins/kxm/package.json +12 -0
  105. package/plugins/kxm/skills/kxm/SKILL.md +97 -0
  106. package/plugins/kxm/skills/kxm/references/protocol.md +103 -0
  107. package/plugins/kxm/skills/kxm-session/SKILL.md +53 -0
  108. package/plugins/kxm/src/arbiter.ts +355 -0
  109. package/plugins/kxm/src/artifacts-exist.ts +62 -0
  110. package/plugins/kxm/src/autocomplete.ts +236 -0
  111. package/plugins/kxm/src/cli.ts +3707 -0
  112. package/plugins/kxm/src/client.ts +614 -0
  113. package/plugins/kxm/src/commands.ts +1063 -0
  114. package/plugins/kxm/src/config.ts +290 -0
  115. package/plugins/kxm/src/context/providers.ts +101 -0
  116. package/plugins/kxm/src/context-packet.ts +332 -0
  117. package/plugins/kxm/src/context.ts +499 -0
  118. package/plugins/kxm/src/core.ts +6 -0
  119. package/plugins/kxm/src/database.ts +563 -0
  120. package/plugins/kxm/src/diagnostics.ts +184 -0
  121. package/plugins/kxm/src/envelope.ts +118 -0
  122. package/plugins/kxm/src/extension.ts +895 -0
  123. package/plugins/kxm/src/external-effects.ts +299 -0
  124. package/plugins/kxm/src/github-watch.ts +255 -0
  125. package/plugins/kxm/src/hub-binding.ts +160 -0
  126. package/plugins/kxm/src/hub.ts +2502 -0
  127. package/plugins/kxm/src/improve.ts +383 -0
  128. package/plugins/kxm/src/inbox.ts +10 -0
  129. package/plugins/kxm/src/kxm-install-kind.ts +113 -0
  130. package/plugins/kxm/src/kxm-update-config.ts +39 -0
  131. package/plugins/kxm/src/kxm-update.ts +238 -0
  132. package/plugins/kxm/src/local-snapshot.ts +406 -0
  133. package/plugins/kxm/src/logger.ts +198 -0
  134. package/plugins/kxm/src/mcp-server.ts +143 -0
  135. package/plugins/kxm/src/memory.ts +385 -0
  136. package/plugins/kxm/src/nous-pi.ts +287 -0
  137. package/plugins/kxm/src/nous-provider.ts +729 -0
  138. package/plugins/kxm/src/price-calc.ts +87 -0
  139. package/plugins/kxm/src/prices.ts +121 -0
  140. package/plugins/kxm/src/protocol.ts +172 -0
  141. package/plugins/kxm/src/recovery.ts +211 -0
  142. package/plugins/kxm/src/redact.ts +26 -0
  143. package/plugins/kxm/src/retrospective.ts +400 -0
  144. package/plugins/kxm/src/routing.ts +830 -0
  145. package/plugins/kxm/src/runtime.ts +9 -0
  146. package/plugins/kxm/src/server.ts +117 -0
  147. package/plugins/kxm/src/session-work.ts +571 -0
  148. package/plugins/kxm/src/session.ts +184 -0
  149. package/plugins/kxm/src/skills.ts +535 -0
  150. package/plugins/kxm/src/state.ts +326 -0
  151. package/plugins/kxm/src/store.ts +637 -0
  152. package/plugins/kxm/src/studio-layout.ts +268 -0
  153. package/plugins/kxm/src/suggest.ts +162 -0
  154. package/plugins/kxm/src/task-manager.ts +244 -0
  155. package/plugins/kxm/src/telemetry.ts +116 -0
  156. package/plugins/kxm/src/tui.ts +1046 -0
  157. package/plugins/kxm/src/vnext-bindings.ts +403 -0
  158. package/plugins/kxm/src/vnext-config.ts +1646 -0
  159. package/plugins/kxm/src/vnext-engine-artifacts.ts +86 -0
  160. package/plugins/kxm/src/vnext-engine-command.ts +533 -0
  161. package/plugins/kxm/src/vnext-engine-compile.ts +722 -0
  162. package/plugins/kxm/src/vnext-engine-evidence.ts +273 -0
  163. package/plugins/kxm/src/vnext-engine-fold.ts +1400 -0
  164. package/plugins/kxm/src/vnext-engine-gate-records.ts +583 -0
  165. package/plugins/kxm/src/vnext-engine-plan.ts +717 -0
  166. package/plugins/kxm/src/vnext-engine.ts +2458 -0
  167. package/plugins/kxm/src/vnext-gate-hash.ts +10 -0
  168. package/plugins/kxm/src/vnext-harness.ts +1142 -0
  169. package/plugins/kxm/src/vnext-init.ts +430 -0
  170. package/plugins/kxm/src/vnext-migrate.ts +1848 -0
  171. package/plugins/kxm/src/vnext-oneshot-producer.ts +424 -0
  172. package/plugins/kxm/src/vnext-permission.ts +936 -0
  173. package/plugins/kxm/src/vnext-pi-producer.ts +628 -0
  174. package/plugins/kxm/src/vnext-repair.ts +1094 -0
  175. package/plugins/kxm/src/vnext-runtime-owner.ts +320 -0
  176. package/plugins/kxm/src/vnext-runtime-store.ts +1560 -0
  177. package/plugins/kxm/src/vnext-runtime-supervisor.ts +586 -0
  178. package/plugins/kxm/src/vnext-runtime.ts +663 -0
  179. package/plugins/kxm/src/vnext-template.ts +247 -0
  180. package/plugins/kxm/src/wiki.ts +313 -0
  181. package/plugins/kxm/src/workflow.ts +1548 -0
  182. package/schemas/vnext/README.md +46 -0
  183. package/schemas/vnext/agent.schema.json +40 -0
  184. package/schemas/vnext/assignment-result.schema.json +66 -0
  185. package/schemas/vnext/backup-manifest.schema.json +89 -0
  186. package/schemas/vnext/candidate.schema.json +109 -0
  187. package/schemas/vnext/common.schema.json +422 -0
  188. package/schemas/vnext/context-candidate.schema.json +76 -0
  189. package/schemas/vnext/context-packet.schema.json +192 -0
  190. package/schemas/vnext/delivery-manifest.schema.json +159 -0
  191. package/schemas/vnext/environment.schema.json +66 -0
  192. package/schemas/vnext/gate-registry.schema.json +109 -0
  193. package/schemas/vnext/handoff-manifest.schema.json +146 -0
  194. package/schemas/vnext/init-operation.schema.json +61 -0
  195. package/schemas/vnext/local-repository-bindings.schema.json +30 -0
  196. package/schemas/vnext/memory-record.schema.json +45 -0
  197. package/schemas/vnext/migration-decision.schema.json +26 -0
  198. package/schemas/vnext/migration-plan.schema.json +123 -0
  199. package/schemas/vnext/migration-receipt.schema.json +52 -0
  200. package/schemas/vnext/model.schema.json +42 -0
  201. package/schemas/vnext/permission-diff.schema.json +57 -0
  202. package/schemas/vnext/prices.schema.json +115 -0
  203. package/schemas/vnext/project.schema.json +85 -0
  204. package/schemas/vnext/repository.schema.json +24 -0
  205. package/schemas/vnext/run-event.schema.json +460 -0
  206. package/schemas/vnext/session-brief.schema.json +153 -0
  207. package/schemas/vnext/sync-event.schema.json +234 -0
  208. package/schemas/vnext/template-provenance.schema.json +38 -0
  209. package/schemas/vnext/workflow.schema.json +248 -0
  210. package/scripts/assignment-run.d.mts +354 -0
  211. package/scripts/assignment-run.mjs +4451 -0
  212. package/scripts/build-runtime.mjs +56 -0
  213. package/scripts/check-generated.mjs +77 -0
  214. package/scripts/check-versions.mjs +34 -0
  215. package/scripts/emit-codex-artifacts.d.mts +9 -0
  216. package/scripts/emit-codex-artifacts.mjs +91 -0
  217. package/scripts/harness-run.d.mts +83 -0
  218. package/scripts/harness-run.mjs +2095 -0
  219. package/scripts/kxm-hub.mjs +105 -0
  220. package/scripts/kxm-publish-npm.mjs +327 -0
  221. package/scripts/kxm-release-github.mjs +472 -0
  222. package/scripts/kxm-runtime-supervisor.mjs +7 -0
  223. package/scripts/kxm-worker.mjs +1127 -0
  224. package/scripts/kxm.mjs +27 -0
  225. package/scripts/roster-policy.d.mts +20 -0
  226. package/scripts/roster-policy.mjs +161 -0
  227. package/scripts/smoke-multi-pi.mjs +479 -0
@@ -0,0 +1,535 @@
1
+ import { createHash } from "node:crypto";
2
+ import { existsSync, mkdirSync, readdirSync, readFileSync, renameSync, rmSync, statSync, writeFileSync } from "node:fs";
3
+ import { join } from "node:path";
4
+ import { parse } from "yaml";
5
+ import { redactSecrets } from "./redact.ts";
6
+
7
+ /**
8
+ * Governed skill candidate lifecycle (v0.5, issue #39).
9
+ *
10
+ * Runtime experience becomes a *candidate*; candidates never become promoted
11
+ * skills without passing static/provenance review, sandbox execution, and
12
+ * protected functional + safety evaluation. Promoted skills are version
13
+ * controlled and immutable to run-time agents: behavior changes require a
14
+ * new candidate/eval cycle. Skill text may inform behavior but never grants
15
+ * tool or permission authority.
16
+ *
17
+ * Storage layout under the workspace root (default `.kxm/skills`):
18
+ * candidates/<id>/SKILL.md + metadata.json
19
+ * promoted/<id>/SKILL.md + metadata.json
20
+ * quarantined/<id>/SKILL.md + metadata.json
21
+ * history/<id>.jsonl — append-only audit trail per skill lineage
22
+ */
23
+
24
+ export const SKILL_CANDIDATE_SCHEMA = "kxm.skill-candidate.v1";
25
+ export const SKILL_EVALUATION_SCHEMA = "kxm.skill-evaluation.v1";
26
+ export const SKILL_DECISION_SCHEMA = "kxm.skill-decision.v1";
27
+
28
+ export const MAX_SKILL_NAME_CHARS = 64;
29
+ export const MAX_SKILL_CONTENT_CHARS = 32_000;
30
+ export const MAX_SKILL_EVIDENCE_REFS = 32;
31
+ export const MAX_SKILL_MODELS = 16;
32
+
33
+ export type SkillState = "candidate" | "promoted" | "quarantined" | "rejected";
34
+ export type SkillDecision = "promoted" | "quarantined" | "rejected";
35
+ export type SkillEvaluationKind = "static-review" | "sandbox" | "functional" | "safety" | "optimization";
36
+
37
+ /** Evaluation kinds that must pass before promotion. */
38
+ export const PROMOTION_REQUIRED_EVALUATIONS: readonly SkillEvaluationKind[] = [
39
+ "static-review",
40
+ "sandbox",
41
+ "functional",
42
+ "safety",
43
+ ];
44
+
45
+ export interface SkillSources {
46
+ runIds: string[];
47
+ journalEntryIds: string[];
48
+ evidenceReceipts: string[];
49
+ }
50
+
51
+ export interface SkillCandidateMetadata {
52
+ schema: typeof SKILL_CANDIDATE_SCHEMA;
53
+ id: string;
54
+ name: string;
55
+ description: string;
56
+ contentSha256: string;
57
+ version: number;
58
+ sources: SkillSources;
59
+ /** Explicit cross-model/cross-harness compatibility record. */
60
+ compatibility: {
61
+ harness: string;
62
+ models: string[];
63
+ };
64
+ createdBy: string;
65
+ createdAt: string;
66
+ /** Prior candidate or promoted skill this candidate supersedes. */
67
+ supersedes?: string;
68
+ }
69
+
70
+ export interface SkillEvaluationRecord {
71
+ schema: typeof SKILL_EVALUATION_SCHEMA;
72
+ candidateId: string;
73
+ kind: SkillEvaluationKind;
74
+ evaluatorVersion: string;
75
+ passed: boolean;
76
+ score?: number;
77
+ details?: string;
78
+ evaluatedAt: string;
79
+ }
80
+
81
+ export interface SkillDecisionRecord {
82
+ schema: typeof SKILL_DECISION_SCHEMA;
83
+ candidateId: string;
84
+ decision: SkillDecision;
85
+ decidedBy: string;
86
+ reason: string;
87
+ evidenceRefs: string[];
88
+ decidedAt: string;
89
+ }
90
+
91
+ export interface SkillHistoryEvent {
92
+ schema: "kxm.skill-history-event.v1";
93
+ event: string;
94
+ by?: string;
95
+ supersedes?: string;
96
+ at: string;
97
+ }
98
+
99
+ export type SkillHistoryRecord = SkillEvaluationRecord | SkillDecisionRecord | SkillHistoryEvent;
100
+
101
+ export class SkillLifecycleError extends Error {
102
+ readonly code: string;
103
+ constructor(code: string, message: string) {
104
+ super(message);
105
+ this.name = "SkillLifecycleError";
106
+ this.code = code;
107
+ }
108
+ }
109
+
110
+ export function skillContentSha256(content: string): string {
111
+ return createHash("sha256").update(content, "utf8").digest("hex");
112
+ }
113
+
114
+ /** Deterministic skill ID: slug + content hash prefix. Changed content means
115
+ * a new candidate; identical content is idempotent. */
116
+ export function skillIdFor(name: string, contentSha256: string): string {
117
+ const slug = name.toLowerCase().replaceAll(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "").slice(0, 40);
118
+ if (!slug) throw new SkillLifecycleError("invalid_skill_name", "skill name must contain alphanumeric characters");
119
+ return `${slug}.${contentSha256.slice(0, 12)}`;
120
+ }
121
+
122
+ export function parseSkillFrontmatter(content: string): {
123
+ frontmatter: Record<string, unknown> | null;
124
+ body: string;
125
+ } {
126
+ const match = content.match(/^---\r?\n([\s\S]*?)\r?\n---\r?\n?([\s\S]*)$/);
127
+ if (!match) {
128
+ return { frontmatter: null, body: content };
129
+ }
130
+ const rawFm = match[1];
131
+ const rawBody = match[2];
132
+ if (rawFm === undefined || rawBody === undefined) {
133
+ return { frontmatter: null, body: content };
134
+ }
135
+ try {
136
+ const parsed = parse(rawFm);
137
+ if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) {
138
+ return { frontmatter: parsed as Record<string, unknown>, body: rawBody };
139
+ }
140
+ } catch {
141
+ // Malformed yaml frontmatter
142
+ }
143
+ return { frontmatter: null, body: content };
144
+ }
145
+
146
+ export function ensureSkillFrontmatter(content: string, name: string, description: string): string {
147
+ const parsed = parseSkillFrontmatter(content);
148
+ if (parsed.frontmatter) {
149
+ const fmName = typeof parsed.frontmatter.name === "string" && parsed.frontmatter.name.trim()
150
+ ? parsed.frontmatter.name.trim()
151
+ : name;
152
+ const fmDesc = typeof parsed.frontmatter.description === "string" && parsed.frontmatter.description.trim()
153
+ ? parsed.frontmatter.description.trim()
154
+ : (description || `Governed skill for ${fmName}`);
155
+ const rest = parsed.body.replace(/^(\r?\n)+/, "");
156
+ return `---\nname: ${fmName}\ndescription: ${fmDesc}\n---\n\n${rest}`;
157
+ }
158
+ const desc = description || `Governed skill for ${name}`;
159
+ const rest = content.replace(/^(\r?\n)+/, "");
160
+ return `---\nname: ${name}\ndescription: ${desc}\n---\n\n${rest}`;
161
+ }
162
+
163
+ export function createUnifiedPatch(relativePath: string, content: string): string {
164
+ const lines = content.split("\n");
165
+ const count = lines.length;
166
+ const header = [
167
+ `diff --git a/${relativePath} b/${relativePath}`,
168
+ "new file mode 100644",
169
+ "--- /dev/null",
170
+ `+++ b/${relativePath}`,
171
+ `@@ -0,0 +1,${count} @@`,
172
+ ];
173
+ const body = lines.map((l) => `+${l}`);
174
+ return [...header, ...body, ""].join("\n");
175
+ }
176
+
177
+ export interface CreateSkillInput {
178
+ name: string;
179
+ description?: string;
180
+ content: string;
181
+ sources: Partial<SkillSources>;
182
+ compatibility: { harness: string; models: string[] };
183
+ createdBy: string;
184
+ supersedes?: string;
185
+ version?: number;
186
+ }
187
+
188
+ export interface SkillLifecycleOptions {
189
+ /** Opt-in hook for skillopt/WikiSkill-style optimization behind protected
190
+ * evals. When unset, `optimization` evaluations are rejected. */
191
+ allowOptimizationEvals?: boolean;
192
+ now?: () => string;
193
+ }
194
+
195
+ export class SkillLifecycle {
196
+ private readonly root: string;
197
+ private readonly now: () => string;
198
+ private readonly allowOptimizationEvals: boolean;
199
+
200
+ constructor(root: string, options: SkillLifecycleOptions = {}) {
201
+ this.root = root;
202
+ this.now = options.now ?? (() => new Date().toISOString());
203
+ this.allowOptimizationEvals = options.allowOptimizationEvals === true;
204
+ }
205
+
206
+ private dir(state: SkillState): string {
207
+ return join(this.root, state === "candidate" ? "candidates" : `${state}s`.replace("rejecteds", "rejected").replace("promoteds", "promoted"));
208
+ }
209
+
210
+ private historyFile(id: string): string {
211
+ return join(this.root, "history", `${id}.jsonl`);
212
+ }
213
+
214
+ private paths(state: SkillState, id: string): { dir: string; metadata: string; skill: string } {
215
+ const dir = join(this.dir(state), id);
216
+ return { dir, metadata: join(dir, "metadata.json"), skill: join(dir, "SKILL.md") };
217
+ }
218
+
219
+ private appendHistory(id: string, record: unknown): void {
220
+ mkdirSync(join(this.root, "history"), { recursive: true });
221
+ const line = `${JSON.stringify(record)}\n`;
222
+ if (existsSync(this.historyFile(id))) {
223
+ // Bound the history file: append within the audit limit.
224
+ const existing = readFileSync(this.historyFile(id), "utf8");
225
+ const lines = existing.split("\n").filter((entry) => entry.trim());
226
+ writeFileSync(this.historyFile(id), [...lines.slice(-499), line.trim()].join("\n") + "\n");
227
+ } else {
228
+ writeFileSync(this.historyFile(id), line);
229
+ }
230
+ }
231
+
232
+ history(id: string): SkillHistoryRecord[] {
233
+ const file = this.historyFile(id);
234
+ if (!existsSync(file)) return [];
235
+ return readFileSync(file, "utf8")
236
+ .split("\n")
237
+ .filter((line) => line.trim())
238
+ .map((line) => JSON.parse(line) as SkillHistoryRecord);
239
+ }
240
+
241
+ private readMetadata(state: SkillState, id: string): SkillCandidateMetadata {
242
+ const { metadata } = this.paths(state, id);
243
+ if (!existsSync(metadata)) {
244
+ throw new SkillLifecycleError("skill_not_found", `skill ${id} not found in ${state}`);
245
+ }
246
+ return JSON.parse(readFileSync(metadata, "utf8")) as SkillCandidateMetadata;
247
+ }
248
+
249
+ private move(from: SkillState, to: SkillState, id: string): void {
250
+ const fromDir = join(this.dir(from), id);
251
+ const toDir = join(this.dir(to), id);
252
+ if (!existsSync(fromDir)) {
253
+ throw new SkillLifecycleError("skill_not_found", `skill ${id} not found in ${from}`);
254
+ }
255
+ mkdirSync(this.dir(to), { recursive: true });
256
+ if (existsSync(toDir)) rmSync(toDir, { recursive: true, force: true });
257
+ renameSync(fromDir, toDir);
258
+ }
259
+
260
+ /** Submit a new skill candidate. Content is redacted of secret material at
261
+ * creation; the ID is derived from name + content hash. */
262
+ create(input: CreateSkillInput): SkillCandidateMetadata {
263
+ const name = input.name?.trim();
264
+ if (!name || name.length > MAX_SKILL_NAME_CHARS) {
265
+ throw new SkillLifecycleError("invalid_skill_name", `skill name must be 1-${MAX_SKILL_NAME_CHARS} characters`);
266
+ }
267
+ const rawContent = redactSecrets(input.content ?? "");
268
+ if (!rawContent.trim() || rawContent.length > MAX_SKILL_CONTENT_CHARS) {
269
+ throw new SkillLifecycleError("invalid_skill_content", `skill content must be 1-${MAX_SKILL_CONTENT_CHARS} characters`);
270
+ }
271
+ const rawDesc = redactSecrets(input.description?.trim() ?? "");
272
+ const content = ensureSkillFrontmatter(rawContent, name, rawDesc);
273
+ if (content.length > MAX_SKILL_CONTENT_CHARS) {
274
+ throw new SkillLifecycleError("invalid_skill_content", `skill content must be 1-${MAX_SKILL_CONTENT_CHARS} characters`);
275
+ }
276
+ const { frontmatter } = parseSkillFrontmatter(content);
277
+ const description = (typeof frontmatter?.description === "string" && frontmatter.description.trim())
278
+ ? frontmatter.description.trim()
279
+ : (rawDesc || `Governed skill for ${name}`);
280
+ const sources: SkillSources = {
281
+ runIds: boundedList(input.sources?.runIds, "runIds"),
282
+ journalEntryIds: boundedList(input.sources?.journalEntryIds, "journalEntryIds"),
283
+ evidenceReceipts: boundedList(input.sources?.evidenceReceipts, "evidenceReceipts"),
284
+ };
285
+ if (sources.runIds.length === 0 && sources.journalEntryIds.length === 0 && sources.evidenceReceipts.length === 0) {
286
+ throw new SkillLifecycleError(
287
+ "skill_sources_required",
288
+ "a skill candidate must reference at least one source run, journal entry, or evidence receipt",
289
+ );
290
+ }
291
+ const createdBy = input.createdBy?.trim();
292
+ if (!createdBy) throw new SkillLifecycleError("invalid_skill_author", "createdBy is required");
293
+ const models = boundedList(input.compatibility?.models, "compatibility.models");
294
+ if (models.length === 0 || models.length > MAX_SKILL_MODELS) {
295
+ throw new SkillLifecycleError("invalid_skill_compatibility", `compatibility.models must list 1-${MAX_SKILL_MODELS} models`);
296
+ }
297
+ const harness = input.compatibility?.harness?.trim();
298
+ if (!harness) throw new SkillLifecycleError("invalid_skill_compatibility", "compatibility.harness is required");
299
+
300
+ const contentSha256 = skillContentSha256(content);
301
+ const id = skillIdFor(name, contentSha256);
302
+ const { dir, metadata, skill } = this.paths("candidate", id);
303
+ if (existsSync(metadata)) {
304
+ throw new SkillLifecycleError(
305
+ "skill_candidate_exists",
306
+ `identical candidate ${id} already exists; changed behavior requires changed content`,
307
+ );
308
+ }
309
+ const record: SkillCandidateMetadata = {
310
+ schema: SKILL_CANDIDATE_SCHEMA,
311
+ id,
312
+ name,
313
+ description,
314
+ contentSha256,
315
+ version: input.version ?? 1,
316
+ sources,
317
+ compatibility: { harness, models },
318
+ createdBy,
319
+ createdAt: this.now(),
320
+ ...(input.supersedes ? { supersedes: input.supersedes } : {}),
321
+ };
322
+ mkdirSync(dir, { recursive: true });
323
+ writeFileSync(skill, content);
324
+ writeFileSync(metadata, `${JSON.stringify(record, null, 2)}\n`);
325
+ this.appendHistory(id, { schema: "kxm.skill-history-event.v1", event: "candidate_created", by: createdBy, supersedes: input.supersedes, at: record.createdAt });
326
+ return record;
327
+ }
328
+
329
+ /** Record a protected evaluation. A failed functional or safety evaluation
330
+ * deterministically quarantines the candidate. */
331
+ evaluate(candidateId: string, input: {
332
+ kind: SkillEvaluationKind;
333
+ evaluatorVersion: string;
334
+ passed: boolean;
335
+ score?: number;
336
+ details?: string;
337
+ evaluatedBy?: string;
338
+ }): { evaluation: SkillEvaluationRecord; quarantined: boolean } {
339
+ if (input.kind === "optimization" && !this.allowOptimizationEvals) {
340
+ throw new SkillLifecycleError(
341
+ "skill_optimization_disabled",
342
+ "optimization evaluations are disabled; enable them explicitly behind protected evals",
343
+ );
344
+ }
345
+ const metadata = this.readMetadata("candidate", candidateId);
346
+ const evaluatorVersion = input.evaluatorVersion?.trim();
347
+ if (!evaluatorVersion) throw new SkillLifecycleError("invalid_skill_evaluation", "evaluatorVersion is required");
348
+ const evaluation: SkillEvaluationRecord = {
349
+ schema: SKILL_EVALUATION_SCHEMA,
350
+ candidateId,
351
+ kind: input.kind,
352
+ evaluatorVersion,
353
+ passed: input.passed === true,
354
+ ...(input.score !== undefined ? { score: input.score } : {}),
355
+ ...(input.details ? { details: redactSecrets(input.details.slice(0, 2_000)) } : {}),
356
+ evaluatedAt: this.now(),
357
+ };
358
+ this.appendHistory(candidateId, evaluation);
359
+ let quarantined = false;
360
+ if (!evaluation.passed && (input.kind === "functional" || input.kind === "safety")) {
361
+ // A functional regression or safety failure quarantines automatically:
362
+ // the evaluator records the decision, a human may later reject fully.
363
+ const decision: SkillDecisionRecord = {
364
+ schema: SKILL_DECISION_SCHEMA,
365
+ candidateId,
366
+ decision: "quarantined",
367
+ decidedBy: input.evaluatedBy?.trim() || `evaluator:${evaluatorVersion}`,
368
+ reason: `automatic quarantine: ${input.kind} evaluation failed (${evaluatorVersion})`,
369
+ evidenceRefs: [`evaluation:${input.kind}:${evaluatorVersion}`],
370
+ decidedAt: this.now(),
371
+ };
372
+ this.move("candidate", "quarantined", candidateId);
373
+ this.appendHistory(candidateId, decision);
374
+ quarantined = true;
375
+ }
376
+ return { evaluation, quarantined };
377
+ }
378
+
379
+ private evaluationsFor(candidateId: string): SkillEvaluationRecord[] {
380
+ return this.history(candidateId).filter(
381
+ (record): record is SkillEvaluationRecord =>
382
+ (record as SkillEvaluationRecord).schema === SKILL_EVALUATION_SCHEMA,
383
+ );
384
+ }
385
+
386
+ /** Promote a candidate that passed every protected evaluation. The
387
+ * promoter must differ from the author, cite durable evidence, and the
388
+ * promoted content is hash-pinned and immutable. Emits a unified diff patch
389
+ * instead of moving the candidate directory. */
390
+ promote(candidateId: string, decision: {
391
+ decidedBy: string;
392
+ reason: string;
393
+ evidenceRefs: string[];
394
+ }): SkillCandidateMetadata & { patch: string; patchPath: string } {
395
+ const metadata = this.readMetadata("candidate", candidateId);
396
+ const decidedBy = decision.decidedBy?.trim();
397
+ if (!decidedBy) throw new SkillLifecycleError("invalid_skill_decision", "decidedBy is required");
398
+ if (decidedBy === metadata.createdBy) {
399
+ throw new SkillLifecycleError("skill_promotion_invalid", "the author of a skill candidate cannot promote it");
400
+ }
401
+ const evidenceRefs = boundedList(decision.evidenceRefs, "evidenceRefs");
402
+ if (evidenceRefs.length === 0) {
403
+ throw new SkillLifecycleError("skill_promotion_invalid", "promotion requires durable evidence references");
404
+ }
405
+ const evaluations = this.evaluationsFor(candidateId);
406
+ const missing: string[] = [];
407
+ for (const kind of PROMOTION_REQUIRED_EVALUATIONS) {
408
+ const latest = [...evaluations].reverse().find((record) => record.kind === kind);
409
+ if (!latest || !latest.passed) missing.push(kind);
410
+ }
411
+ if (missing.length > 0) {
412
+ throw new SkillLifecycleError(
413
+ "skill_evaluations_incomplete",
414
+ `promotion requires passing ${missing.join(", ")} evaluations`,
415
+ );
416
+ }
417
+ // Integrity check before promotion: content on disk matches the hash.
418
+ this.verify("candidate", candidateId);
419
+ const record: SkillDecisionRecord = {
420
+ schema: SKILL_DECISION_SCHEMA,
421
+ candidateId,
422
+ decision: "promoted",
423
+ decidedBy,
424
+ reason: decision.reason?.trim() || "passed protected evaluation",
425
+ evidenceRefs,
426
+ decidedAt: this.now(),
427
+ };
428
+
429
+ // Instead of moving directory, write promoted directory and generate patch
430
+ const candidatePaths = this.paths("candidate", candidateId);
431
+ const promotedPaths = this.paths("promoted", candidateId);
432
+ mkdirSync(promotedPaths.dir, { recursive: true });
433
+
434
+ const skillContent = readFileSync(candidatePaths.skill, "utf8");
435
+ const metadataContent = readFileSync(candidatePaths.metadata, "utf8");
436
+ writeFileSync(promotedPaths.skill, skillContent);
437
+ writeFileSync(promotedPaths.metadata, metadataContent);
438
+
439
+ const patchesDir = join(this.root, "patches");
440
+ mkdirSync(patchesDir, { recursive: true });
441
+ const patchPath = join(patchesDir, `${candidateId}.patch`);
442
+ const relSkillPath = `.kxm/skills/promoted/${candidateId}/SKILL.md`;
443
+ const relMetaPath = `.kxm/skills/promoted/${candidateId}/metadata.json`;
444
+ const patch = `${createUnifiedPatch(relSkillPath, skillContent)}${createUnifiedPatch(relMetaPath, metadataContent)}`;
445
+ writeFileSync(patchPath, patch, "utf8");
446
+
447
+ this.appendHistory(candidateId, record);
448
+ return { ...metadata, patch, patchPath };
449
+ }
450
+
451
+ reject(candidateId: string, decision: { decidedBy: string; reason: string }): SkillCandidateMetadata {
452
+ const metadata = this.readMetadata("candidate", candidateId);
453
+ const record: SkillDecisionRecord = {
454
+ schema: SKILL_DECISION_SCHEMA,
455
+ candidateId,
456
+ decision: "rejected",
457
+ decidedBy: decision.decidedBy?.trim() || "kxm-admin",
458
+ reason: decision.reason?.trim() || "rejected",
459
+ evidenceRefs: [],
460
+ decidedAt: this.now(),
461
+ };
462
+ // Rejected candidates remain in history for future learning; the
463
+ // candidate directory is removed and its full lineage stays queryable.
464
+ this.move("candidate", "rejected", candidateId);
465
+ this.appendHistory(candidateId, record);
466
+ return metadata;
467
+ }
468
+
469
+ /** Verify content integrity of a stored skill (any state). Detects
470
+ * out-of-band edits to promoted skills and verifies standard YAML frontmatter. */
471
+ verify(state: SkillState, id: string): SkillCandidateMetadata {
472
+ const metadata = this.readMetadata(state, id);
473
+ const { skill } = this.paths(state, id);
474
+ const content = readFileSync(skill, "utf8");
475
+ if (skillContentSha256(content) !== metadata.contentSha256) {
476
+ throw new SkillLifecycleError(
477
+ "skill_integrity_violation",
478
+ `skill ${id} content does not match its pinned hash; promoted skills are immutable and require a new candidate/eval cycle`,
479
+ );
480
+ }
481
+ const { frontmatter } = parseSkillFrontmatter(content);
482
+ if (
483
+ !frontmatter ||
484
+ typeof frontmatter.name !== "string" ||
485
+ !frontmatter.name.trim() ||
486
+ typeof frontmatter.description !== "string" ||
487
+ !frontmatter.description.trim()
488
+ ) {
489
+ throw new SkillLifecycleError(
490
+ "invalid_skill_frontmatter",
491
+ `skill ${id} must contain valid YAML frontmatter with 'name' and 'description'`,
492
+ );
493
+ }
494
+ return metadata;
495
+ }
496
+
497
+ list(state: SkillState): SkillCandidateMetadata[] {
498
+ const dir = this.dir(state);
499
+ if (!existsSync(dir)) return [];
500
+ const ids = readdirSorted(dir);
501
+ return ids
502
+ .map((id) => {
503
+ try {
504
+ return this.readMetadata(state, id);
505
+ } catch {
506
+ return undefined;
507
+ }
508
+ })
509
+ .filter((metadata): metadata is SkillCandidateMetadata => metadata !== undefined);
510
+ }
511
+
512
+ read(state: SkillState, id: string): { metadata: SkillCandidateMetadata; content: string } {
513
+ const metadata = this.readMetadata(state, id);
514
+ const { skill } = this.paths(state, id);
515
+ return { metadata, content: readFileSync(skill, "utf8") };
516
+ }
517
+ }
518
+
519
+ function boundedList(value: string[] | undefined, field: string): string[] {
520
+ if (value === undefined) return [];
521
+ if (!Array.isArray(value)) {
522
+ throw new SkillLifecycleError("invalid_skill_input", `${field} must be an array of strings`);
523
+ }
524
+ const refs = value.map((ref) => String(ref).trim()).filter((ref) => ref.length > 0);
525
+ if (refs.length > MAX_SKILL_EVIDENCE_REFS) {
526
+ throw new SkillLifecycleError("invalid_skill_input", `${field} exceeds ${MAX_SKILL_EVIDENCE_REFS} references`);
527
+ }
528
+ return [...new Set(refs)];
529
+ }
530
+
531
+ function readdirSorted(dir: string): string[] {
532
+ return readdirSync(dir)
533
+ .filter((entry) => statSync(join(dir, entry)).isDirectory())
534
+ .sort();
535
+ }