@klhapp/skillmux 1.1.0 → 1.2.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/CHANGELOG.md CHANGED
@@ -5,6 +5,16 @@ All notable changes to this project are documented in this file.
5
5
  The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
6
6
  and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
7
 
8
+ ## [1.2.0](https://github.com/klhq/skillmux/compare/v1.1.0...v1.2.0) (2026-07-28)
9
+
10
+
11
+ ### Added
12
+
13
+ * **calibration:** finish remote contract and docs ([f696da2](https://github.com/klhq/skillmux/commit/f696da25094c19537b1291f694114f8e4052ea69))
14
+ * **calibration:** improve dataset quality ([#88](https://github.com/klhq/skillmux/issues/88)) ([e99b764](https://github.com/klhq/skillmux/commit/e99b764ce90b7432fca0b01c22560ba73b401dd2))
15
+ * **calibration:** require labelled audit feedback ([16d9ec8](https://github.com/klhq/skillmux/commit/16d9ec81acbdca0e42a401b9e28f9e3814b3e0ab))
16
+ * **calibration:** require labelled audit feedback ([#89](https://github.com/klhq/skillmux/issues/89)) ([a7a99e2](https://github.com/klhq/skillmux/commit/a7a99e2d82e619255135b1e941c69c8d6653012c))
17
+
8
18
  ## [1.1.0](https://github.com/klhq/skillmux/compare/v1.0.1...v1.1.0) (2026-07-28)
9
19
 
10
20
 
package/README.md CHANGED
@@ -14,6 +14,7 @@ Built for agents that lack native skill triggering (Goose recipe workers, openco
14
14
  - [Docker Usage](#docker-usage)
15
15
  - [Configuration](#configuration) — inference modes, security scanning, installing skills, env vars
16
16
  - [CLI & Automation](docs/cli.md) — context management, remote target resolution, policy calibration, JSON envelopes
17
+ - [Policy Calibration](docs/calibration.md) — labelled datasets, certification, apply lifecycle, and reference profile
17
18
  - [Benchmarks & Evaluation](#benchmarks--evaluation)
18
19
  - [FAQ & Troubleshooting](#faq--troubleshooting)
19
20
  - [Guarantees](#guarantees)
@@ -546,7 +547,11 @@ bun run src/cli.ts eval
546
547
  # hybrid recall@5: 1.000
547
548
  ```
548
549
 
549
- Custom policy calibration can also be performed against domain-specific query logs using `skillmux calibrate` (see [`docs/cli.md`](docs/cli.md#policy-calibration-skillmux-calibrate)).
550
+ Custom policy calibration can also be performed against reviewed,
551
+ domain-specific query datasets using `skillmux calibrate`. See the
552
+ [calibration guide](docs/calibration.md) for the full operator lifecycle and
553
+ the [CLI reference](docs/cli.md#policy-calibration-skillmux-calibrate) for
554
+ command syntax.
550
555
 
551
556
  ## FAQ & Troubleshooting
552
557
 
@@ -18,7 +18,9 @@ endpoint = "https://reranker.example.com/v1/rerank"
18
18
  model = "your-reranker-model"
19
19
  api_key_env = "RERANKER_API_KEY"
20
20
 
21
- # Required when a reranker is configured. Calibrate these for your model.
21
+ # Optional. Without these values, reranked results remain ambiguous.
22
+ # Calibrate locally against your corpus before enabling automatic matches.
23
+ # See docs/calibration.md; these example values are not universal defaults.
22
24
  [inference.thresholds]
23
25
  match_score = 0.90
24
26
  match_margin = 0.30
@@ -0,0 +1,106 @@
1
+ # Policy calibration
2
+
3
+ Calibration selects the three reranker-score thresholds that turn an ordered
4
+ shortlist into `matched`, `ambiguous`, or `no_match`. It is an operator action,
5
+ not background learning, and it currently runs only against a local Skillmux
6
+ target.
7
+
8
+ ## Lifecycle
9
+
10
+ The complete workflow is:
11
+
12
+ ```text
13
+ install CLI → configure vault/index/embedding/reranker → obtain labelled dataset
14
+ → calibrate run → review calibrate show RUN_ID → calibrate apply RUN_ID
15
+ → live-reloaded policy handles subsequent requests
16
+ ```
17
+
18
+ First configure and index the same vault, embedding model, and reranker that
19
+ will serve requests. Supply a reviewed dataset, or generate a starting point
20
+ and review every label:
21
+
22
+ ```sh
23
+ skillmux calibrate generate-dataset --out ./eval/queries.json
24
+ skillmux calibrate run --dataset ./eval/queries.json
25
+ skillmux calibrate show RUN_ID
26
+ skillmux calibrate apply RUN_ID
27
+ ```
28
+
29
+ Skillmux retrieves candidates and reranks exactly once for each evaluation
30
+ query. It caches those observations, searches thresholds on the `tune` split,
31
+ then certifies the selected policy on the frozen `test` split. Calibration
32
+ starts only when an operator invokes `calibrate run`.
33
+
34
+ The operator owns the labels: supply or review the cases, start the run,
35
+ inspect its evidence, and explicitly apply an acceptable result. A successful
36
+ run never changes live thresholds by itself.
37
+
38
+ ## Reading a run
39
+
40
+ A `run_id` identifies one immutable calibration attempt and its evidence.
41
+ `calibrate show RUN_ID` is read-only. It reports:
42
+
43
+ - selected thresholds and tune/test metrics;
44
+ - auto-match precision confidence and sample counts;
45
+ - retrieval and delivered-shortlist recall;
46
+ - a closed failure reason when certification fails;
47
+ - reranker, embedding, corpus, and dataset fingerprints;
48
+ - dataset provenance and the number of human-labelled cases; and
49
+ - the attempt count for the dataset hash.
50
+
51
+ `calibrate apply RUN_ID` accepts only a completed, test-certified run. It
52
+ rechecks the reranker fingerprint, rejects thresholds masked by environment
53
+ variables, atomically updates the TOML file, and lets the config watcher
54
+ activate the new snapshot.
55
+
56
+ ## Dataset responsibilities
57
+
58
+ Each case needs a query, expected outcome, relevant skill ids, and a fixed
59
+ `tune` or `test` split. Unknown skill ids are rejected. Keep a skill entirely
60
+ within one split so the test set measures generalization rather than memorized
61
+ skill wording.
62
+
63
+ Generated datasets are scaffolding, not ground truth. Review paraphrases,
64
+ near-miss negatives, and ambiguous cases before using them for certification.
65
+ Audit-derived cases require an explicit human label and provenance. Raw audit
66
+ queries are excluded unless the importer is deliberately configured to retain
67
+ them.
68
+
69
+ ## When to recalibrate
70
+
71
+ Re-run calibration after a material change to the corpus, embedding or
72
+ retrieval behavior, reranker adapter or model, or after collecting enough new
73
+ human-labelled feedback. Do not recalibrate per user request. Every rerun gets
74
+ a new `run_id`; the active policy remains unchanged until one is applied.
75
+
76
+ ## Local and remote targets
77
+
78
+ Calibration is local-only in this release. Local commands operate on the
79
+ configured local vault, index, inference endpoints, dataset path, evidence
80
+ database, and TOML file. Human output always prints `Target: local`; JSON output
81
+ uses `"target": "local"`.
82
+
83
+ Remote servers advertise `"calibration": false`. Every
84
+ `/admin/v1/calibrations` route returns HTTP `501` with
85
+ `error: "not_implemented"`, and the CLI rejects remote calibration before
86
+ uploading or claiming to execute a local dataset path. This also prevents raw
87
+ evaluation queries from being exposed through the admin API.
88
+
89
+ ## Reference starting profile
90
+
91
+ Reranker scores are not portable across models, adapters, model revisions, or
92
+ corpora. The profile below is published only to make the checked-in BGE example
93
+ concrete; it is not a certified substitute for calibration.
94
+
95
+ | Model | Adapter | `match_score` | `match_margin` | `candidate_floor` |
96
+ |---|---|---:|---:|---:|
97
+ | `BAAI/bge-reranker-v2-m3` | `jina-v1` | `0.90` | `0.20` | `0.40` |
98
+
99
+ Provenance: the small synthetic corpus and labelled decision cases in
100
+ [`tests/router-core.spec.test.ts`](../tests/router-core.spec.test.ts), with the
101
+ wire contract captured by
102
+ [`tests/fixtures/reranker/jina-v1-request.json`](../tests/fixtures/reranker/jina-v1-request.json).
103
+ That fixture is below the default 30-auto-match certification minimum, so the
104
+ values are a smoke-test/reference profile, not a completed calibration run.
105
+ Run the lifecycle above against the deployment's real corpus before enabling
106
+ automatic matches in production.
@@ -2,7 +2,10 @@
2
2
 
3
3
  Skillmux defaults to FTS5 plus local GTE-small semantic retrieval. Most users need no config file.
4
4
 
5
- For detailed CLI command reference, target resolution, policy calibration, and automation envelopes, see [`docs/cli.md`](cli.md).
5
+ For detailed CLI command reference, target resolution, and automation
6
+ envelopes, see [`docs/cli.md`](cli.md). For labelled datasets, threshold
7
+ certification, reference values, and the apply lifecycle, see
8
+ [`docs/calibration.md`](calibration.md).
6
9
 
7
10
  ## Machine config bootstrap
8
11
 
@@ -105,7 +108,13 @@ candidate list returned to the calling LLM after retrieval, reranking, and
105
108
  threshold filtering. It does not change retrieval depth or the matched,
106
109
  ambiguous, or no-match classification.
107
110
 
108
- Reranker thresholds have no universal default because score distributions are model-specific. When configuring a reranker, provide calibrated `inference.thresholds.match_score`, `inference.thresholds.match_margin`, and `inference.thresholds.candidate_floor`; otherwise configuration is rejected rather than silently applying unsuitable values.
111
+ Reranker thresholds have no universal default because score distributions are
112
+ model-specific. Without `inference.thresholds`, Skillmux still uses the
113
+ reranker to order candidates but keeps outcomes ambiguous rather than
114
+ auto-matching. Use `skillmux calibrate run` to select
115
+ `match_score`, `match_margin`, and `candidate_floor`, then explicitly apply the
116
+ certified run. The [calibration guide](calibration.md) also publishes a
117
+ clearly-scoped BGE reference profile for smoke tests.
109
118
 
110
119
  ## HTTP server
111
120
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@klhapp/skillmux",
3
- "version": "1.1.0",
3
+ "version": "1.2.0",
4
4
  "description": "Local read-only MCP server routing natural-language task queries to skills in a SKILL.md vault, with zero-loss delivery",
5
5
  "type": "module",
6
6
  "private": false,
@@ -24,6 +24,7 @@
24
24
  "src",
25
25
  "docs/schema.json",
26
26
  "docs/configuration.md",
27
+ "docs/calibration.md",
27
28
  "docs/releasing.md",
28
29
  "README.md",
29
30
  "LICENSE",
package/src/adapters.ts CHANGED
@@ -1,6 +1,6 @@
1
1
  import { createHash } from "node:crypto";
2
2
  import { join } from "node:path";
3
- import { applyCalibrationRun, getCalibrationRun, insertCalibrationRun, listCalibrationRuns, loadDecisionCasesFromFile, openCalibrateDb, runCalibration, type CalibrationResult } from "./calibrate";
3
+ import { applyCalibrationRun, getCalibrationRun, insertCalibrationRun, listCalibrationRuns, loadDecisionCasesFromFile, openCalibrateDb, runCalibration, summarizeDatasetProvenance, type CalibrationResult } from "./calibrate";
4
4
  import { createClients } from "./clients";
5
5
  import { DEFAULT_CONFIG_PATH, embeddingFingerprint, expandHome, loadConfig, rerankerFingerprint } from "./config";
6
6
  import { openIndex } from "./db";
@@ -139,7 +139,19 @@ export class LocalAdapter implements TargetAdapter {
139
139
  }): Promise<{ run_id?: string; result?: CalibrationResult }> {
140
140
  const config = await loadConfig(this.configPath);
141
141
  const datasetFile = opts?.datasetPath ?? join(expandHome(config.state_dir), "queries.json");
142
- const cases = loadDecisionCasesFromFile(datasetFile);
142
+ const indexDb = openIndex(expandHome(config.state_dir));
143
+ let indexedSkills: Array<{ skill_id: string; content_sha256: string }>;
144
+ try {
145
+ indexedSkills = indexDb
146
+ .query("SELECT skill_id, content_sha256 FROM skills ORDER BY skill_id")
147
+ .all() as Array<{ skill_id: string; content_sha256: string }>;
148
+ } finally {
149
+ indexDb.close();
150
+ }
151
+ const cases = loadDecisionCasesFromFile(
152
+ datasetFile,
153
+ indexedSkills.map((skill) => skill.skill_id),
154
+ );
143
155
  const clients = createClients(config);
144
156
  configure({ config, clients });
145
157
  const result = await runCalibration({
@@ -168,18 +180,9 @@ export class LocalAdapter implements TargetAdapter {
168
180
  throw new Error("A configured remote reranker is required to record calibration.");
169
181
  }
170
182
  const datasetText = await Bun.file(datasetFile).text();
171
- const indexDb = openIndex(expandHome(config.state_dir));
172
- let corpusFingerprint: string;
173
- try {
174
- const rows = indexDb
175
- .query("SELECT skill_id, content_sha256 FROM skills ORDER BY skill_id")
176
- .all();
177
- corpusFingerprint =
178
- "vault:" +
179
- createHash("sha256").update(JSON.stringify(rows)).digest("hex");
180
- } finally {
181
- indexDb.close();
182
- }
183
+ const corpusFingerprint =
184
+ "vault:" +
185
+ createHash("sha256").update(JSON.stringify(indexedSkills)).digest("hex");
183
186
  const runId = `run_${crypto.randomUUID()}`;
184
187
  const db = openCalibrateDb(expandHome(config.state_dir));
185
188
  try {
@@ -191,6 +194,7 @@ export class LocalAdapter implements TargetAdapter {
191
194
  embedding_fingerprint: embeddingFingerprint(config),
192
195
  corpus_fingerprint: corpusFingerprint,
193
196
  dataset_hash: createHash("sha256").update(datasetText).digest("hex"),
197
+ dataset_provenance: summarizeDatasetProvenance(cases),
194
198
  candidate_limit: config.thresholds.candidate_limit,
195
199
  min_auto_match_precision: opts?.minAutoMatchPrecision ?? 0.99,
196
200
  min_auto_match_count: opts?.minAutoMatchCount ?? 30,
@@ -420,41 +424,30 @@ export class RemoteAdapter implements TargetAdapter {
420
424
  minDeliveredShortlistRecallAtK?: number;
421
425
  minAutoMatchCount?: number;
422
426
  }): Promise<{ run_id?: string; result?: CalibrationResult }> {
423
- const { status, data } = await this.fetchJson("/admin/v1/calibrations", {
424
- method: "POST",
425
- headers: { "Content-Type": "application/json" },
426
- body: JSON.stringify({
427
- dataset_path: opts?.datasetPath,
428
- min_auto_match_precision: opts?.minAutoMatchPrecision,
429
- min_retrieval_recall_at_k: opts?.minRetrievalRecallAtK,
430
- min_delivered_shortlist_recall_at_k: opts?.minDeliveredShortlistRecallAtK,
431
- min_auto_match_count: opts?.minAutoMatchCount,
432
- }),
433
- });
434
- if (status !== 202) {
435
- throw new Error(`Remote calibration start failed (${status}): ${data?.message || data}`);
436
- }
437
- return data;
427
+ void opts;
428
+ throw this.remoteCalibrationNotImplemented();
438
429
  }
439
430
 
440
431
  async calibrateList(): Promise<any[]> {
441
- const { status, data } = await this.fetchJson("/admin/v1/calibrations");
442
- if (status !== 200) throw new Error(`Remote calibration list failed (${status}): ${data?.message || data}`);
443
- return data;
432
+ throw this.remoteCalibrationNotImplemented();
444
433
  }
445
434
 
446
435
  async calibrateShow(runId: string): Promise<any> {
447
- const { status, data } = await this.fetchJson(`/admin/v1/calibrations/${runId}`);
448
- if (status !== 200) throw new Error(`Remote calibration show failed (${status}): ${data?.message || data}`);
449
- return data;
436
+ void runId;
437
+ throw this.remoteCalibrationNotImplemented();
450
438
  }
451
439
 
452
440
  async calibrateApply(runId: string): Promise<any> {
453
- const { status, data } = await this.fetchJson(`/admin/v1/calibrations/${runId}/apply`, {
454
- method: "POST",
455
- });
456
- if (status !== 200) throw new Error(`Remote calibration apply failed (${status}): ${data?.message || data}`);
457
- return data;
441
+ void runId;
442
+ throw this.remoteCalibrationNotImplemented();
443
+ }
444
+
445
+ private remoteCalibrationNotImplemented(): CliError {
446
+ return new CliError(
447
+ `Remote calibration is not implemented for target ${this.serverUrl}; ` +
448
+ "run `skillmux calibrate` against a local target.",
449
+ 2,
450
+ );
458
451
  }
459
452
  }
460
453
 
package/src/calibrate.ts CHANGED
@@ -3,7 +3,7 @@ import { join } from "node:path";
3
3
  import { Database } from "bun:sqlite";
4
4
  import { z } from "zod";
5
5
  import { decideResolveOutcome } from "./decision";
6
- import type { RankedCandidate } from "./types";
6
+ import type { AuditRow, RankedCandidate } from "./types";
7
7
 
8
8
  export { generateDataset, type GenerateDatasetOptions } from "./dataset-generator";
9
9
 
@@ -15,11 +15,21 @@ export { generateDataset, type GenerateDatasetOptions } from "./dataset-generato
15
15
  export type DecisionSplit = "tune" | "test";
16
16
  export type DecisionOutcome = "matched" | "ambiguous" | "no_match";
17
17
 
18
+ export interface DecisionCaseProvenance {
19
+ version: 1;
20
+ source: "authored" | "audit_import";
21
+ review_status: "human_labelled" | "unreviewed";
22
+ query_storage: "raw" | "redacted";
23
+ audit_id?: number;
24
+ labelled_at?: string;
25
+ }
26
+
18
27
  export interface DecisionCase {
19
28
  query: string;
20
29
  split: DecisionSplit;
21
30
  expected_outcome: DecisionOutcome;
22
31
  relevant_skill_ids: string[];
32
+ provenance?: DecisionCaseProvenance;
23
33
  }
24
34
 
25
35
  // ---------------------------------------------------------------------------
@@ -31,6 +41,14 @@ const rawCaseSchema = z.object({
31
41
  split: z.enum(["tune", "test"]),
32
42
  expected_outcome: z.enum(["matched", "ambiguous", "no_match"]),
33
43
  relevant_skill_ids: z.array(z.string()),
44
+ provenance: z.object({
45
+ version: z.literal(1),
46
+ source: z.enum(["authored", "audit_import"]),
47
+ review_status: z.enum(["human_labelled", "unreviewed"]),
48
+ query_storage: z.enum(["raw", "redacted"]),
49
+ audit_id: z.number().int().positive().optional(),
50
+ labelled_at: z.string().datetime().optional(),
51
+ }).strict().optional(),
34
52
  }).strict();
35
53
 
36
54
  type RawCase = z.infer<typeof rawCaseSchema>;
@@ -45,6 +63,25 @@ function validateCase(raw: RawCase, idx: number): DecisionCase {
45
63
  }
46
64
 
47
65
  const { expected_outcome, relevant_skill_ids } = raw;
66
+ const provenance: DecisionCaseProvenance = raw.provenance ?? {
67
+ version: 1,
68
+ source: "authored",
69
+ review_status: "human_labelled",
70
+ query_storage: "raw",
71
+ };
72
+
73
+ if (provenance.source === "audit_import") {
74
+ if (provenance.audit_id === undefined) {
75
+ throw new Error(
76
+ `Validation error at case ${idx}: imported field "provenance.audit_id" is required`,
77
+ );
78
+ }
79
+ if (provenance.review_status !== "human_labelled" || !provenance.labelled_at) {
80
+ throw new Error(
81
+ `Validation error at case ${idx}: imported audit case is unreviewed; human label and "provenance.labelled_at" are required for certification`,
82
+ );
83
+ }
84
+ }
48
85
 
49
86
  if (expected_outcome === "matched") {
50
87
  if (relevant_skill_ids.length !== 1) {
@@ -67,7 +104,85 @@ function validateCase(raw: RawCase, idx: number): DecisionCase {
67
104
  }
68
105
  }
69
106
 
70
- return raw as DecisionCase;
107
+ return { ...raw, provenance };
108
+ }
109
+
110
+ export interface AuditFeedbackLabel {
111
+ split: DecisionSplit;
112
+ expected_outcome: DecisionOutcome;
113
+ relevant_skill_ids: string[];
114
+ labelled_at: string;
115
+ }
116
+
117
+ export type AuditQueryPrivacy =
118
+ | { include_raw_query: true }
119
+ | { include_raw_query: false; redacted_query: string };
120
+
121
+ /** Import an audit outcome only after a separate human label is supplied. */
122
+ export function importLabelledAuditCase(
123
+ audit: AuditRow,
124
+ label: AuditFeedbackLabel,
125
+ privacy: AuditQueryPrivacy,
126
+ ): DecisionCase {
127
+ const query = privacy.include_raw_query ? audit.query : privacy.redacted_query.trim();
128
+ if (!query) {
129
+ throw new Error("A non-empty redacted_query is required when raw audit queries are excluded");
130
+ }
131
+ const parsed = rawCaseSchema.parse({
132
+ query,
133
+ split: label.split,
134
+ expected_outcome: label.expected_outcome,
135
+ relevant_skill_ids: label.relevant_skill_ids,
136
+ provenance: {
137
+ version: 1,
138
+ source: "audit_import",
139
+ review_status: "human_labelled",
140
+ query_storage: privacy.include_raw_query ? "raw" : "redacted",
141
+ audit_id: audit.id,
142
+ labelled_at: label.labelled_at,
143
+ },
144
+ });
145
+ return validateCase(parsed, audit.id);
146
+ }
147
+
148
+ export interface DatasetProvenanceSummary {
149
+ version: 1;
150
+ human_labelled_case_count: number;
151
+ imported_labelled_case_count: number;
152
+ imported_unreviewed_case_count: number;
153
+ raw_query_case_count: number;
154
+ redacted_query_case_count: number;
155
+ }
156
+
157
+ export function summarizeDatasetProvenance(
158
+ cases: DecisionCase[],
159
+ ): DatasetProvenanceSummary {
160
+ const provenance = (item: DecisionCase): DecisionCaseProvenance =>
161
+ item.provenance ?? {
162
+ version: 1,
163
+ source: "authored",
164
+ review_status: "human_labelled",
165
+ query_storage: "raw",
166
+ };
167
+ return {
168
+ version: 1,
169
+ human_labelled_case_count:
170
+ cases.filter((item) => provenance(item).review_status === "human_labelled").length,
171
+ imported_labelled_case_count:
172
+ cases.filter((item) =>
173
+ provenance(item).source === "audit_import" &&
174
+ provenance(item).review_status === "human_labelled"
175
+ ).length,
176
+ imported_unreviewed_case_count:
177
+ cases.filter((item) =>
178
+ provenance(item).source === "audit_import" &&
179
+ provenance(item).review_status === "unreviewed"
180
+ ).length,
181
+ raw_query_case_count:
182
+ cases.filter((item) => provenance(item).query_storage === "raw").length,
183
+ redacted_query_case_count:
184
+ cases.filter((item) => provenance(item).query_storage === "redacted").length,
185
+ };
71
186
  }
72
187
 
73
188
  // ---------------------------------------------------------------------------
@@ -113,8 +228,12 @@ function validateDatasetCompleteness(cases: DecisionCase[]): void {
113
228
  * no_match → 0)
114
229
  * - Dataset completeness (both splits, all outcome types in each split)
115
230
  */
116
- export function loadDecisionCases(raw: unknown[]): DecisionCase[] {
231
+ export function loadDecisionCases(
232
+ raw: unknown[],
233
+ validSkillIds?: Iterable<string>,
234
+ ): DecisionCase[] {
117
235
  const parsed: DecisionCase[] = [];
236
+ const validIds = validSkillIds ? new Set(validSkillIds) : undefined;
118
237
 
119
238
  for (let i = 0; i < raw.length; i++) {
120
239
  const item = raw[i];
@@ -128,7 +247,17 @@ export function loadDecisionCases(raw: unknown[]): DecisionCase[] {
128
247
  );
129
248
  }
130
249
 
131
- parsed.push(validateCase(result.data, i));
250
+ const parsedCase = validateCase(result.data, i);
251
+ if (validIds) {
252
+ for (const skillId of parsedCase.relevant_skill_ids) {
253
+ if (!validIds.has(skillId)) {
254
+ throw new Error(
255
+ `Validation error at case ${i}: field "relevant_skill_ids" references unknown vault skill "${skillId}"`,
256
+ );
257
+ }
258
+ }
259
+ }
260
+ parsed.push(parsedCase);
132
261
  }
133
262
 
134
263
  validateDatasetCompleteness(parsed);
@@ -139,9 +268,12 @@ export function loadDecisionCases(raw: unknown[]): DecisionCase[] {
139
268
  * Read a JSON file from disk and validate it as a decision-policy dataset.
140
269
  * Throws if the file cannot be read or the contents fail validation.
141
270
  */
142
- export function loadDecisionCasesFromFile(path: string): DecisionCase[] {
271
+ export function loadDecisionCasesFromFile(
272
+ path: string,
273
+ validSkillIds?: Iterable<string>,
274
+ ): DecisionCase[] {
143
275
  const raw = JSON.parse(readFileSync(path, "utf8")) as unknown[];
144
- return loadDecisionCases(raw);
276
+ return loadDecisionCases(raw, validSkillIds);
145
277
  }
146
278
 
147
279
  // ---------------------------------------------------------------------------
@@ -734,6 +866,7 @@ export interface CalibrationRunRecord {
734
866
  embedding_fingerprint: string;
735
867
  corpus_fingerprint: string;
736
868
  dataset_hash: string;
869
+ dataset_provenance?: DatasetProvenanceSummary;
737
870
  candidate_limit: number;
738
871
  attempt_count?: number;
739
872
  min_auto_match_precision: number;
@@ -756,6 +889,8 @@ export interface CalibrationRunSummary {
756
889
  embedding_fingerprint: string;
757
890
  corpus_fingerprint: string;
758
891
  dataset_hash: string;
892
+ human_labelled_case_count: number;
893
+ imported_labelled_case_count: number;
759
894
  candidate_limit: number;
760
895
  attempt_count: number;
761
896
  min_auto_match_precision: number;
@@ -806,6 +941,15 @@ export function openCalibrateDb(stateDir: string): Database {
806
941
  if (!columns.some((column) => column.name === "failed_reason")) {
807
942
  db.run("ALTER TABLE calibration_runs ADD COLUMN failed_reason TEXT");
808
943
  }
944
+ if (!columns.some((column) => column.name === "dataset_provenance")) {
945
+ db.run("ALTER TABLE calibration_runs ADD COLUMN dataset_provenance TEXT NOT NULL DEFAULT '{}'");
946
+ }
947
+ if (!columns.some((column) => column.name === "human_labelled_case_count")) {
948
+ db.run("ALTER TABLE calibration_runs ADD COLUMN human_labelled_case_count INTEGER NOT NULL DEFAULT 0");
949
+ }
950
+ if (!columns.some((column) => column.name === "imported_labelled_case_count")) {
951
+ db.run("ALTER TABLE calibration_runs ADD COLUMN imported_labelled_case_count INTEGER NOT NULL DEFAULT 0");
952
+ }
809
953
  return db;
810
954
  }
811
955
 
@@ -822,8 +966,9 @@ export function insertCalibrationRun(db: Database, run: CalibrationRunRecord): v
822
966
  candidate_limit,
823
967
  attempt_count, min_auto_match_precision, min_auto_match_count,
824
968
  min_delivered_shortlist_recall_at_k, min_shortlist_recall_at_5, failed_reason,
825
- selected_thresholds, tune_metrics, test_metrics, observations
826
- ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
969
+ selected_thresholds, tune_metrics, test_metrics, observations,
970
+ dataset_provenance, human_labelled_case_count, imported_labelled_case_count
971
+ ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
827
972
  [
828
973
  run.run_id,
829
974
  run.created_at,
@@ -843,6 +988,9 @@ export function insertCalibrationRun(db: Database, run: CalibrationRunRecord): v
843
988
  run.tune_metrics != null ? JSON.stringify(run.tune_metrics) : null,
844
989
  run.test_metrics != null ? JSON.stringify(run.test_metrics) : null,
845
990
  JSON.stringify(run.observations),
991
+ JSON.stringify(run.dataset_provenance ?? {}),
992
+ run.dataset_provenance?.human_labelled_case_count ?? 0,
993
+ run.dataset_provenance?.imported_labelled_case_count ?? 0,
846
994
  ],
847
995
  );
848
996
  }
@@ -866,6 +1014,9 @@ interface RawCalibrationRow {
866
1014
  tune_metrics: string | null;
867
1015
  test_metrics: string | null;
868
1016
  observations: string;
1017
+ dataset_provenance: string;
1018
+ human_labelled_case_count: number;
1019
+ imported_labelled_case_count: number;
869
1020
  }
870
1021
 
871
1022
  function parseMetrics(json: string): CalibrationMetrics {
@@ -900,6 +1051,10 @@ function rowToRecord(row: RawCalibrationRow): CalibrationRunRecord {
900
1051
  embedding_fingerprint: row.embedding_fingerprint,
901
1052
  corpus_fingerprint: row.corpus_fingerprint,
902
1053
  dataset_hash: row.dataset_hash,
1054
+ dataset_provenance:
1055
+ Object.keys(JSON.parse(row.dataset_provenance) as object).length > 0
1056
+ ? JSON.parse(row.dataset_provenance) as DatasetProvenanceSummary
1057
+ : undefined,
903
1058
  candidate_limit: row.candidate_limit,
904
1059
  attempt_count: row.attempt_count,
905
1060
  min_auto_match_precision: row.min_auto_match_precision,
@@ -939,7 +1094,8 @@ export function listCalibrationRuns(db: Database): CalibrationRunSummary[] {
939
1094
  reranker_fingerprint, embedding_fingerprint, corpus_fingerprint, dataset_hash,
940
1095
  candidate_limit,
941
1096
  attempt_count, min_auto_match_precision, min_auto_match_count,
942
- min_delivered_shortlist_recall_at_k, min_shortlist_recall_at_5, failed_reason
1097
+ min_delivered_shortlist_recall_at_k, min_shortlist_recall_at_5, failed_reason,
1098
+ human_labelled_case_count, imported_labelled_case_count
943
1099
  FROM calibration_runs ORDER BY created_at DESC`,
944
1100
  )
945
1101
  .all() as CalibrationRunSummary[];
package/src/cli.ts CHANGED
@@ -446,7 +446,7 @@ async function handleCalibrateCommand(
446
446
  minAutoMatchCount,
447
447
  });
448
448
  emitSuccess({ isJson: ctx.isJson, target: ctx.target }, res, () => {
449
- renderTargetBanner(ctx.target);
449
+ renderCalibrationTarget(ctx.target);
450
450
  console.log(`Calibration run complete.`);
451
451
  if (res.result) console.log(JSON.stringify(res.result, null, 2));
452
452
  });
@@ -456,7 +456,7 @@ async function handleCalibrateCommand(
456
456
  if (sub === "list") {
457
457
  const res = await adapter.calibrateList();
458
458
  emitSuccess({ isJson: ctx.isJson, target: ctx.target }, res, () => {
459
- renderTargetBanner(ctx.target);
459
+ renderCalibrationTarget(ctx.target);
460
460
  renderTable(
461
461
  [
462
462
  { key: "run_id", header: "RUN_ID" },
@@ -474,7 +474,7 @@ async function handleCalibrateCommand(
474
474
  if (!runId) throw new Error("usage: skillmux calibrate show <run_id>");
475
475
  const res = await adapter.calibrateShow(runId);
476
476
  emitSuccess({ isJson: ctx.isJson, target: ctx.target }, res, () => {
477
- renderTargetBanner(ctx.target);
477
+ renderCalibrationTarget(ctx.target);
478
478
  console.log(JSON.stringify(res, null, 2));
479
479
  });
480
480
  return;
@@ -485,7 +485,7 @@ async function handleCalibrateCommand(
485
485
  if (!runId) throw new Error("usage: skillmux calibrate apply <run_id>");
486
486
  const res = await adapter.calibrateApply(runId);
487
487
  emitSuccess({ isJson: ctx.isJson, target: ctx.target }, res, () => {
488
- renderTargetBanner(ctx.target);
488
+ renderCalibrationTarget(ctx.target);
489
489
  console.log(`Applied calibration run "${runId}"`);
490
490
  });
491
491
  return;
@@ -501,6 +501,14 @@ async function handleCalibrateCommand(
501
501
  );
502
502
  }
503
503
 
504
+ function renderCalibrationTarget(target: ResolvedTarget): void {
505
+ if (target.type === "local") {
506
+ console.log("Target: local");
507
+ } else {
508
+ console.log(`Target: remote (${target.name} -> ${target.server})`);
509
+ }
510
+ }
511
+
504
512
  async function handleCompletionsCommand(shell: string) {
505
513
  if (shell !== "bash" && shell !== "zsh" && shell !== "fish") {
506
514
  throw new Error("usage: skillmux completions <bash|zsh|fish>");
@@ -13,14 +13,45 @@ export interface GenerateDatasetOptions {
13
13
  queriesPerSplit?: number;
14
14
  }
15
15
 
16
- const GENERIC_NO_MATCH_QUERIES = [
17
- "what is the weather in Paris today",
18
- "recipe for baking sourdough bread at home",
19
- "what is the distance from Earth to Mars",
20
- "explain quantum entanglement simply",
21
- "how do I solve a quadratic equation",
22
- "who won the 1998 World Cup",
23
- ];
16
+ const STOP_WORDS = new Set([
17
+ "a", "an", "and", "are", "as", "at", "be", "by", "for", "from", "in", "is",
18
+ "it", "of", "on", "or", "the", "this", "to", "use", "with",
19
+ ]);
20
+
21
+ function words(value: string): string[] {
22
+ return value
23
+ .toLowerCase()
24
+ .match(/[a-z0-9]+/g)
25
+ ?.filter((word) => word.length > 2 && !STOP_WORDS.has(word)) ?? [];
26
+ }
27
+
28
+ function anchors(skill: VaultSkill): string[] {
29
+ const preferred = [...skill.aliases.flatMap(words), ...words(skill.title)];
30
+ const fallback = words(skill.description);
31
+ return [...new Set([...preferred, ...fallback])].slice(0, 2);
32
+ }
33
+
34
+ function matchedQuery(skill: VaultSkill, variant: number): string {
35
+ const [first = "specialized", second = "workflow"] = anchors(skill);
36
+ const templates = [
37
+ `I need practical guidance completing an unfamiliar ${first} ${second} task safely`,
38
+ `Which available workflow can handle my unusual ${first} ${second} problem end to end`,
39
+ `Please guide me through a difficult unfamiliar ${first} ${second} operation safely`,
40
+ ];
41
+ return templates[variant % templates.length]!;
42
+ }
43
+
44
+ function ambiguousQuery(first: VaultSkill, second: VaultSkill): string {
45
+ const [firstAnchor = "first"] = anchors(first);
46
+ const [secondAnchor = "second"] = anchors(second);
47
+ return `Help with a workflow spanning both ${firstAnchor} and ${secondAnchor} responsibilities`;
48
+ }
49
+
50
+ function nearMissQuery(first: VaultSkill, second: VaultSkill): string {
51
+ const [firstAnchor = "one"] = anchors(first);
52
+ const [secondAnchor = "another"] = anchors(second);
53
+ return `Explain the theory comparing ${firstAnchor} and ${secondAnchor} without performing either workflow`;
54
+ }
24
55
 
25
56
  /**
26
57
  * Automatically generate a synthetic decision-policy calibration dataset
@@ -30,110 +61,58 @@ export function generateDataset(
30
61
  skills: VaultSkill[],
31
62
  options: GenerateDatasetOptions = {},
32
63
  ): RawDecisionCase[] {
33
- const cases: RawDecisionCase[] = [];
64
+ if (skills.length < 4) {
65
+ throw new Error(
66
+ "Dataset generation requires at least 4 vault skills so tune and test can each contain matched and ambiguous cases without skill leakage",
67
+ );
68
+ }
34
69
 
35
- // --- 1. Matched Cases ---
36
- for (const skill of skills) {
37
- // Primary query from title + description
38
- cases.push({
39
- query: `how do I ${skill.title.toLowerCase()}: ${skill.description.toLowerCase()}`,
40
- split: "tune",
41
- expected_outcome: "matched",
42
- relevant_skill_ids: [skill.skill_id],
43
- });
70
+ const cases: RawDecisionCase[] = [];
71
+ const sorted = [...skills].sort((a, b) => a.skill_id.localeCompare(b.skill_id));
72
+ const splitAt = Math.ceil(sorted.length / 2);
73
+ const bySplit: Record<DecisionSplit, VaultSkill[]> = {
74
+ tune: sorted.slice(0, splitAt),
75
+ test: sorted.slice(splitAt),
76
+ };
77
+ const targetPerSplit = Math.max(3, options.queriesPerSplit ?? 10);
44
78
 
45
- // Secondary query from aliases
46
- if (skill.aliases.length > 0) {
47
- cases.push({
48
- query: `help me with ${skill.aliases[0]}`,
49
- split: "test",
50
- expected_outcome: "matched",
51
- relevant_skill_ids: [skill.skill_id],
52
- });
53
- } else {
79
+ for (const split of ["tune", "test"] as const) {
80
+ const splitSkills = bySplit[split];
81
+ for (let i = 0; i < splitSkills.length; i++) {
82
+ const skill = splitSkills[i]!;
54
83
  cases.push({
55
- query: `execute task related to ${skill.title}`,
56
- split: "test",
84
+ query: matchedQuery(skill, i),
85
+ split,
57
86
  expected_outcome: "matched",
58
87
  relevant_skill_ids: [skill.skill_id],
59
88
  });
60
89
  }
61
- }
62
90
 
63
- // --- 2. Ambiguous Cases ---
64
- if (skills.length >= 2) {
65
- // Pair skills for ambiguous multi-match
66
- for (let i = 0; i < skills.length - 1; i += 2) {
67
- const s1 = skills[i]!;
68
- const s2 = skills[i + 1]!;
69
- const split: DecisionSplit = i % 4 === 0 ? "tune" : "test";
70
- cases.push({
71
- query: `automated task using ${s1.title} and ${s2.title}`,
72
- split,
73
- expected_outcome: "ambiguous",
74
- relevant_skill_ids: [s1.skill_id, s2.skill_id],
75
- });
76
- }
77
- } else {
78
- // Fallback ambiguous cases if fewer than 2 skills
79
- cases.push({
80
- query: "automate browser workflow testing",
81
- split: "tune",
82
- expected_outcome: "ambiguous",
83
- relevant_skill_ids: ["mock-e2e", "mock-browser"],
84
- });
85
- cases.push({
86
- query: "extract and fetch clean web text",
87
- split: "test",
88
- expected_outcome: "ambiguous",
89
- relevant_skill_ids: ["mock-fetch", "mock-extract"],
90
- });
91
- }
92
-
93
- // Ensure both tune and test have ambiguous cases
94
- if (!cases.some((c) => c.split === "tune" && c.expected_outcome === "ambiguous")) {
95
- const sIds = skills.length >= 2 ? [skills[0]!.skill_id, skills[1]!.skill_id] : ["mock-a", "mock-b"];
91
+ const first = splitSkills[0]!;
92
+ const second = splitSkills[1]!;
96
93
  cases.push({
97
- query: "integrated workflow multi skill query",
98
- split: "tune",
94
+ query: ambiguousQuery(first, second),
95
+ split,
99
96
  expected_outcome: "ambiguous",
100
- relevant_skill_ids: sIds,
97
+ relevant_skill_ids: [first.skill_id, second.skill_id],
101
98
  });
102
- }
103
- if (!cases.some((c) => c.split === "test" && c.expected_outcome === "ambiguous")) {
104
- const sIds = skills.length >= 2 ? [skills[0]!.skill_id, skills[1]!.skill_id] : ["mock-a", "mock-b"];
105
99
  cases.push({
106
- query: "combined operations multi skill query",
107
- split: "test",
108
- expected_outcome: "ambiguous",
109
- relevant_skill_ids: sIds,
110
- });
111
- }
112
-
113
- // --- 3. No Match Cases ---
114
- GENERIC_NO_MATCH_QUERIES.forEach((q, idx) => {
115
- cases.push({
116
- query: q,
117
- split: idx % 2 === 0 ? "tune" : "test",
100
+ query: nearMissQuery(first, second),
101
+ split,
118
102
  expected_outcome: "no_match",
119
103
  relevant_skill_ids: [],
120
104
  });
121
- });
122
105
 
123
- // Ensure both splits have at least 1 matched case if skills were empty
124
- if (skills.length === 0) {
125
- cases.push({
126
- query: "run mock container action",
127
- split: "tune",
128
- expected_outcome: "matched",
129
- relevant_skill_ids: ["mock-container"],
130
- });
131
- cases.push({
132
- query: "search mock API docs",
133
- split: "test",
134
- expected_outcome: "matched",
135
- relevant_skill_ids: ["mock-docs"],
136
- });
106
+ for (let i = cases.filter((item) => item.split === split).length; i < targetPerSplit; i++) {
107
+ const left = splitSkills[i % splitSkills.length]!;
108
+ const right = splitSkills[(i + 1) % splitSkills.length]!;
109
+ cases.push({
110
+ query: i % 2 === 0 ? matchedQuery(left, i) : nearMissQuery(left, right),
111
+ split,
112
+ expected_outcome: i % 2 === 0 ? "matched" : "no_match",
113
+ relevant_skill_ids: i % 2 === 0 ? [left.skill_id] : [],
114
+ });
115
+ }
137
116
  }
138
117
 
139
118
  return cases;
package/src/server.ts CHANGED
@@ -4,7 +4,7 @@ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
4
4
  import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
5
5
  import { z } from "zod";
6
6
  import { createClients } from "./clients";
7
- import { expandHome, loadConfig, rerankerFingerprint, resolveConfigPath } from "./config";
7
+ import { loadConfig, resolveConfigPath } from "./config";
8
8
  import { ConfigWatcher, type ReloadStatus } from "./config-watcher";
9
9
  import { RuntimeSnapshotManager } from "./snapshot";
10
10
  import {
@@ -28,11 +28,6 @@ import {
28
28
  RELOADABLE_KEYS,
29
29
  RESTART_REQUIRED_KEYS,
30
30
  } from "./config-service";
31
- import {
32
- applyCalibrationRun,
33
- getCalibrationRun,
34
- listCalibrationRuns,
35
- } from "./calibrate";
36
31
 
37
32
  export const metricsRegistry = new MetricsRegistry();
38
33
  export const readinessState = new ReadinessState();
@@ -381,7 +376,7 @@ export async function startServer(opts?: {
381
376
  JSON.stringify({
382
377
  config_read: true,
383
378
  config_write: !isExternallyManaged,
384
- calibration: true,
379
+ calibration: false,
385
380
  persistence: isExternallyManaged
386
381
  ? "externally_managed"
387
382
  : "writable",
@@ -465,70 +460,15 @@ export async function startServer(opts?: {
465
460
  }
466
461
 
467
462
  if (url.pathname.startsWith("/admin/v1/calibrations")) {
468
- const { db } = await getRuntime();
469
- if (
470
- req.method === "GET" &&
471
- url.pathname === "/admin/v1/calibrations"
472
- ) {
473
- const runs = listCalibrationRuns(db);
474
- return new Response(JSON.stringify(runs), {
475
- status: 200,
476
- headers,
477
- });
478
- }
479
- const runIdMatch = url.pathname.match(
480
- /^\/admin\/v1\/calibrations\/([^\/]+)$/,
481
- );
482
- if (req.method === "GET" && runIdMatch && runIdMatch[1]) {
483
- const runId = runIdMatch[1];
484
- const run = getCalibrationRun(db, runId);
485
- if (!run)
486
- return new Response(
487
- JSON.stringify({ error: "Calibration run not found" }),
488
- { status: 404, headers },
489
- );
490
- return new Response(JSON.stringify(run), {
491
- status: 200,
492
- headers,
493
- });
494
- }
495
- if (
496
- req.method === "POST" &&
497
- url.pathname === "/admin/v1/calibrations"
498
- ) {
499
- const runId = "run_" + Math.random().toString(36).slice(2, 10);
500
- return new Response(
501
- JSON.stringify({ run_id: runId, status: "running" }),
502
- { status: 202, headers },
503
- );
504
- }
505
- const applyMatch = url.pathname.match(
506
- /^\/admin\/v1\/calibrations\/([^\/]+)\/apply$/,
463
+ return new Response(
464
+ JSON.stringify({
465
+ error: "not_implemented",
466
+ message:
467
+ "Remote calibration is not implemented in this release. " +
468
+ "Run `skillmux calibrate` against a local target.",
469
+ }),
470
+ { status: 501, headers },
507
471
  );
508
- if (req.method === "POST" && applyMatch && applyMatch[1]) {
509
- const runId = applyMatch[1];
510
- const run = getCalibrationRun(db, runId);
511
- if (!run)
512
- return new Response(
513
- JSON.stringify({ error: "Calibration run not found" }),
514
- { status: 404, headers },
515
- );
516
- const active = snapshots.acquire();
517
- const currentRerankerFingerprint = rerankerFingerprint(
518
- active.snapshot.config,
519
- );
520
- active.release();
521
- await applyCalibrationRun(
522
- db,
523
- runId,
524
- expandHome(configPath),
525
- { currentRerankerFingerprint },
526
- );
527
- return new Response(JSON.stringify({ ok: true, run_id: runId }), {
528
- status: 200,
529
- headers,
530
- });
531
- }
532
472
  }
533
473
 
534
474
  return new Response("Not Found", { status: 404, headers });