@jinn-network/jinn-layer 0.1.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 (131) hide show
  1. package/dist/adapters/contribution-adapter.d.ts +18 -0
  2. package/dist/adapters/contribution-adapter.js +134 -0
  3. package/dist/adapters/corpus-adapter.d.ts +12 -0
  4. package/dist/adapters/corpus-adapter.js +212 -0
  5. package/dist/adapters/index.d.ts +17 -0
  6. package/dist/adapters/index.js +17 -0
  7. package/dist/adapters/json-map-store.d.ts +4 -0
  8. package/dist/adapters/json-map-store.js +31 -0
  9. package/dist/adapters/local-learning-adapter.d.ts +25 -0
  10. package/dist/adapters/local-learning-adapter.js +49 -0
  11. package/dist/adapters/skills-adapter.d.ts +8 -0
  12. package/dist/adapters/skills-adapter.js +55 -0
  13. package/dist/admission-policy.d.ts +38 -0
  14. package/dist/admission-policy.js +17 -0
  15. package/dist/bin/jinn-distill-mcp.d.ts +2 -0
  16. package/dist/bin/jinn-distill-mcp.js +10 -0
  17. package/dist/bin/jinn-layer.d.ts +7 -0
  18. package/dist/bin/jinn-layer.js +15 -0
  19. package/dist/bridge-fetch-evidence.d.ts +102 -0
  20. package/dist/bridge-fetch-evidence.js +677 -0
  21. package/dist/bridge-verdict-source.d.ts +47 -0
  22. package/dist/bridge-verdict-source.js +216 -0
  23. package/dist/bridge.d.ts +247 -0
  24. package/dist/bridge.js +439 -0
  25. package/dist/capture.d.ts +98 -0
  26. package/dist/capture.js +617 -0
  27. package/dist/cli.d.ts +148 -0
  28. package/dist/cli.js +2221 -0
  29. package/dist/cluster-selection.d.ts +34 -0
  30. package/dist/cluster-selection.js +135 -0
  31. package/dist/cluster.d.ts +89 -0
  32. package/dist/cluster.js +142 -0
  33. package/dist/consume.d.ts +139 -0
  34. package/dist/consume.js +280 -0
  35. package/dist/corpus-probes.d.ts +61 -0
  36. package/dist/corpus-probes.js +120 -0
  37. package/dist/corpus-store.d.ts +16 -0
  38. package/dist/corpus-store.js +101 -0
  39. package/dist/distill-captures.d.ts +56 -0
  40. package/dist/distill-captures.js +283 -0
  41. package/dist/distill-feedback.d.ts +20 -0
  42. package/dist/distill-feedback.js +30 -0
  43. package/dist/distill-llm.d.ts +138 -0
  44. package/dist/distill-llm.js +438 -0
  45. package/dist/distill-mcp-server.d.ts +30 -0
  46. package/dist/distill-mcp-server.js +281 -0
  47. package/dist/distill-mode.d.ts +62 -0
  48. package/dist/distill-mode.js +107 -0
  49. package/dist/distill-progress.d.ts +78 -0
  50. package/dist/distill-progress.js +69 -0
  51. package/dist/distill-prompt.d.ts +34 -0
  52. package/dist/distill-prompt.js +85 -0
  53. package/dist/distill-render.d.ts +123 -0
  54. package/dist/distill-render.js +303 -0
  55. package/dist/distill-runs.d.ts +28 -0
  56. package/dist/distill-runs.js +39 -0
  57. package/dist/distill-traces.d.ts +67 -0
  58. package/dist/distill-traces.js +211 -0
  59. package/dist/distill.d.ts +180 -0
  60. package/dist/distill.js +337 -0
  61. package/dist/distiller.d.ts +118 -0
  62. package/dist/distiller.js +142 -0
  63. package/dist/envelope.d.ts +9 -0
  64. package/dist/envelope.js +9 -0
  65. package/dist/eval-prep.d.ts +129 -0
  66. package/dist/eval-prep.js +523 -0
  67. package/dist/execution-publish.d.ts +62 -0
  68. package/dist/execution-publish.js +72 -0
  69. package/dist/gate.d.ts +39 -0
  70. package/dist/gate.js +106 -0
  71. package/dist/index.d.ts +49 -0
  72. package/dist/index.js +50 -0
  73. package/dist/ipfs-cid.d.ts +18 -0
  74. package/dist/ipfs-cid.js +149 -0
  75. package/dist/layer-default.d.ts +3 -0
  76. package/dist/layer-default.js +10 -0
  77. package/dist/ledger.d.ts +62 -0
  78. package/dist/ledger.js +104 -0
  79. package/dist/measurement.d.ts +90 -0
  80. package/dist/measurement.js +134 -0
  81. package/dist/path-safety.d.ts +19 -0
  82. package/dist/path-safety.js +303 -0
  83. package/dist/pipeline.d.ts +94 -0
  84. package/dist/pipeline.js +124 -0
  85. package/dist/plugin-wiring.d.ts +4 -0
  86. package/dist/plugin-wiring.js +54 -0
  87. package/dist/preview.d.ts +31 -0
  88. package/dist/preview.js +28 -0
  89. package/dist/process-contract.d.ts +303 -0
  90. package/dist/process-contract.js +118 -0
  91. package/dist/publish-skill.d.ts +86 -0
  92. package/dist/publish-skill.js +116 -0
  93. package/dist/publish.d.ts +294 -0
  94. package/dist/publish.js +1090 -0
  95. package/dist/scripts/scan-skills-registry.d.ts +24 -0
  96. package/dist/scripts/scan-skills-registry.js +207 -0
  97. package/dist/seed-import/curated-batch.d.ts +36 -0
  98. package/dist/seed-import/curated-batch.js +103 -0
  99. package/dist/seed-import/episode-execute.d.ts +50 -0
  100. package/dist/seed-import/episode-execute.js +235 -0
  101. package/dist/seed-import/episode-fetch.d.ts +119 -0
  102. package/dist/seed-import/episode-fetch.js +137 -0
  103. package/dist/seed-import/episode-plan.d.ts +11 -0
  104. package/dist/seed-import/episode-plan.js +27 -0
  105. package/dist/seed-import/episode-report.d.ts +39 -0
  106. package/dist/seed-import/episode-report.js +36 -0
  107. package/dist/seed-import/execute.d.ts +61 -0
  108. package/dist/seed-import/execute.js +245 -0
  109. package/dist/seed-import/fetch.d.ts +62 -0
  110. package/dist/seed-import/fetch.js +82 -0
  111. package/dist/seed-import/licence.d.ts +17 -0
  112. package/dist/seed-import/licence.js +36 -0
  113. package/dist/seed-import/plan.d.ts +10 -0
  114. package/dist/seed-import/plan.js +21 -0
  115. package/dist/seed-import/report.d.ts +34 -0
  116. package/dist/seed-import/report.js +31 -0
  117. package/dist/seed-import/state.d.ts +72 -0
  118. package/dist/seed-import/state.js +120 -0
  119. package/dist/signal.d.ts +36 -0
  120. package/dist/signal.js +48 -0
  121. package/dist/signing.d.ts +8 -0
  122. package/dist/signing.js +21 -0
  123. package/dist/skill-package.d.ts +70 -0
  124. package/dist/skill-package.js +141 -0
  125. package/dist/skill.d.ts +21 -0
  126. package/dist/skill.js +77 -0
  127. package/dist/snapshot-transcript.d.ts +54 -0
  128. package/dist/snapshot-transcript.js +117 -0
  129. package/dist/three-arm-run.d.ts +27 -0
  130. package/dist/three-arm-run.js +41 -0
  131. package/package.json +60 -0
