@evomap/evolver 1.69.10 → 1.69.12

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