@evomap/evolver 1.69.11 → 1.69.13

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.
@@ -0,0 +1,645 @@
1
+ 'use strict';
2
+
3
+ // skill2gep.js -- Reverse distillation: take a locally-invoked Skill (Cursor,
4
+ // Claude Code, Codex, or any procedural SKILL.md) plus the real execution that
5
+ // just ran on top of it, and turn it into GEP assets (Gene + Capsule) that can
6
+ // be published to the EvoMap community.
7
+ //
8
+ // This module is the *inverse* of skillDistiller.js:
9
+ // skillDistiller.js : capsule stream -> Gene (forward distillation)
10
+ // skill2gep.js : Skill.md + 1 run -> Gene + Capsule (reverse)
11
+ //
12
+ // Design contract (mirrors ~/.cursor/skills/skill2gep/SKILL.md):
13
+ // - Gene comes from the Skill text (plus its real execution trace),
14
+ // validated via validateSynthesizedGene().
15
+ // - Capsule is produced ONLY from a real execution trace. If the trace
16
+ // is empty or zero blast radius, we refuse to emit a successful Capsule.
17
+ // - Capsule.execution_trace MUST cover every entry in Gene.validation
18
+ // (whitespace-normalized exact match) or we downgrade to Gene-only.
19
+ // - All assets go through assetStore (which SHA-256-content-addresses them)
20
+ // before upload.
21
+
22
+ const fs = require('fs');
23
+ const path = require('path');
24
+ const crypto = require('crypto');
25
+
26
+ const paths = require('./paths');
27
+ const assetStore = require('./assetStore');
28
+ const skillDistiller = require('./skillDistiller');
29
+ const skillPublisher = require('./skillPublisher');
30
+ const envFingerprint = require('./envFingerprint');
31
+ const a2a = require('./a2aProtocol');
32
+
33
+ const SKILL2GEP_ID_PREFIX = 'gene_s2g_';
34
+ const CAPSULE_ID_PREFIX = 'cap_s2g_';
35
+ const LOG_FILE = 'skill2gep_log.jsonl';
36
+ const STATE_FILE = 'skill2gep_state.json';
37
+ const DEFAULT_HOOK_TIMEOUT_MS = 25000;
38
+
39
+ // Paper + docs we cite in the rationale field so agents can explain to users
40
+ // why we ship Genes/Capsules in addition to the human-facing Skill.
41
+ // NOTE: The paper validates Gene as a control-dense interface on 45 scientific
42
+ // code-solving scenarios with Gemini 3.1 Pro/Flash Lite. Generalization to other
43
+ // agent domains (web ops, long tool chains, multi-agent negotiation, etc.) is an
44
+ // explicit assumption of this tool, not a proven result. The rationale string
45
+ // we emit reflects this.
46
+ const RATIONALE_LINKS = {
47
+ paper: 'Wang, Ren, Zhang. From Procedural Skills to Strategy Genes. arXiv:2604.15097',
48
+ protocol: 'https://evomap.ai/wiki/16-gep-protocol',
49
+ skill_store: 'https://evomap.ai/wiki/31-skill-store',
50
+ };
51
+
52
+ const RATIONALE_TEXT = ''
53
+ + 'Emitted both the human-facing Skill and the machine-facing GEP asset(s). '
54
+ + 'In the paper\'s domain (45 scientific code-solving scenarios, Gemini 3.1 '
55
+ + 'Pro/Flash Lite; ' + 'Wang, Ren, Zhang, arXiv:2604.15097'
56
+ + '), Gene-as-control-interface outperforms procedural SKILL.md. '
57
+ + 'Generalization to other domains is an assumption of this tool, not a '
58
+ + 'proven result; outcome quality depends on the source Skill and on real '
59
+ + 'execution evidence. See ' + 'https://evomap.ai/wiki/16-gep-protocol'
60
+ + ' for the protocol.';
61
+
62
+ function ensureDir(p) { if (!fs.existsSync(p)) fs.mkdirSync(p, { recursive: true }); }
63
+
64
+ function readJsonSafe(p, fallback) {
65
+ try {
66
+ if (!fs.existsSync(p)) return fallback;
67
+ const raw = fs.readFileSync(p, 'utf8');
68
+ if (!raw.trim()) return fallback;
69
+ return JSON.parse(raw);
70
+ } catch (_) { return fallback; }
71
+ }
72
+
73
+ function appendJsonl(p, obj) {
74
+ ensureDir(path.dirname(p));
75
+ fs.appendFileSync(p, JSON.stringify(obj) + '\n', 'utf8');
76
+ }
77
+
78
+ function logPath() { return path.join(paths.getMemoryDir(), LOG_FILE); }
79
+ function statePath() { return path.join(paths.getMemoryDir(), STATE_FILE); }
80
+
81
+ function readState() { return readJsonSafe(statePath(), { seen: {} }); }
82
+ function writeState(s) {
83
+ ensureDir(path.dirname(statePath()));
84
+ const tmp = statePath() + '.tmp';
85
+ fs.writeFileSync(tmp, JSON.stringify(s, null, 2) + '\n', 'utf8');
86
+ fs.renameSync(tmp, statePath());
87
+ }
88
+
89
+ function slugify(s) {
90
+ return String(s || '')
91
+ .toLowerCase()
92
+ .replace(/[^a-z0-9]+/g, '_')
93
+ .replace(/^_+|_+$/g, '')
94
+ .slice(0, 60);
95
+ }
96
+
97
+ function shortHash(s) {
98
+ return crypto.createHash('sha256').update(String(s || '')).digest('hex').slice(0, 10);
99
+ }
100
+
101
+ function normalizeCmd(s) { return String(s || '').replace(/\s+/g, ' ').trim(); }
102
+
103
+ // ---------------------------------------------------------------------------
104
+ // Parse a procedural SKILL.md / markdown workflow into structured sections.
105
+ // ---------------------------------------------------------------------------
106
+ function parseSkillMd(skillMd) {
107
+ const text = String(skillMd || '');
108
+
109
+ let frontmatter = {};
110
+ const fmMatch = text.match(/^---\n([\s\S]*?)\n---\n/);
111
+ let body = text;
112
+ if (fmMatch) {
113
+ fmMatch[1].split(/\n/).forEach((line) => {
114
+ const kv = line.match(/^([A-Za-z0-9_-]+)\s*:\s*(.*)$/);
115
+ if (kv) frontmatter[kv[1].trim().toLowerCase()] = kv[2].trim();
116
+ });
117
+ body = text.slice(fmMatch[0].length);
118
+ }
119
+
120
+ const sections = {};
121
+ let currentKey = '_preamble';
122
+ sections[currentKey] = [];
123
+ body.split(/\n/).forEach((line) => {
124
+ const hdr = line.match(/^##+\s+(.+?)\s*$/);
125
+ if (hdr) {
126
+ currentKey = hdr[1].toLowerCase().trim();
127
+ sections[currentKey] = [];
128
+ } else {
129
+ sections[currentKey].push(line);
130
+ }
131
+ });
132
+ Object.keys(sections).forEach((k) => { sections[k] = sections[k].join('\n').trim(); });
133
+
134
+ function pickSection(keywords) {
135
+ for (const kw of keywords) {
136
+ for (const k of Object.keys(sections)) {
137
+ if (k.indexOf(kw) !== -1) return sections[k];
138
+ }
139
+ }
140
+ return '';
141
+ }
142
+
143
+ const signals = [];
144
+ const signalSource = (frontmatter.description || '') + '\n' + pickSection([
145
+ 'trigger', 'when to use', 'when', 'use when', 'scenario',
146
+ ]);
147
+ signalSource.split(/[`,.\n]/).forEach((tok) => {
148
+ const s = tok.trim().toLowerCase().replace(/[^a-z0-9_]/g, '_').replace(/^_+|_+$/g, '');
149
+ if (s.length >= 3 && s.length <= 40 && /[a-z]/.test(s) && signals.indexOf(s) === -1 && !/^\d+$/.test(s)) {
150
+ signals.push(s);
151
+ }
152
+ });
153
+
154
+ const strategy = [];
155
+ const strategyBlock = pickSection(['workflow', 'strategy', 'steps', 'procedure', 'quick start', 'how to']);
156
+ strategyBlock.split(/\n/).forEach((line) => {
157
+ const step = line.match(/^\s*(?:\d+\.|[-*])\s+(.+?)\s*$/);
158
+ if (step) {
159
+ const s = step[1].trim();
160
+ if (s.length >= 5 && s.length <= 300) strategy.push(s);
161
+ }
162
+ });
163
+
164
+ const avoid = [];
165
+ const avoidBlock = pickSection(['avoid', 'pitfall', 'anti-pattern', 'common mistake', 'do not', 'forbidden']);
166
+ avoidBlock.split(/\n/).forEach((line) => {
167
+ const step = line.match(/^\s*(?:\d+\.|[-*])\s+(.+?)\s*$/);
168
+ if (step) {
169
+ const s = step[1].trim();
170
+ if (s.length >= 5 && s.length <= 300) avoid.push(s);
171
+ }
172
+ });
173
+
174
+ const validation = [];
175
+ const valBlock = pickSection(['validation', 'test', 'verify', 'check']);
176
+ const fenceRe = /```(?:bash|sh|shell)?\s*\n([\s\S]*?)\n```/g;
177
+ let fm;
178
+ while ((fm = fenceRe.exec(valBlock)) !== null) {
179
+ fm[1].split(/\n/).forEach((ln) => {
180
+ const t = ln.trim();
181
+ if (t && !t.startsWith('#') && t.length <= 300) validation.push(t);
182
+ });
183
+ }
184
+
185
+ const preconditions = [];
186
+ const preBlock = pickSection(['precondition', 'requirement', 'prerequisite']);
187
+ preBlock.split(/\n/).forEach((line) => {
188
+ const step = line.match(/^\s*(?:\d+\.|[-*])\s+(.+?)\s*$/);
189
+ if (step) preconditions.push(step[1].trim());
190
+ });
191
+
192
+ return {
193
+ frontmatter: frontmatter,
194
+ sections: sections,
195
+ name: frontmatter.name || (sections['_preamble'] || '').split(/\n/)[0].replace(/^#+\s*/, '').trim(),
196
+ description: frontmatter.description || '',
197
+ signals_match: signals.slice(0, 8),
198
+ strategy: strategy.slice(0, 10),
199
+ avoid: avoid.slice(0, 5),
200
+ validation: validation.slice(0, 5),
201
+ preconditions: preconditions.slice(0, 4),
202
+ };
203
+ }
204
+
205
+ // ---------------------------------------------------------------------------
206
+ // Synthesize a draft Gene from parsed Skill + execution trace.
207
+ // Validation is delegated to skillDistiller.validateSynthesizedGene() so that
208
+ // we reuse the sanitization, ID-rewrite, forbidden-path, and validation-cmd
209
+ // policy rules already hardened there.
210
+ // ---------------------------------------------------------------------------
211
+ function synthesizeGene(parsed, execution, opts) {
212
+ const traceSignals = Array.isArray(execution && execution.signals) ? execution.signals : [];
213
+ const mergedSignals = Array.from(new Set([].concat(parsed.signals_match || [], traceSignals)));
214
+
215
+ // AVOID items live in their own top-level `avoid` field on the Gene, NOT as
216
+ // synthetic "AVOID: ..." strategy steps. Skill Store / Hub renderers should
217
+ // surface them in a dedicated "## Avoid" section so downstream consumers
218
+ // never mistake anti-patterns for positive steps.
219
+ const strategy = [];
220
+ (parsed.strategy || []).forEach((s) => strategy.push(s));
221
+ if (strategy.length < 3) {
222
+ strategy.push('Identify the dominant trigger signals from the Skill description.');
223
+ strategy.push('Apply the smallest targeted change that satisfies the Skill workflow.');
224
+ strategy.push('Run the Skill validation commands and abort if any fails.');
225
+ }
226
+ const avoid = Array.isArray(parsed.avoid) ? parsed.avoid.slice(0, 5) : [];
227
+
228
+ // Filter validation commands through the same allow-list that
229
+ // validateSynthesizedGene will later apply (node/npm/npx only). If the
230
+ // skill's original validation lines are all blocked (e.g. pytest, bash)
231
+ // we would end up with an empty gene.validation, which would silently
232
+ // defeat the Capsule coverage check. In that case, behavior depends on
233
+ // strict mode:
234
+ // - strict=true -> refuse to synthesize; caller gets an explicit error.
235
+ // - strict=false -> fall back to a concrete but near-trivial 'node --version'
236
+ // so Gene.validation is never empty. The quality
237
+ // heuristics field records that a fallback was used.
238
+ const policyCheck = require('./policyCheck');
239
+ const rawValidations = Array.isArray(parsed.validation) ? parsed.validation : [];
240
+ const allowedValidations = rawValidations
241
+ .map((v) => String(v || '').trim())
242
+ .filter((v) => v && policyCheck.isValidationCommandAllowed(v));
243
+ const fallbackUsed = allowedValidations.length === 0;
244
+ const strict = Boolean(opts && opts.strict);
245
+ if (strict && fallbackUsed) {
246
+ return {
247
+ valid: false,
248
+ errors: [
249
+ 'strict mode: no allowed validation commands found in the Skill. '
250
+ + 'GEP validation only permits "node "/"npm "/"npx " prefixes. '
251
+ + 'Rewrite the Skill\'s validation section with those, or drop --strict.',
252
+ ],
253
+ gene: null,
254
+ };
255
+ }
256
+ const validation = fallbackUsed ? ['node --version'] : allowedValidations;
257
+
258
+ // Quality heuristics: lightweight signals for downstream reviewers (and the
259
+ // paper-assumption disclaimer). These do NOT guarantee Gene quality; they
260
+ // only describe how much signal we managed to extract from the source Skill.
261
+ const avoidCount = (parsed.avoid || []).length;
262
+ const strategySteps = (parsed.strategy || []).length;
263
+ const qualityHeuristics = {
264
+ strategy_steps: strategySteps,
265
+ avoid_count: avoidCount,
266
+ validation_declared_count: rawValidations.length,
267
+ validation_runnable_count: allowedValidations.length,
268
+ validation_fallback_used: fallbackUsed,
269
+ signals_extracted: (parsed.signals_match || []).length,
270
+ preconditions_extracted: (parsed.preconditions || []).length,
271
+ };
272
+
273
+ const skillSlug = slugify(parsed.name || (opts && opts.skillName) || 'skill');
274
+ const draft = {
275
+ type: 'Gene',
276
+ id: SKILL2GEP_ID_PREFIX + skillSlug,
277
+ summary: (parsed.description || strategy[0] || 'Reusable strategy distilled from Skill').slice(0, 200),
278
+ category: inferCategory(mergedSignals, parsed.description),
279
+ signals_match: mergedSignals.slice(0, 8),
280
+ preconditions: (parsed.preconditions && parsed.preconditions.length > 0)
281
+ ? parsed.preconditions
282
+ : ['Skill ' + (parsed.name || 'unknown') + ' has just been executed locally'],
283
+ strategy: strategy.slice(0, 10),
284
+ avoid: avoid,
285
+ constraints: {
286
+ max_files: (opts && opts.maxFiles) || skillDistiller.DISTILLED_MAX_FILES,
287
+ forbidden_paths: ['.git', 'node_modules'],
288
+ },
289
+ validation: validation,
290
+ schema_version: '1.6.0',
291
+ _source: {
292
+ kind: 'skill2gep',
293
+ skill_name: parsed.name || null,
294
+ skill_platform: (opts && opts.platform) || null,
295
+ skill_hash: opts && opts.skillHash ? opts.skillHash : null,
296
+ rationale_paper: RATIONALE_LINKS.paper,
297
+ paper_scope: 'code-science (arXiv:2604.15097, 45 tasks, Gemini 3.1 Pro/Flash Lite)',
298
+ claims_outside_scope: 'assumption',
299
+ quality_heuristics: qualityHeuristics,
300
+ },
301
+ };
302
+
303
+ const assetsDir = paths.getGepAssetsDir();
304
+ const existingGenesJson = readJsonSafe(path.join(assetsDir, 'genes.json'), { genes: [] });
305
+ const existingGenes = Array.isArray(existingGenesJson.genes) ? existingGenesJson.genes : [];
306
+ const result = skillDistiller.validateSynthesizedGene(draft, existingGenes);
307
+ return result;
308
+ }
309
+
310
+ function inferCategory(signals, description) {
311
+ const hay = ((description || '') + ' ' + (signals || []).join(' ')).toLowerCase();
312
+ if (/error|fail|repair|rollback|bug|fix|guard/.test(hay)) return 'repair';
313
+ if (/feature|add|implement|new capability|innovate/.test(hay)) return 'innovate';
314
+ return 'optimize';
315
+ }
316
+
317
+ // ---------------------------------------------------------------------------
318
+ // Forgery guard: a Capsule with status=success but no execution evidence is
319
+ // rejected outright. This is the single most important defence against agents
320
+ // "hallucinating" a successful run just to bulk up the community registry.
321
+ // ---------------------------------------------------------------------------
322
+ function detectForgery(execution) {
323
+ const trace = Array.isArray(execution && execution.trace) ? execution.trace : [];
324
+ const blast = execution && execution.blast_radius ? execution.blast_radius : null;
325
+ const files = blast ? Number(blast.files || 0) : 0;
326
+ const lines = blast ? Number(blast.lines || 0) : 0;
327
+ const status = execution && execution.status ? String(execution.status) : 'failed';
328
+ if (status !== 'success') return null;
329
+ if (trace.length === 0) return 'empty_execution_trace';
330
+ if (files === 0 && lines === 0) return 'zero_blast_radius_with_success';
331
+ const anyExitRecorded = trace.some((t) => Number.isInteger(t && t.exit));
332
+ if (!anyExitRecorded) return 'no_exit_code_in_trace';
333
+ return null;
334
+ }
335
+
336
+ // ---------------------------------------------------------------------------
337
+ // Assemble a Capsule from a gene reference + real execution evidence.
338
+ // Cross-references Gene.validation -> execution.trace. If any validation
339
+ // command is missing from the trace, we refuse to emit the Capsule and
340
+ // return a diagnostic instead.
341
+ // ---------------------------------------------------------------------------
342
+ function assembleCapsule(gene, execution, opts) {
343
+ const trace = Array.isArray(execution && execution.trace) ? execution.trace : [];
344
+ const geneValidations = Array.isArray(gene.validation) ? gene.validation : [];
345
+ const traceCmds = new Set(trace.map((t) => normalizeCmd(t && t.cmd)));
346
+ const missing = [];
347
+ geneValidations.forEach((v) => { if (!traceCmds.has(normalizeCmd(v))) missing.push(v); });
348
+ if (missing.length > 0) {
349
+ return { ok: false, reason: 'validation_coverage_missing', missing: missing };
350
+ }
351
+ for (const v of geneValidations) {
352
+ const t = trace.find((tt) => normalizeCmd(tt && tt.cmd) === normalizeCmd(v));
353
+ if (t && !Number.isInteger(t.exit)) {
354
+ return { ok: false, reason: 'validation_missing_exit_code', cmd: v };
355
+ }
356
+ }
357
+
358
+ const scoreRaw = execution && execution.score != null ? Number(execution.score) : null;
359
+ const status = execution && execution.status ? String(execution.status) : 'failed';
360
+ let score;
361
+ if (Number.isFinite(scoreRaw)) {
362
+ score = Math.max(0, Math.min(1, scoreRaw));
363
+ } else {
364
+ score = status === 'success' ? 0.8 : 0.2;
365
+ }
366
+
367
+ const blast = execution && execution.blast_radius ? execution.blast_radius : { files: 0, lines: 0 };
368
+ const env = (envFingerprint && typeof envFingerprint.captureEnvFingerprint === 'function')
369
+ ? envFingerprint.captureEnvFingerprint()
370
+ : ((execution && execution.env_fingerprint) || null);
371
+
372
+ // gene.id may have been rewritten by validateSynthesizedGene (e.g. to
373
+ // DISTILLED_ID_PREFIX); extract whatever suffix is there instead of
374
+ // assuming our original SKILL2GEP_ID_PREFIX is still present.
375
+ const geneIdSuffix = String(gene.id).replace(/^gene_[a-z0-9]+_/, '').replace(/^gene_/, '');
376
+ const idKey = shortHash(gene.id + '|' + (execution && execution.started_at || new Date().toISOString()));
377
+ const capsule = {
378
+ type: 'Capsule',
379
+ id: CAPSULE_ID_PREFIX + slugify(geneIdSuffix) + '_' + idKey,
380
+ gene: gene.id,
381
+ trigger: Array.isArray(execution && execution.trigger) ? execution.trigger : (gene.signals_match || []).slice(0, 6),
382
+ summary: (execution && execution.summary) || ('Applied ' + gene.id + ' on scenario ' + (opts && opts.scenario || 'local skill invocation')),
383
+ confidence: Math.max(0, Math.min(1, score)),
384
+ blast_radius: { files: Number(blast.files || 0), lines: Number(blast.lines || 0) },
385
+ outcome: { status: status, score: score },
386
+ success_reason: status === 'success' ? ((execution && execution.success_reason) || 'Skill workflow completed and all declared validations passed.') : null,
387
+ env_fingerprint: env || { os: process.platform, node: process.version },
388
+ source_type: 'skill2gep_hook',
389
+ strategy: Array.isArray(gene.strategy) ? gene.strategy.slice() : [],
390
+ content: (execution && execution.content_summary) || buildContentSummary(trace, blast),
391
+ execution_trace: trace.map((t, i) => ({
392
+ step: Number.isInteger(t && t.step) ? t.step : i + 1,
393
+ cmd: String(t && t.cmd || ''),
394
+ exit: Number.isInteger(t && t.exit) ? t.exit : null,
395
+ stdout_tail: t && t.stdout_tail ? String(t.stdout_tail).slice(0, 300) : '',
396
+ })),
397
+ schema_version: '1.6.0',
398
+ };
399
+ return { ok: true, capsule: capsule };
400
+ }
401
+
402
+ function buildContentSummary(trace, blast) {
403
+ const okCount = trace.filter((t) => Number(t && t.exit) === 0).length;
404
+ const files = blast ? Number(blast.files || 0) : 0;
405
+ const lines = blast ? Number(blast.lines || 0) : 0;
406
+ return 'Ran ' + trace.length + ' validation command(s), ' + okCount + ' passed. Blast radius: ' + files + ' files, ' + lines + ' lines.';
407
+ }
408
+
409
+ // ---------------------------------------------------------------------------
410
+ // Main entrypoint: runOnSkillInvocation(opts)
411
+ //
412
+ // opts = {
413
+ // skillPath: absolute path to SKILL.md or skill directory (required)
414
+ // skillName: optional, auto-derived from frontmatter otherwise
415
+ // platform: 'cursor' | 'claude-code' | 'codex' | generic (optional)
416
+ // execution: {
417
+ // status: 'success' | 'failed' (REQUIRED for Capsule emission)
418
+ // score: 0..1
419
+ // started_at: ISO8601 string
420
+ // trace: [ { step, cmd, exit, stdout_tail }, ... ]
421
+ // blast_radius: { files, lines }
422
+ // trigger: [ signals actually fired ]
423
+ // signals: [ signals actually detected ]
424
+ // summary: optional one-line result
425
+ // success_reason, env_fingerprint, content_summary -- all optional
426
+ // },
427
+ // publish: boolean (default true, from SKILL2GEP_AUTO_PUBLISH)
428
+ // }
429
+ //
430
+ // Returns {
431
+ // ok: boolean,
432
+ // gene, capsule,
433
+ // capsule_diagnostic, // null, or reason why we refused to emit a Capsule
434
+ // persist_errors, // list of local storage errors (upsert, write state)
435
+ // publish_requested, // true if auto-publish was attempted
436
+ // publish_promise, // Promise<publish result> if publish was fired
437
+ // rationale, // one-line explanation citing the paper
438
+ // reason, errors // set when ok=false
439
+ // }
440
+ // ---------------------------------------------------------------------------
441
+ function runOnSkillInvocation(opts) {
442
+ opts = opts || {};
443
+ const skillPath = opts.skillPath;
444
+ if (!skillPath || !fs.existsSync(skillPath)) {
445
+ return { ok: false, reason: 'skill_path_missing', skillPath: skillPath };
446
+ }
447
+
448
+ let skillMdPath = skillPath;
449
+ try {
450
+ const stat = fs.statSync(skillPath);
451
+ if (stat.isDirectory()) skillMdPath = path.join(skillPath, 'SKILL.md');
452
+ } catch (_) { return { ok: false, reason: 'skill_path_unreadable' }; }
453
+ if (!fs.existsSync(skillMdPath)) return { ok: false, reason: 'skill_md_missing', tried: skillMdPath };
454
+
455
+ let skillMd;
456
+ try { skillMd = fs.readFileSync(skillMdPath, 'utf8'); }
457
+ catch (err) { return { ok: false, reason: 'skill_md_read_failed', error: err && err.message ? err.message : String(err) }; }
458
+ const skillHash = shortHash(skillMd);
459
+
460
+ // Idempotency: if we've already distilled this exact skill content + the
461
+ // same execution fingerprint, skip to avoid duplicate community uploads.
462
+ const execHash = shortHash(JSON.stringify({
463
+ trace: (opts.execution && opts.execution.trace) || [],
464
+ br: opts.execution && opts.execution.blast_radius || null,
465
+ status: opts.execution && opts.execution.status || null,
466
+ }));
467
+ const state = readState();
468
+ const seenKey = skillHash + ':' + execHash;
469
+ if (state.seen && state.seen[seenKey]) {
470
+ return { ok: false, reason: 'already_distilled', gene: state.seen[seenKey].gene, capsule: state.seen[seenKey].capsule };
471
+ }
472
+
473
+ const parsed = parseSkillMd(skillMd);
474
+ const geneResult = synthesizeGene(parsed, opts.execution || {}, {
475
+ skillName: opts.skillName || parsed.name,
476
+ platform: opts.platform || null,
477
+ skillHash: skillHash,
478
+ strict: Boolean(opts.strict),
479
+ });
480
+ if (!geneResult.valid) {
481
+ appendJsonl(logPath(), {
482
+ timestamp: new Date().toISOString(), status: 'gene_validation_failed',
483
+ skill: opts.skillName || parsed.name, errors: geneResult.errors,
484
+ });
485
+ return { ok: false, reason: 'gene_validation_failed', errors: geneResult.errors };
486
+ }
487
+ const gene = geneResult.gene;
488
+
489
+ let capsule = null;
490
+ let capsuleDiag = null;
491
+ if (opts.execution && opts.execution.status) {
492
+ const forgery = detectForgery(opts.execution);
493
+ if (forgery) {
494
+ capsuleDiag = { reason: 'capsule_rejected_forgery', detail: forgery };
495
+ } else {
496
+ const capRes = assembleCapsule(gene, opts.execution, { scenario: opts.scenario || parsed.name });
497
+ if (capRes.ok) capsule = capRes.capsule; else capsuleDiag = capRes;
498
+ }
499
+ }
500
+
501
+ const persistErrors = [];
502
+ try { assetStore.upsertGene(gene); }
503
+ catch (err) { persistErrors.push({ step: 'upsertGene', error: err && err.message ? err.message : String(err) }); }
504
+ if (capsule) {
505
+ try { assetStore.appendCapsule(capsule); }
506
+ catch (err) { persistErrors.push({ step: 'appendCapsule', error: err && err.message ? err.message : String(err) }); }
507
+ }
508
+
509
+ state.seen = state.seen || {};
510
+ state.seen[seenKey] = {
511
+ at: new Date().toISOString(),
512
+ gene: gene.id,
513
+ capsule: capsule ? capsule.id : null,
514
+ };
515
+ try { writeState(state); } catch (err) { persistErrors.push({ step: 'writeState', error: err && err.message ? err.message : String(err) }); }
516
+
517
+ const shouldPublish = (opts.publish !== false)
518
+ && String(process.env.SKILL2GEP_AUTO_PUBLISH || 'true').toLowerCase() !== 'false';
519
+
520
+ // Kick off publish in background. We never block the hook on the Hub -- if
521
+ // the network is slow, the hook still exits in bounded time and we log the
522
+ // publish promise's outcome asynchronously.
523
+ let publishPromise = null;
524
+ if (shouldPublish) {
525
+ publishPromise = publishAssets(gene, capsule).then((result) => {
526
+ appendJsonl(logPath(), {
527
+ timestamp: new Date().toISOString(),
528
+ status: 'publish_result',
529
+ skill: opts.skillName || parsed.name,
530
+ gene_id: gene.id,
531
+ capsule_id: capsule ? capsule.id : null,
532
+ publish: result,
533
+ });
534
+ return result;
535
+ }).catch((err) => {
536
+ const fail = { ok: false, error: err && err.message ? err.message : String(err) };
537
+ appendJsonl(logPath(), {
538
+ timestamp: new Date().toISOString(),
539
+ status: 'publish_error',
540
+ skill: opts.skillName || parsed.name,
541
+ gene_id: gene.id,
542
+ capsule_id: capsule ? capsule.id : null,
543
+ publish: fail,
544
+ });
545
+ return fail;
546
+ });
547
+ }
548
+
549
+ appendJsonl(logPath(), {
550
+ timestamp: new Date().toISOString(),
551
+ status: 'distilled',
552
+ skill: opts.skillName || parsed.name,
553
+ gene_id: gene.id,
554
+ capsule_id: capsule ? capsule.id : null,
555
+ capsule_diagnostic: capsuleDiag,
556
+ persist_errors: persistErrors,
557
+ published_requested: shouldPublish,
558
+ });
559
+
560
+ return {
561
+ ok: true,
562
+ gene: gene,
563
+ capsule: capsule,
564
+ capsule_diagnostic: capsuleDiag,
565
+ persist_errors: persistErrors,
566
+ publish_requested: shouldPublish,
567
+ publish_promise: publishPromise,
568
+ rationale: RATIONALE_TEXT,
569
+ };
570
+ }
571
+
572
+ // ---------------------------------------------------------------------------
573
+ // Community upload. Two channels, both best-effort:
574
+ //
575
+ // 1. Skill Store: skillPublisher.publishSkillToHub() converts the Gene into a
576
+ // SKILL.md and POSTs it to /a2a/skill/store/publish. This is the human-
577
+ // facing channel that also serves as a Gene index.
578
+ //
579
+ // 2. GEP publish bundle: a2a.buildPublishBundle({gene, capsule}) signs both
580
+ // assets with the node secret and a2a.httpTransportSend() POSTs them to
581
+ // /a2a/publish (the A2A message_type routing). This is the auditable
582
+ // machine-facing channel used by solidify.js for normal capsule
583
+ // publishing.
584
+ //
585
+ // We always try channel 1 for the Gene; channel 2 only runs if a real Capsule
586
+ // is attached (Gene-only bundles are not supported by the A2A schema). Each
587
+ // channel's failure is isolated so a broken one cannot block the other.
588
+ // ---------------------------------------------------------------------------
589
+ function publishAssets(gene, capsule) {
590
+ const skillPromise = publishSkillChannel(gene);
591
+ const bundlePromise = capsule ? publishBundleChannel(gene, capsule) : Promise.resolve({ ok: false, skipped: 'no_capsule' });
592
+ return Promise.all([skillPromise, bundlePromise]).then(([skill, bundle]) => ({
593
+ skill_store: skill,
594
+ gep_bundle: bundle,
595
+ ok: Boolean((skill && skill.ok) || (bundle && bundle.ok)),
596
+ }));
597
+ }
598
+
599
+ function publishSkillChannel(gene) {
600
+ try {
601
+ const p = skillPublisher.publishSkillToHub(gene);
602
+ return Promise.resolve(p).catch((err) => ({ ok: false, error: err && err.message ? err.message : String(err) }));
603
+ } catch (err) {
604
+ return Promise.resolve({ ok: false, error: err && err.message ? err.message : String(err) });
605
+ }
606
+ }
607
+
608
+ function publishBundleChannel(gene, capsule) {
609
+ const hubUrl = a2a.getHubUrl && a2a.getHubUrl();
610
+ if (!hubUrl) return Promise.resolve({ ok: false, error: 'no_hub_url' });
611
+ let message;
612
+ try {
613
+ // buildPublishBundle mutates asset_id on the objects it receives, so
614
+ // clone first to avoid polluting the locally stored gene/capsule.
615
+ const geneClone = JSON.parse(JSON.stringify(gene));
616
+ const capsuleClone = JSON.parse(JSON.stringify(capsule));
617
+ message = a2a.buildPublishBundle({ gene: geneClone, capsule: capsuleClone });
618
+ } catch (err) {
619
+ return Promise.resolve({ ok: false, error: 'build_publish_bundle_failed: ' + (err && err.message ? err.message : String(err)) });
620
+ }
621
+ try {
622
+ const send = a2a.httpTransportSend(message, { hubUrl: hubUrl, timeoutMs: 15000 });
623
+ return Promise.resolve(send).catch((err) => ({ ok: false, error: err && err.message ? err.message : String(err) }));
624
+ } catch (err) {
625
+ return Promise.resolve({ ok: false, error: err && err.message ? err.message : String(err) });
626
+ }
627
+ }
628
+
629
+ module.exports = {
630
+ SKILL2GEP_ID_PREFIX,
631
+ CAPSULE_ID_PREFIX,
632
+ RATIONALE_LINKS,
633
+ RATIONALE_TEXT,
634
+ parseSkillMd,
635
+ synthesizeGene,
636
+ detectForgery,
637
+ assembleCapsule,
638
+ runOnSkillInvocation,
639
+ publishAssets,
640
+ publishSkillChannel,
641
+ publishBundleChannel,
642
+ logPath,
643
+ statePath,
644
+ DEFAULT_HOOK_TIMEOUT_MS,
645
+ };