@@ -0,0 +1,337 @@
1
+ /**
2
+ * The distillation step — evidence clusters → layer-2 skills
3
+ * (spec/2026-07-06-distillation-v1.md §7, D4/D10).
4
+ *
5
+ * Scripted, single-shot, flat (recursion/hierarchy are v3). Three modes keyed to
6
+ * the cluster's tier (§7): pattern → strategic-pattern skill; lesson →
7
+ * failure-lesson skill (diagnosis, not prescription); contrastive → one skill
8
+ * from both polarities of an instance. Each skill is:
9
+ * 1. distilled by an injected LLM port (`deps.distill`) using
10
+ * `jinn-skill-distill-prompt-v1` (the port owns the model call);
11
+ * 2. **secret-scrubbed fail-closed** — the body is run through the layer-2
12
+ * pipeline; if the scrub would change anything (a secret was present), the
13
+ * skill is DROPPED, not published (so published bodies are provably clean
14
+ * AND never re-defaced, §7 step 4 / §10);
15
+ * 3. **contamination-scanned** against the held-out slate (§12 axis 3) — a
16
+ * body naming a slate instance/repo/PR is dropped;
17
+ * 4. **structurally gated** (§7 step 6, v0.5) — the fixed skeleton (five
18
+ * non-empty sections), the description anti-trigger ("Not for:"), and (for
19
+ * lessons) the imperative-counterfactual guard, all DETERMINISTIC (a deep
20
+ * quality judge is deferred admission-checking, §13);
21
+ * 5. published via `publishSkill()` with provenance back-links, the prompt
22
+ * SHA, and the auditability fields (distill model + token estimates, §5).
23
+ *
24
+ * Clustering is upstream (human-curated for v1, §7) — this consumes pre-formed
25
+ * clusters of eligible evidence.
26
+ */
27
+ import { buildLayer2ScrubPipeline, } from '@jinn-network/core/scrub';
28
+ import { assertConformantName, } from './skill-package.js';
29
+ import { JINN_SKILL_DISTILL_PROMPT_V1_SHA256, JINN_SKILL_META_DISTILL_PROMPT_V1_SHA256 } from './distill-prompt.js';
30
+ function escapeRegExp(s) {
31
+ return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
32
+ }
33
+ /**
34
+ * Derive held-out slate tokens from SWE-bench `instance_id`s (`owner__repo-<pr#>`)
35
+ * and any explicit repos, split by match strategy:
36
+ *
37
+ * - **substrings** — delimiter-bearing tokens with a low false-positive rate
38
+ * (the full `owner__repo-n` instance id, `owner/repo`, `#pr`). Matched by
39
+ * plain `includes`.
40
+ * - **words** — bare identifiers (the repo name alone, e.g. `flask`, `go`,
41
+ * `requests`) that recur inside ordinary prose. Matched on `\b`-word
42
+ * boundaries so "we are **go**ing" / "handle **requests**" do NOT trip the
43
+ * scan, while a body literally naming the held-out repo as a word does.
44
+ *
45
+ * Local to the distiller (cap-v0 will carry repos explicitly, retiring this)
46
+ * so the module does not couple to the bridge branch.
47
+ */
48
+ function slateTokens(slate) {
49
+ const substrings = new Set();
50
+ const words = new Set();
51
+ for (const id of slate.instanceIds) {
52
+ substrings.add(id.toLowerCase()); // full instance id (carries `__` and `-`)
53
+ const m = /^(.+)__(.+)-(\d+)$/.exec(id);
54
+ if (m) {
55
+ substrings.add(`${m[1]}/${m[2]}`.toLowerCase()); // owner/repo (carries `/`)
56
+ substrings.add(`#${m[3]}`); // #pr (carries `#`)
57
+ words.add(m[2].toLowerCase()); // bare repo → word-boundary match
58
+ }
59
+ }
60
+ for (const repo of slate.repos ?? [])
61
+ words.add(repo.toLowerCase());
62
+ return { substrings: [...substrings], words: [...words] };
63
+ }
64
+ /**
65
+ * Scan a distilled body for held-out slate tokens (instance id / repo / PR#).
66
+ * Delimiter-bearing tokens match as substrings; bare repo names match only on
67
+ * word boundaries, so a common-word repo does not silently drop a clean skill.
68
+ */
69
+ export function lexicalContaminationScan(body, slate) {
70
+ const lower = body.toLowerCase();
71
+ const { substrings, words } = slateTokens(slate);
72
+ const hits = [];
73
+ for (const t of substrings)
74
+ if (t && lower.includes(t))
75
+ hits.push(t);
76
+ for (const w of words) {
77
+ if (w && new RegExp(`\\b${escapeRegExp(w)}\\b`).test(lower))
78
+ hits.push(w);
79
+ }
80
+ return { contaminated: hits.length > 0, hits };
81
+ }
82
+ function sanitizeName(raw) {
83
+ return raw.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '');
84
+ }
85
+ /** The fixed distilled-skill skeleton (§5, v0.5): each section must be non-empty. */
86
+ const REQUIRED_SECTIONS = ['When to use', 'Strategy', 'Steps', 'Pitfalls', 'Verify'];
87
+ /**
88
+ * Extract the trimmed content under a `## <title>` heading (case-insensitive),
89
+ * up to the next `## ` heading or EOF. Returns null when the heading is absent.
90
+ * `### ` and deeper subheadings are content, not section boundaries.
91
+ */
92
+ function sectionContent(md, title) {
93
+ let capturing = false;
94
+ const captured = [];
95
+ for (const line of md.split('\n')) {
96
+ const h = /^##\s+(.+?)\s*$/.exec(line);
97
+ if (h) {
98
+ if (capturing)
99
+ break; // next section heading ends this one
100
+ if (h[1].trim().toLowerCase() === title.toLowerCase())
101
+ capturing = true;
102
+ continue;
103
+ }
104
+ if (capturing)
105
+ captured.push(line);
106
+ }
107
+ return capturing ? captured.join('\n').trim() : null;
108
+ }
109
+ /**
110
+ * Lexical markers of an IMPERATIVE counterfactual in a failure-lesson body
111
+ * (spec §7 v0.5 verified-counterfactual rule). Deliberately SHALLOW — a deep
112
+ * semantic judge of lesson quality is admission-checking, deferred (§13). The
113
+ * evidence verifies THAT an attempt failed; a lesson states diagnosis, and
114
+ * stating a prescription ("do X instead") as fact overstates that evidence.
115
+ */
116
+ const LESSON_IMPERATIVE_COUNTERFACTUAL = [
117
+ /\binstead[,:]?\s+(do|use|run|call|apply|prefer|switch|add|set|replace|write)\b/i,
118
+ /\bthe (correct|right)\s+\w+\s+is\b/i,
119
+ ];
120
+ /**
121
+ * Deterministic structural conformance gate (spec §7 step 6, v0.5). Returns a
122
+ * rejection reason, or null when the output conforms:
123
+ * - fixed skeleton: all five sections present and non-empty;
124
+ * - description anti-trigger: a "Not for:" clause is present;
125
+ * - (lesson tier only) no imperative counterfactual.
126
+ */
127
+ function structuralRejection(out, tier) {
128
+ for (const title of REQUIRED_SECTIONS) {
129
+ const content = sectionContent(out.body, title);
130
+ if (content === null)
131
+ return `skeleton: missing section "## ${title}"`;
132
+ if (content.length === 0)
133
+ return `skeleton: empty section "## ${title}"`;
134
+ }
135
+ if (!/\bNot for\b/i.test(out.description)) {
136
+ return 'description missing "Not for:" anti-trigger clause';
137
+ }
138
+ if (tier === 'lesson' && LESSON_IMPERATIVE_COUNTERFACTUAL.some((re) => re.test(out.body))) {
139
+ return 'lesson counterfactual: unverified prescription (diagnosis only unless contrastive)';
140
+ }
141
+ return null;
142
+ }
143
+ /** Deterministic token estimate: ceil(utf8 bytes / 4) — the §5 compression metric. */
144
+ function estimateTokens(s) {
145
+ return Math.ceil(new TextEncoder().encode(s).length / 4);
146
+ }
147
+ const SKILL_KIND_BY_TIER = {
148
+ pattern: 'strategic-pattern',
149
+ lesson: 'failure-lesson',
150
+ contrastive: 'contrastive',
151
+ };
152
+ /**
153
+ * The shared per-skill finish: fail-closed output scrub (2) → contamination scan
154
+ * (3) → structural gate (4) → name check → package build → publish (5). Returns a
155
+ * deterministic rejection reason, or the published package. `publishSkill`
156
+ * failures throw (the caller records them as errors). Called by BOTH
157
+ * `distillClusters` and `metaDistill` so stage-2 passes the identical gate.
158
+ */
159
+ async function finalizeSkill(out, spec, deps) {
160
+ // (2) fail-closed output secret scrub.
161
+ const scrubbed = await deps.pipeline.run({ 'skill.md': out.body });
162
+ if (scrubbed.redactions.length > 0 || String(scrubbed.attributes['skill.md']) !== out.body) {
163
+ const hits = [...new Set(scrubbed.redactions.map((r) => `${r.stage}/${r.detail ?? r.kind}`))];
164
+ const detail = hits.length > 0 ? hits.join(', ') : 'body altered by scrub';
165
+ return { ok: false, reason: `secret-in-output (dropped, fail-closed): ${detail}` };
166
+ }
167
+ // (3) contamination scan against the held-out slate.
168
+ const scan = lexicalContaminationScan(out.body, deps.slate);
169
+ if (scan.contaminated)
170
+ return { ok: false, reason: `contamination: ${scan.hits.join(', ')}` };
171
+ // (4) deterministic structural conformance gate.
172
+ const structuralReason = structuralRejection(out, spec.tier);
173
+ if (structuralReason)
174
+ return { ok: false, reason: structuralReason };
175
+ const name = sanitizeName(out.name);
176
+ if (!name)
177
+ return { ok: false, reason: `non-conformant skill name from distiller: ${JSON.stringify(out.name)}` };
178
+ assertConformantName(name);
179
+ const pkg = {
180
+ name,
181
+ description: out.description,
182
+ license: null,
183
+ jinn: {
184
+ schema: 'jinn.skill.v1',
185
+ distribution: deps.distribution,
186
+ verifiabilityTier: deps.verifiabilityTier ?? 'evaluator-verified',
187
+ distilledFrom: spec.distinctInstances,
188
+ provenance: spec.evidenceRefs,
189
+ distillPromptSha256: spec.promptSha,
190
+ distilledAt: deps.now.toISOString(),
191
+ skillKind: spec.skillKind,
192
+ ...(deps.skillStatus ? { status: deps.skillStatus } : {}),
193
+ ...(deps.evidenceTier ? { evidenceTier: deps.evidenceTier } : {}),
194
+ ...(deps.sourceTools ? { sourceTools: deps.sourceTools } : {}),
195
+ ...(deps.targetTools ? { targetTools: deps.targetTools } : {}),
196
+ ...(deps.distillModel ? { distillModel: deps.distillModel } : {}),
197
+ evidenceTokens: estimateTokens(JSON.stringify(spec.input ?? '')),
198
+ skillTokens: estimateTokens(out.body),
199
+ },
200
+ body: out.body,
201
+ };
202
+ const pub = await deps.publishSkill(pkg);
203
+ return { ok: true, pkg, envelopeRef: pub.envelopeRef };
204
+ }
205
+ /** Resolve the lazy per-stage deps into the concrete {@link FinalizeDeps} both stages share. */
206
+ function resolveFinalizeDeps(deps) {
207
+ return {
208
+ pipeline: deps.scrubPipeline ?? buildLayer2ScrubPipeline(),
209
+ distribution: deps.distribution ?? 'coding',
210
+ now: deps.now?.() ?? new Date(),
211
+ slate: deps.slate,
212
+ ...(deps.distillModel ? { distillModel: deps.distillModel } : {}),
213
+ ...(deps.verifiabilityTier ? { verifiabilityTier: deps.verifiabilityTier } : {}),
214
+ ...(deps.skillStatus ? { skillStatus: deps.skillStatus } : {}),
215
+ ...(deps.evidenceTier ? { evidenceTier: deps.evidenceTier } : {}),
216
+ ...(deps.sourceTools ? { sourceTools: deps.sourceTools } : {}),
217
+ ...(deps.targetTools ? { targetTools: deps.targetTools } : {}),
218
+ publishSkill: deps.publishSkill,
219
+ };
220
+ }
221
+ export async function distillClusters(clusters, deps) {
222
+ const finalizeDeps = resolveFinalizeDeps(deps);
223
+ const result = { published: [], rejected: [], errors: [] };
224
+ deps.onPlan?.(clusters.map((c, i) => ({
225
+ clusterId: c.clusterId,
226
+ index: i + 1,
227
+ captureCount: c.evidenceRefs.length,
228
+ refs: [...c.evidenceRefs],
229
+ })));
230
+ const total = clusters.length;
231
+ for (const [i, cluster] of clusters.entries()) {
232
+ const index = i + 1;
233
+ const base = { clusterId: cluster.clusterId, index, total };
234
+ deps.onCluster?.({ phase: 'start', ...base });
235
+ const startedAt = Date.now();
236
+ try {
237
+ const out = await deps.distill(cluster);
238
+ const skillKind = SKILL_KIND_BY_TIER[cluster.tier];
239
+ const fin = await finalizeSkill(out, {
240
+ tier: cluster.tier,
241
+ evidenceRefs: cluster.evidenceRefs,
242
+ distinctInstances: new Set(cluster.instanceIds).size,
243
+ input: cluster.input,
244
+ skillKind,
245
+ promptSha: JINN_SKILL_DISTILL_PROMPT_V1_SHA256,
246
+ }, finalizeDeps);
247
+ if (fin.ok) {
248
+ result.published.push({ clusterId: cluster.clusterId, skillKind, envelopeRef: fin.envelopeRef, pkg: fin.pkg });
249
+ deps.onCluster?.({ phase: 'end', ...base, outcome: 'published', skillName: fin.pkg.name, durationMs: Date.now() - startedAt });
250
+ }
251
+ else {
252
+ result.rejected.push({ clusterId: cluster.clusterId, reason: fin.reason });
253
+ deps.onCluster?.({ phase: 'end', ...base, outcome: 'rejected', reason: fin.reason, durationMs: Date.now() - startedAt });
254
+ }
255
+ }
256
+ catch (err) {
257
+ const error = err instanceof Error ? err.message : String(err);
258
+ result.errors.push({ clusterId: cluster.clusterId, error });
259
+ deps.onCluster?.({ phase: 'end', ...base, outcome: 'error', error, durationMs: Date.now() - startedAt });
260
+ }
261
+ }
262
+ return result;
263
+ }
264
+ /**
265
+ * Stage-2 cross-instance meta-distill (issue #1463). For each same-polarity
266
+ * meta-cluster: run the meta LLM, keep only the sources it names in `supports`,
267
+ * require **≥2 distinct supporting instances**, union their layer-1 evidence
268
+ * CIDs into the provenance, and finish through the SAME `finalizeSkill` gate as
269
+ * stage-1 — so a meta-skill passes the identical scrub / contamination /
270
+ * structural checks. Additive and opt-in; never called unless the pipeline
271
+ * enables it.
272
+ */
273
+ export async function metaDistill(clusters, deps) {
274
+ const finalizeDeps = resolveFinalizeDeps(deps);
275
+ const result = { published: [], rejected: [], errors: [] };
276
+ for (const cluster of clusters) {
277
+ try {
278
+ const out = await deps.metaDistill(cluster);
279
+ // Keep sources the model corroborated, in the cluster's stable id order
280
+ // (deterministic regardless of the model's `supports` ordering).
281
+ const supported = new Set(out.supports);
282
+ const supportedSources = cluster.sources.filter((s) => supported.has(s.id));
283
+ const distinctInstances = new Set(supportedSources.flatMap((s) => s.instanceIds));
284
+ if (distinctInstances.size < 2) {
285
+ result.rejected.push({
286
+ metaClusterId: cluster.metaClusterId,
287
+ reason: `cross-instance: fewer than 2 distinct supporting instances (got ${distinctInstances.size})`,
288
+ });
289
+ continue;
290
+ }
291
+ // Union the supporting evidence refs (dedup, preserve first-seen order) →
292
+ // provenance (the full audited evidence set). distilledFrom is the
293
+ // distinct-instance count (§6, #1478), NOT the union size. The finalize
294
+ // `input` is the supported source bodies, so evidenceTokens is honest.
295
+ const seen = new Set();
296
+ const unionRefs = [];
297
+ for (const s of supportedSources)
298
+ for (const ref of s.evidenceRefs) {
299
+ if (!seen.has(ref)) {
300
+ seen.add(ref);
301
+ unionRefs.push(ref);
302
+ }
303
+ }
304
+ // Defensive publish-boundary guard: the feature's invariant (AC1) is that a
305
+ // cross-instance skill's provenance is the *union* of >1 supporting evidence
306
+ // refs. buildMetaClusters upholds this, but metaDistill is exported and a
307
+ // direct caller could pass sources whose refs collapse to <2 (shared or empty
308
+ // evidenceRefs). Reject rather than publish a skill that violates distilledFrom > 1.
309
+ if (unionRefs.length < 2) {
310
+ result.rejected.push({
311
+ metaClusterId: cluster.metaClusterId,
312
+ reason: `cross-instance: union provenance has fewer than 2 evidence refs (got ${unionRefs.length})`,
313
+ });
314
+ continue;
315
+ }
316
+ const finalizeInput = supportedSources.map((s) => ({ name: s.name, description: s.description, body: s.body }));
317
+ const fin = await finalizeSkill(out, {
318
+ tier: cluster.gateTier,
319
+ evidenceRefs: unionRefs,
320
+ distinctInstances: distinctInstances.size,
321
+ input: finalizeInput,
322
+ skillKind: 'cross-instance',
323
+ promptSha: JINN_SKILL_META_DISTILL_PROMPT_V1_SHA256,
324
+ }, finalizeDeps);
325
+ if (fin.ok) {
326
+ result.published.push({ metaClusterId: cluster.metaClusterId, skillKind: 'cross-instance', envelopeRef: fin.envelopeRef, pkg: fin.pkg });
327
+ }
328
+ else {
329
+ result.rejected.push({ metaClusterId: cluster.metaClusterId, reason: fin.reason });
330
+ }
331
+ }
332
+ catch (err) {
333
+ result.errors.push({ metaClusterId: cluster.metaClusterId, error: err instanceof Error ? err.message : String(err) });
334
+ }
335
+ }
336
+ return result;
337
+ }
@@ -0,0 +1,118 @@
1
+ /**
2
+ * The `Distiller` seam (issue #1488) — one interface, two backends.
3
+ *
4
+ * A single surface, "distill these captures → skills", sits OVER the merged
5
+ * distillation engine (`clusterEvidence` → `distillClusters`, spec
6
+ * §6–§7). The engine is untouched; a backend only binds the SOURCE (where the
7
+ * captures come from) and the SINK (where the distilled skills go):
8
+ *
9
+ * - {@link createLocalDistiller} (rung 1) — source = the operator's OWN local
10
+ * captures; sink = a private local skill install (SKILL.md, NO chain
11
+ * anchor). It scrubs each capture at the layer-2 (secret-only) altitude —
12
+ * the same altitude the network bridge uses (bridge.ts, D6) — parses the
13
+ * resulting envelope, and hands it to the SAME gate/cluster/distill engine
14
+ * the network path runs. Nothing is published to the corpus; both the
15
+ * layer-1 evidence and the layer-2 skill stay on the operator's machine.
16
+ *
17
+ * - {@link createNetworkDistiller} (rung 3) — source = the bonded network's
18
+ * verified evidence (the existing `session-derived.v1` task path); sink =
19
+ * the public anchored corpus. STUBBED for the MVP: it fails closed until
20
+ * #1395 wires the source + sink, so it is a drop-in later.
21
+ *
22
+ * The two rungs are symmetric behind the one interface: the same `distill()`
23
+ * call, a different backend.
24
+ */
25
+ import { type ScrubPipeline } from '@jinn-network/core/scrub';
26
+ import { type CapturedTask } from './capture.js';
27
+ import { type DistillCluster, type DistillDeps, type DistillLLMOutput, type DistillResult } from './distill.js';
28
+ import { type SkillPackage } from './skill-package.js';
29
+ /** The held-out slate shape shared by the engine's gate + contamination scan. */
30
+ export interface HeldOutSlate {
31
+ instanceIds: Set<string>;
32
+ repos?: Set<string>;
33
+ }
34
+ /**
35
+ * The layer-2 skill sink: install one distilled skill, returning the corpus ref
36
+ * and (if anchored) the anchor tx. This is the engine's `publishSkill` port
37
+ * shape, named as the seam's SINK so a backend supplies either a private local
38
+ * install (no anchor) or the anchored corpus.
39
+ */
40
+ export type SkillSink = (pkg: SkillPackage) => Promise<{
41
+ envelopeRef: string;
42
+ anchorTx: string | null;
43
+ }>;
44
+ /** The result of one distillation run: the engine's distilled output + cluster count. */
45
+ export interface DistillerResult {
46
+ /** Distinct clusters the eligible captures formed (§7). */
47
+ clusterCount: number;
48
+ /** The engine's per-cluster outcome: published skills, rejections, errors. */
49
+ distilled: DistillResult;
50
+ }
51
+ /**
52
+ * The single distillation surface. Backends bind the source (via the captures
53
+ * they accept) and the sink (where the skills go); the engine in between is
54
+ * shared and unchanged.
55
+ */
56
+ export interface Distiller {
57
+ /** Distill a batch of captures into layer-2 skills via this backend's sink. */
58
+ distill(captures: CapturedTask[]): Promise<DistillerResult>;
59
+ }
60
+ /**
61
+ * A private local skill install (rung-1 sink): write `<outDir>/<name>/SKILL.md`
62
+ * with the embedded provenance block, no chain anchor. Mirrors the fs-writer the
63
+ * `distill run` CLI + `distill-run-live.ts` already use, extracted so the
64
+ * LocalDistiller and those call sites share one implementation.
65
+ */
66
+ export declare function createLocalSkillSink(outDir: string): SkillSink;
67
+ export interface LocalDistillerDeps {
68
+ /** The LLM distill port (jinn-skill-distill-prompt-v1 over a cluster). */
69
+ distill: (cluster: DistillCluster) => Promise<DistillLLMOutput>;
70
+ /** Where distilled skills are installed (default sink: {@link createLocalSkillSink}). */
71
+ sink: SkillSink;
72
+ /**
73
+ * Held-out slate — defence-in-depth over the gate + the distiller's
74
+ * contamination scan. Own-captures rarely intersect a public held-out slate,
75
+ * so this defaults to empty.
76
+ */
77
+ slate?: HeldOutSlate;
78
+ /** Skill distribution recorded in provenance (§5). Default `coding`. */
79
+ distribution?: string;
80
+ /** The distilling model, recorded in each skill's provenance (§5 auditability). */
81
+ distillModel?: string;
82
+ /** Injectable layer-2 scrub pipeline (tests). Default: {@link buildLayer2ScrubPipeline}. */
83
+ scrubPipeline?: ScrubPipeline;
84
+ now?: () => Date;
85
+ /** Progress seam (#1533), threaded through to {@link distillClusters}. */
86
+ onPlan?: DistillDeps['onPlan'];
87
+ onCluster?: DistillDeps['onCluster'];
88
+ }
89
+ /**
90
+ * Rung-1 backend: distill the operator's OWN local captures in-process.
91
+ *
92
+ * Each capture is scrubbed at the layer-2 altitude and turned into an in-memory
93
+ * layer-1 envelope (never published), then the whole batch runs through the
94
+ * shared gate/cluster/distill engine. The instance key for clustering is the
95
+ * capture's `sessionId` — one own session is one instance. Only
96
+ * evaluator-verified captures are eligible (the gate, §6); everything else is
97
+ * dropped before the LLM is called.
98
+ */
99
+ export declare function createLocalDistiller(deps: LocalDistillerDeps): Distiller;
100
+ /**
101
+ * Thrown when {@link createNetworkDistiller}'s backend is invoked before it is
102
+ * wired (fail-closed). The rung-3 wiring — a `session-derived.v1` verdict source
103
+ * feeding `runDistillationPipeline`, sinking to the anchored corpus — lands with
104
+ * #1395.
105
+ */
106
+ export declare class NetworkDistillerNotWiredError extends Error {
107
+ constructor();
108
+ }
109
+ /**
110
+ * Rung-3 backend (STUBBED). The drop-in seam: the wired version drives the same
111
+ * merged engine as LocalDistiller, but binds the network SOURCE — the
112
+ * `session-derived.v1` task path over the bonded network's verified evidence
113
+ * (bridge-verdict-source.ts → bridgeAttempts → clusterEvidence) — and the
114
+ * anchored-corpus SINK (`publishSkill` with a chain anchor). Until #1395 wires
115
+ * those ports, `distill()` fails closed with {@link NetworkDistillerNotWiredError}
116
+ * so the not-wired state is loud, never a silent no-op.
117
+ */
118
+ export declare function createNetworkDistiller(): Distiller;
@@ -0,0 +1,142 @@
1
+ /**
2
+ * The `Distiller` seam (issue #1488) — one interface, two backends.
3
+ *
4
+ * A single surface, "distill these captures → skills", sits OVER the merged
5
+ * distillation engine (`clusterEvidence` → `distillClusters`, spec
6
+ * §6–§7). The engine is untouched; a backend only binds the SOURCE (where the
7
+ * captures come from) and the SINK (where the distilled skills go):
8
+ *
9
+ * - {@link createLocalDistiller} (rung 1) — source = the operator's OWN local
10
+ * captures; sink = a private local skill install (SKILL.md, NO chain
11
+ * anchor). It scrubs each capture at the layer-2 (secret-only) altitude —
12
+ * the same altitude the network bridge uses (bridge.ts, D6) — parses the
13
+ * resulting envelope, and hands it to the SAME gate/cluster/distill engine
14
+ * the network path runs. Nothing is published to the corpus; both the
15
+ * layer-1 evidence and the layer-2 skill stay on the operator's machine.
16
+ *
17
+ * - {@link createNetworkDistiller} (rung 3) — source = the bonded network's
18
+ * verified evidence (the existing `session-derived.v1` task path); sink =
19
+ * the public anchored corpus. STUBBED for the MVP: it fails closed until
20
+ * #1395 wires the source + sink, so it is a drop-in later.
21
+ *
22
+ * The two rungs are symmetric behind the one interface: the same `distill()`
23
+ * call, a different backend.
24
+ */
25
+ import { mkdirSync, writeFileSync } from 'node:fs';
26
+ import { join } from 'node:path';
27
+ import { buildLayer2ScrubPipeline, } from '@jinn-network/core/scrub';
28
+ import { capture } from './capture.js';
29
+ import { parseTraceEnvelopeV0 } from './envelope.js';
30
+ import { clusterEvidence } from './cluster.js';
31
+ import { distillClusters, } from './distill.js';
32
+ import { assertConformantName, buildSkillMarkdown } from './skill-package.js';
33
+ /**
34
+ * A private local skill install (rung-1 sink): write `<outDir>/<name>/SKILL.md`
35
+ * with the embedded provenance block, no chain anchor. Mirrors the fs-writer the
36
+ * `distill run` CLI + `distill-run-live.ts` already use, extracted so the
37
+ * LocalDistiller and those call sites share one implementation.
38
+ */
39
+ export function createLocalSkillSink(outDir) {
40
+ return async (pkg) => {
41
+ // Validate the publisher-controlled name BEFORE it touches the filesystem —
42
+ // the engine already emits conformant names, but this helper is exported, so
43
+ // it defends its own boundary (no `../` in the install path).
44
+ assertConformantName(pkg.name);
45
+ const dir = join(outDir, pkg.name);
46
+ mkdirSync(dir, { recursive: true });
47
+ writeFileSync(join(dir, 'SKILL.md'), buildSkillMarkdown(pkg));
48
+ return { envelopeRef: `local:${pkg.name}`, anchorTx: null };
49
+ };
50
+ }
51
+ /**
52
+ * Rung-1 backend: distill the operator's OWN local captures in-process.
53
+ *
54
+ * Each capture is scrubbed at the layer-2 altitude and turned into an in-memory
55
+ * layer-1 envelope (never published), then the whole batch runs through the
56
+ * shared gate/cluster/distill engine. The instance key for clustering is the
57
+ * capture's `sessionId` — one own session is one instance. Only
58
+ * evaluator-verified captures are eligible (the gate, §6); everything else is
59
+ * dropped before the LLM is called.
60
+ */
61
+ export function createLocalDistiller(deps) {
62
+ return {
63
+ async distill(captures) {
64
+ const layer2 = deps.scrubPipeline ?? buildLayer2ScrubPipeline();
65
+ const slate = deps.slate ?? { instanceIds: new Set() };
66
+ // Source binding: own capture → scrubbed layer-1 envelope → cluster item.
67
+ // Parsing with consent=true only CONSTRUCTS the in-memory envelope for the
68
+ // gate/cluster; nothing leaves the machine (the network bridge does the
69
+ // same, pipeline.ts).
70
+ const items = [];
71
+ for (const task of captures) {
72
+ const pending = await capture(task, { pipeline: layer2 });
73
+ const env = parseTraceEnvelopeV0({
74
+ ...pending.draft,
75
+ consent: { contributionConsent: true, scrubCompleted: true },
76
+ });
77
+ items.push({
78
+ ref: `local-capture:${task.session.sessionId}`,
79
+ instanceId: task.session.sessionId,
80
+ env,
81
+ });
82
+ }
83
+ const clusters = clusterEvidence(items, {
84
+ heldOut: (id) => slate.instanceIds.has(id),
85
+ eligibilityMode: 'local-experimental',
86
+ });
87
+ const sourceTools = [...new Set(captures.map((task) => task.environment.harness.name))].sort();
88
+ const evidenceTier = clusters.some((cluster) => cluster.tier === 'contrastive')
89
+ ? 'contrastive'
90
+ : captures.length > 1
91
+ ? 'recurring-pattern'
92
+ : 'single-example';
93
+ const distilled = await distillClusters(clusters, {
94
+ distill: deps.distill,
95
+ publishSkill: deps.sink,
96
+ slate,
97
+ distribution: deps.distribution ?? 'coding',
98
+ verifiabilityTier: 'user-accepted',
99
+ skillStatus: 'experimental',
100
+ evidenceTier,
101
+ sourceTools,
102
+ targetTools: ['current'],
103
+ ...(deps.distillModel ? { distillModel: deps.distillModel } : {}),
104
+ ...(deps.now ? { now: deps.now } : {}),
105
+ ...(deps.onPlan ? { onPlan: deps.onPlan } : {}),
106
+ ...(deps.onCluster ? { onCluster: deps.onCluster } : {}),
107
+ });
108
+ return { clusterCount: clusters.length, distilled };
109
+ },
110
+ };
111
+ }
112
+ /**
113
+ * Thrown when {@link createNetworkDistiller}'s backend is invoked before it is
114
+ * wired (fail-closed). The rung-3 wiring — a `session-derived.v1` verdict source
115
+ * feeding `runDistillationPipeline`, sinking to the anchored corpus — lands with
116
+ * #1395.
117
+ */
118
+ export class NetworkDistillerNotWiredError extends Error {
119
+ constructor() {
120
+ super('NetworkDistiller is not wired for the MVP (rung 3). The bonded-network ' +
121
+ 'source (session-derived.v1 verified evidence) and the anchored-corpus ' +
122
+ 'sink land with #1395; until then this backend fails closed. Use ' +
123
+ 'createLocalDistiller for own-captures distillation.');
124
+ this.name = 'NetworkDistillerNotWiredError';
125
+ }
126
+ }
127
+ /**
128
+ * Rung-3 backend (STUBBED). The drop-in seam: the wired version drives the same
129
+ * merged engine as LocalDistiller, but binds the network SOURCE — the
130
+ * `session-derived.v1` task path over the bonded network's verified evidence
131
+ * (bridge-verdict-source.ts → bridgeAttempts → clusterEvidence) — and the
132
+ * anchored-corpus SINK (`publishSkill` with a chain anchor). Until #1395 wires
133
+ * those ports, `distill()` fails closed with {@link NetworkDistillerNotWiredError}
134
+ * so the not-wired state is loud, never a silent no-op.
135
+ */
136
+ export function createNetworkDistiller() {
137
+ return {
138
+ async distill() {
139
+ throw new NetworkDistillerNotWiredError();
140
+ },
141
+ };
142
+ }
@@ -0,0 +1,9 @@
1
+ /**
2
+ * Re-export shim — the canonical trace-envelope schema now lives in
3
+ * `@jinn-network/core` (moved in C2, #1833). This file keeps the ~13
4
+ * harness-layer `./envelope.js` importers unchanged while the schema lives
5
+ * one package up. Import the surface from core:
6
+ *
7
+ * import { TraceEnvelopeV0Schema } from '@jinn-network/core';
8
+ */
9
+ export * from '@jinn-network/core';
@@ -0,0 +1,9 @@
1
+ /**
2
+ * Re-export shim — the canonical trace-envelope schema now lives in
3
+ * `@jinn-network/core` (moved in C2, #1833). This file keeps the ~13
4
+ * harness-layer `./envelope.js` importers unchanged while the schema lives
5
+ * one package up. Import the surface from core:
6
+ *
7
+ * import { TraceEnvelopeV0Schema } from '@jinn-network/core';
8
+ */
9
+ export * from '@jinn-network/core';