@evomap/evolver-core 2.0.0-beta.1 → 2.0.0-beta.3

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 (110) hide show
  1. package/assets/gep/genes.jsonl +5 -0
  2. package/dist/algo/candidateAssembly.js +23 -14
  3. package/dist/algo/capabilityCandidates.d.ts +2 -0
  4. package/dist/algo/capabilityCandidates.js +5 -0
  5. package/dist/algo/conversationSniffer.d.ts +18 -0
  6. package/dist/algo/conversationSniffer.js +132 -0
  7. package/dist/algo/cycleEngine.d.ts +11 -0
  8. package/dist/algo/cycleEngine.js +14 -6
  9. package/dist/algo/cycleFailureClassifier.d.ts +1 -1
  10. package/dist/algo/cycleFailureClassifier.js +13 -5
  11. package/dist/algo/geneIntake.d.ts +14 -4
  12. package/dist/algo/geneIntake.js +37 -9
  13. package/dist/algo/geneSelection.d.ts +18 -1
  14. package/dist/algo/geneSelection.js +42 -18
  15. package/dist/algo/index.d.ts +2 -0
  16. package/dist/algo/index.js +2 -0
  17. package/dist/algo/memoryGraph.d.ts +62 -0
  18. package/dist/algo/memoryGraph.js +86 -0
  19. package/dist/algo/orchestrator.d.ts +3 -0
  20. package/dist/algo/orchestrator.js +14 -2
  21. package/dist/assetstore/assetSidecarRecords.d.ts +23 -0
  22. package/dist/assetstore/assetSidecarRecords.js +142 -0
  23. package/dist/assetstore/assetSidecarRecovery.d.ts +48 -0
  24. package/dist/assetstore/assetSidecarRecovery.js +288 -0
  25. package/dist/assetstore/assetStoreHealth.d.ts +75 -0
  26. package/dist/assetstore/assetStoreHealth.js +277 -0
  27. package/dist/assetstore/assetStoreLayout.d.ts +2 -0
  28. package/dist/assetstore/assetStoreLayout.js +6 -0
  29. package/dist/assetstore/assetStoreStorage.d.ts +42 -0
  30. package/dist/assetstore/assetStoreStorage.js +318 -0
  31. package/dist/assetstore/assetSyncLedger.d.ts +32 -0
  32. package/dist/assetstore/assetSyncLedger.js +66 -0
  33. package/dist/assetstore/index.d.ts +5 -1
  34. package/dist/assetstore/index.js +5 -1
  35. package/dist/assetstore/localJsonl.d.ts +10 -2
  36. package/dist/assetstore/localJsonl.js +93 -37
  37. package/dist/assetstore/pendingSignals.js +3 -1
  38. package/dist/assetstore/provenance.d.ts +20 -2
  39. package/dist/assetstore/provenance.js +92 -56
  40. package/dist/assetstore/provider.d.ts +2 -0
  41. package/dist/assetstore/reviewFilter.js +5 -2
  42. package/dist/assetstore/reviewLedger.d.ts +14 -2
  43. package/dist/assetstore/reviewLedger.js +78 -43
  44. package/dist/assetstore/seedGenes.d.ts +3 -0
  45. package/dist/assetstore/seedGenes.js +134 -0
  46. package/dist/benchmark/antiGeneBenchmark.d.ts +2 -0
  47. package/dist/benchmark/antiGeneBenchmark.js +11 -1
  48. package/dist/benchmark/antiGeneImpact.d.ts +16 -0
  49. package/dist/benchmark/antiGeneImpact.js +34 -0
  50. package/dist/benchmark/antiGeneRollout.d.ts +2 -0
  51. package/dist/benchmark/antiGeneRollout.js +13 -1
  52. package/dist/benchmark/index.d.ts +2 -1
  53. package/dist/benchmark/index.js +2 -1
  54. package/dist/benchmark/triggerShift.d.ts +62 -0
  55. package/dist/benchmark/triggerShift.js +106 -0
  56. package/dist/events/eventArchive.d.ts +65 -0
  57. package/dist/events/eventArchive.js +343 -0
  58. package/dist/events/eventStore.js +2 -12
  59. package/dist/events/ingest.d.ts +1 -1
  60. package/dist/events/ingest.js +2 -0
  61. package/dist/events/paths.d.ts +1 -1
  62. package/dist/events/paths.js +2 -2
  63. package/dist/events/public.d.ts +3 -1
  64. package/dist/events/public.js +2 -1
  65. package/dist/events/retention.d.ts +24 -1
  66. package/dist/events/retention.js +133 -37
  67. package/dist/exec/autoExec.d.ts +6 -1
  68. package/dist/exec/autoExec.js +34 -0
  69. package/dist/exec/autonomousCycle.d.ts +2 -0
  70. package/dist/exec/autonomousCycle.js +5 -0
  71. package/dist/exec/claudeBridge.d.ts +7 -2
  72. package/dist/exec/claudeBridge.js +115 -14
  73. package/dist/exec/prompt.js +9 -0
  74. package/dist/exec/runnerRegistry.d.ts +64 -18
  75. package/dist/exec/runnerRegistry.js +341 -38
  76. package/dist/exec/selfPrObfuscation.d.ts +2 -1
  77. package/dist/exec/selfPrObfuscation.js +19 -6
  78. package/dist/hub/agentDirectory.d.ts +90 -0
  79. package/dist/hub/agentDirectory.js +104 -0
  80. package/dist/hub/bindings.js +23 -3
  81. package/dist/hub/capability.d.ts +14 -2
  82. package/dist/hub/fake.d.ts +4 -2
  83. package/dist/hub/fake.js +3 -2
  84. package/dist/hub/index.d.ts +1 -0
  85. package/dist/hub/index.js +1 -0
  86. package/dist/mailbox/catalog.js +3 -0
  87. package/dist/mailbox/ipcServer.js +2 -2
  88. package/dist/mailbox/store.d.ts +14 -0
  89. package/dist/mailbox/store.js +44 -2
  90. package/dist/material/consumer.js +9 -6
  91. package/dist/material/index.d.ts +1 -0
  92. package/dist/material/index.js +1 -0
  93. package/dist/material/materialArchive.d.ts +81 -0
  94. package/dist/material/materialArchive.js +466 -0
  95. package/dist/material/materialStore.d.ts +1 -0
  96. package/dist/material/materialStore.js +6 -13
  97. package/dist/ops/savingsCore.js +1 -2
  98. package/dist/ops/selfUpdate.d.ts +10 -1
  99. package/dist/ops/selfUpdate.js +64 -15
  100. package/dist/schema/common.d.ts +1 -1
  101. package/dist/schema/common.js +1 -1
  102. package/dist/schema/material.d.ts +5 -5
  103. package/dist/trace/trajectoryExport.js +2 -2
  104. package/dist/util/fileLock.d.ts +19 -2
  105. package/dist/util/fileLock.js +166 -31
  106. package/dist/wire/geneHints.d.ts +42 -1
  107. package/dist/wire/geneHints.js +52 -1
  108. package/dist/wire/index.d.ts +14 -2
  109. package/dist/wire/index.js +1 -1
  110. package/package.json +6 -1
@@ -0,0 +1,5 @@
1
+ {"type":"Gene","schema_version":"1.12.1","id":"gene_seed_publish_feishu_doc","category":"innovate","signals_match":["publish_markdown_to_feishu","create_feishu_doc","export_report_to_feishu","publish_results_to_lark_doc","feishu_doc","lark_doc"],"preconditions":["lark-cli is installed and available on PATH.","lark-cli auth status reports a ready user or bot identity."],"strategy":["Run lark-cli doctor and require an ok result before publishing.","Use the Docs v2 API and avoid deprecated Docs v1 flags.","Write long Markdown content to a temporary file and pass it as an @file input.","Create or update the document with the intended user or bot identity, then return the document URL.","Never overwrite local lark-cli credential files while preparing the document."],"constraints":{"max_files":2,"forbidden_paths":[".git","node_modules","~/.lark-cli/config.json"]},"validation":["node --version"],"summary":"Publish Markdown content as a Feishu/Lark document through the official CLI, using file-backed content and Docs v2.","asset_id":"sha256:4cdcf2b2a30bcb29a3310360c3cb16c7fc7d427d23a43c836ffa5076cfe3c041"}
2
+ {"type":"Gene","schema_version":"1.12.1","id":"gene_seed_conventional_git_commit","category":"optimize","signals_match":["git_commit","create_commit","commit_changes","conventional_commit","stage_and_commit","write_commit_message"],"preconditions":["A git repository has staged or unstaged changes that should become one logical commit."],"strategy":["Inspect git status plus the staged or unstaged diff before choosing a commit shape.","Pick a Conventional Commits type and optional scope from the actual changed files.","Stage only the intended logical files and never stage credentials or unrelated changes.","Write a present-tense imperative subject under 72 characters, with body or footer when needed.","Do not amend, force-push, hard-reset, or bypass hooks without explicit operator approval."],"constraints":{"max_files":50,"forbidden_paths":[".git","node_modules"]},"validation":["node --version"],"summary":"Create a focused Conventional Commits-style git commit from the real diff while avoiding secrets and unrelated changes.","asset_id":"sha256:8f0acd8af3d0a94d6f50e7fe20d96ff39dc982022b851b204eddce47e177f467"}
3
+ {"type":"Gene","schema_version":"1.12.1","id":"gene_seed_poll_bugbot_review","category":"optimize","signals_match":["poll_bugbot","bugbot_review","wait_for_ci_review","pr_review_gate","cursor_bugbot","pr_opened"],"preconditions":["An open GitHub PR exists and Cursor Bugbot is expected to review it."],"strategy":["Poll the Cursor Bugbot check by name until it reaches a terminal conclusion or the operator timeout is reached.","Treat SUCCESS as mergeable only when required checks are green and there are no unresolved Bugbot comments.","Treat NEUTRAL as not passed; fetch and summarize Cursor Bugbot inline comments for the operator.","On FAILURE or ACTION_REQUIRED, surface the findings and do not merge automatically.","Only squash-merge when the operator explicitly asked for merge and the review gate is clean."],"constraints":{"max_files":1,"forbidden_paths":[".git","node_modules"]},"validation":["node --version"],"summary":"Wait for Cursor Bugbot on a GitHub PR and gate merge decisions on the actual check conclusion and comments.","asset_id":"sha256:87737bf9d2702667c31e336fc79096f4da7f12a84baae25e827ad62b4a08d332"}
4
+ {"type":"Gene","schema_version":"1.12.1","id":"gene_seed_gateway_timeout_recovery","category":"repair","signals_match":["gateway_timeout","upstream_timeout","http_524","http_504","request_timed_out","retry_on_timeout"],"preconditions":["A tool call, fetch, subagent request, or long command returned a gateway-class timeout."],"strategy":["Treat gateway timeout as transient or size-driven until one bounded recovery attempt proves otherwise.","Retry the exact same operation once, then stop retrying the monolithic call.","If the retry also times out, split the work by file, directory, endpoint, record, section, or time window.","Run independent slices in parallel when safe and merge the results.","Apply the same decomposition recursively only to the slice that still times out."],"constraints":{"max_files":1,"forbidden_paths":[".git","node_modules"]},"validation":["node --version"],"summary":"Recover from gateway or upstream timeouts by retrying once, then decomposing the work instead of looping the same large call.","asset_id":"sha256:fe6820477e47a8eb848ecfaa33cdb4242d9af77b78fac21fa1d716244984615c"}
5
+ {"type":"Gene","schema_version":"1.12.1","id":"gene_seed_github_webhook_listener","category":"innovate","signals_match":["github_webhook_listener","bugbot_webhook","passive_pr_notifications","notify_when_bugbot_finishes","webhook_tunnel","github_pr_inbox"],"preconditions":["A developer machine can run a local listener and an outbound tunnel for GitHub webhook delivery."],"strategy":["Use an HMAC-validated local listener for GitHub webhook payloads and deduplicate delivery ids.","Expose the listener through an outbound-only tunnel rather than an inbound public port.","Write vetted notifications into a local inbox with restrictive permissions; never execute webhook payloads.","On session start, re-fetch PR state through GitHub before surfacing any inbox item.","Rotate webhook secrets periodically and immediately after suspected exposure."],"constraints":{"max_files":20,"forbidden_paths":[".git","node_modules"]},"validation":["node --version"],"summary":"Deploy a local HMAC-validated GitHub webhook listener that turns PR and Bugbot events into safe operator notifications.","asset_id":"sha256:a64e8d0a91cebd54290451527987230572d8a5bfe90b6ca73cd3368fd3d1f689"}
@@ -2,7 +2,7 @@ import { aggregateLearningHistory } from '../assetstore/learningHistory.js';
2
2
  import { reuseSentiment } from '../ops/reuseOutcomes.js';
3
3
  import { tagOverlapScore } from '../signals/expand.js';
4
4
  import { bannedGenesFromFailures } from './bans.js';
5
- import { isDistilledGeneId } from './geneIntake.js';
5
+ import { geneGenerationSource } from './geneIntake.js';
6
6
  function asStrings(v) {
7
7
  return Array.isArray(v) ? v.filter((x) => typeof x === 'string') : [];
8
8
  }
@@ -18,14 +18,15 @@ function candidateIds(geneId, assetId) {
18
18
  function isBannedCandidate(banned, geneId, assetId) {
19
19
  return candidateIds(geneId, assetId).some((id) => banned.has(id));
20
20
  }
21
- function passesInjectedCandidateGates(candidate, opts) {
21
+ function passesInjectedCandidateGates(candidate, opts, provenance, review) {
22
22
  const assetId = candidate.assetId;
23
23
  if (opts.provenance && !opts.includeUntrusted) {
24
- if (!assetId || opts.provenance.get(assetId)?.trusted !== true)
24
+ if (!assetId || provenance?.get(assetId)?.trusted !== true)
25
25
  return false;
26
26
  }
27
27
  if (opts.review) {
28
- if (!assetId || !opts.review.isApproved(assetId))
28
+ const reviewRecord = assetId ? review?.get(assetId) : undefined;
29
+ if (!assetId || (reviewRecord !== undefined && reviewRecord.state !== 'approved'))
29
30
  return false;
30
31
  }
31
32
  return true;
@@ -79,20 +80,23 @@ export async function assembleSelectionPool(store, signals, opts = {}) {
79
80
  const genes = await store.list('Gene', limit);
80
81
  const banned = await computeBans(store, signals, limit);
81
82
  const sigSet = new Set(signals);
83
+ const provenance = opts.provenance?.snapshot();
84
+ const review = opts.review?.snapshot();
82
85
  const out = [];
83
86
  const distilledFallback = [];
84
87
  const antiWarnings = [];
85
88
  for (const g of genes) {
86
89
  // Trust-first (#30): untrusted (e.g. hub-ingested) genes are excluded from the candidate pool by default;
87
90
  // they enter only with includeUntrusted or after an explicit promotion. Provenance is keyed by asset_id.
88
- if (opts.provenance && !opts.includeUntrusted && !opts.provenance.isTrusted(String(g.asset_id)))
91
+ if (opts.provenance && !opts.includeUntrusted && provenance?.get(String(g.asset_id))?.trusted === false)
89
92
  continue;
90
93
  // Review-first (#89/#91): auto-distilled drafts land quarantined until a human approves; rejected drafts stay
91
94
  // out too. No record → eligible (cycle/migrate genes). Symmetric to the provenance trust-first filter above.
92
95
  // Probation (#306): includeProbation lets a quarantined draft be TRIED so it can earn evidence; a rejected
93
96
  // draft is never tried. Off (default) keeps quarantined drafts out until approval.
94
- if (opts.review && !opts.review.isApproved(String(g.asset_id))
95
- && (!opts.includeProbation || opts.review.get(String(g.asset_id))?.state === 'rejected'))
97
+ const reviewRecord = review?.get(String(g.asset_id));
98
+ if (opts.review && reviewRecord !== undefined && reviewRecord.state !== 'approved'
99
+ && (!opts.includeProbation || reviewRecord.state === 'rejected'))
96
100
  continue;
97
101
  const geneId = typeof g['id'] === 'string' ? String(g['id']) : String(g.asset_id);
98
102
  const assetId = String(g.asset_id);
@@ -104,18 +108,20 @@ export async function assembleSelectionPool(store, signals, opts = {}) {
104
108
  const literal = signalsMatch.some((m) => sigSet.has(m));
105
109
  const relevant = literal || tagOverlapScore(signals, { signalsMatch, geneId, category, summary }) > 0;
106
110
  if (!relevant) {
107
- // #97: a trusted, approved, non-banned distilled gene that doesn't match the live signals is not a normal
108
- // candidate, but it IS eligible as a last-resort fallback (selection uses it only when nothing clears the
109
- // floor). NB in v2 "distilled" = any intaken gene (intakeGene tags them all gene_distilled_), not just skill-
110
- // derived as in v1 eligibility here is gated by trust/review/ban, not by skill provenance.
111
+ // #97: a trusted, approved, non-banned distilled (or evolved) gene that doesn't match the live signals is not
112
+ // a normal candidate, but it IS eligible as a last-resort fallback (selection uses it only when nothing clears
113
+ // the floor). Eligibility here is gated by trust/review/ban, not by skill provenance. Provenance is read from
114
+ // generation_meta (V1 #302); a legacy gene without it falls back to the `gene_distilled_` id namespace.
111
115
  // Lightweight: no learning-history aggregation — it is picked first-match, not ranked by health.
112
- if (isDistilledGeneId(geneId)) {
116
+ const gsrc = geneGenerationSource(g, geneId);
117
+ if (gsrc === 'distilled' || gsrc === 'evolved') {
113
118
  distilledFallback.push({
114
119
  geneId,
115
120
  assetId,
116
121
  signalsMatch,
117
122
  view: emptyLearningView(geneId),
118
123
  reuseCount: 0,
124
+ generationSource: gsrc,
119
125
  ...(category ? { category } : {}),
120
126
  ...(summary ? { summary } : {}),
121
127
  });
@@ -145,7 +151,7 @@ export async function assembleSelectionPool(store, signals, opts = {}) {
145
151
  if (opts.hubCandidates && opts.hubCandidates.length > 0) {
146
152
  const localIds = new Set(out.map((c) => c.geneId));
147
153
  for (const h of opts.hubCandidates) {
148
- if (!passesInjectedCandidateGates(h, opts))
154
+ if (!passesInjectedCandidateGates(h, opts, provenance, review))
149
155
  continue;
150
156
  if (localIds.has(h.geneId))
151
157
  continue; // a trusted local gene already covers this id
@@ -160,7 +166,10 @@ export async function assembleSelectionPool(store, signals, opts = {}) {
160
166
  }
161
167
  }
162
168
  for (const a of await store.list('AntiGene', limit)) {
163
- if (opts.review && !opts.review.isApproved(String(a.asset_id)))
169
+ // AntiGene is negative memory that changes an autonomous prompt. Unlike legacy/cycle-authored Genes, it must
170
+ // never inherit ReviewLedger's backward-compatible "no record = approved" default: no ledger, no record,
171
+ // quarantined, and rejected all fail closed. Only an explicit human approval can enable warning injection.
172
+ if (review?.get(String(a.asset_id))?.state !== 'approved')
164
173
  continue;
165
174
  const trigger = asStrings(a['trigger']);
166
175
  const avoid = asStrings(a['avoid']);
@@ -1,3 +1,4 @@
1
+ import type { ConversationCapabilityHit } from './conversationSniffer.js';
1
2
  /** The "five-question shape" of a capability proposal (ported verbatim from v1). */
2
3
  export interface FiveQuestionShape {
3
4
  title: string;
@@ -30,6 +31,7 @@ export interface ExtractCandidatesInput {
30
31
  toolCalls?: readonly string[];
31
32
  signals?: readonly string[];
32
33
  failedCapsules?: readonly FailedCapsuleLike[];
34
+ conversationHits?: readonly ConversationCapabilityHit[];
33
35
  }
34
36
  /**
35
37
  * Mine capability candidates from three sources (ported v1): repeated tool calls (>=3), active opportunity
@@ -80,6 +80,11 @@ export function extractCapabilityCandidates(inp) {
80
80
  const title = `Repeated tool usage: ${tool}`;
81
81
  out.push(candidate('transcript', stableHash(title), title, signals, `Observed ${count} occurrences of tool call ${tool}.`, signalTags));
82
82
  }
83
+ // A2. Conversation sniffer hits: a transcript explicitly proved a reusable capability.
84
+ for (const hit of inp.conversationHits ?? []) {
85
+ const hitSignals = [...new Set([...(hit.signals ?? []), ...signals].map((s) => String(s)).filter(Boolean))];
86
+ out.push(candidate('transcript', stableHash('conversation:' + hit.id), hit.title || 'Verified reusable conversation capability', hitSignals, hit.evidence.length > 0 ? hit.evidence.slice(0, 2).join('; ') : hit.summary, [...new Set([...signalTags, ...expandSignals(hitSignals)])]));
87
+ }
83
88
  // B. Signal-mapped candidates: predefined defensive/opportunity signals.
84
89
  for (const sc of SIGNAL_CANDIDATES) {
85
90
  if (!signals.some((s) => s === sc.signal || s.startsWith(sc.signal + ':')))
@@ -0,0 +1,18 @@
1
+ import type { SignalSourceTurn } from '../signals/extractor.js';
2
+ export interface ConversationSnifferTurn extends SignalSourceTurn {
3
+ role?: string;
4
+ }
5
+ export interface ConversationCapabilityHit {
6
+ id: string;
7
+ title: string;
8
+ summary: string;
9
+ signals: string[];
10
+ evidence: string[];
11
+ score: number;
12
+ reasons: string[];
13
+ toolCalls: string[];
14
+ }
15
+ export interface ConversationSnifferOptions {
16
+ maxHits?: number;
17
+ }
18
+ export declare function sniffConversationCapabilities(turns: readonly ConversationSnifferTurn[], opts?: ConversationSnifferOptions): ConversationCapabilityHit[];
@@ -0,0 +1,132 @@
1
+ import { createHash } from 'node:crypto';
2
+ import { redactString } from '../hub/sanitize.js';
3
+ const REUSABLE_RE = /\b(reusable|repeatable|workflow|playbook|runbook|capability|procedure|pattern|recipe|documented|future runs?|next time|can reuse|reuse this)\b|可复用|复用|能力|流程|工作流/i;
4
+ const PROOF_RE = /\b(validated|verified|passed|green|success(?:ful|fully)?|succeeded|works?|completed|published|uploaded|recorded:true|exit code:?\s*0|all tests passed)\b|验证|通过|成功|已发布|可用/i;
5
+ const FAILURE_RE = /\b(failed|failure|error|exception|traceback|exit code:?\s*[1-9]|not working|unable to)\b|失败|错误|报错/i;
6
+ const EXIT_ZERO_RE = /\bexit code:?\s*0\b/i;
7
+ const EXIT_NON_ZERO_RE = /\bexit code:?\s*[1-9]\d*\b/i;
8
+ const DOMAIN_TOKENS = [
9
+ [/\b(lark|feishu|飞书)\b/i, 'lark'],
10
+ [/\b(github|gh|pull request|bugbot)\b/i, 'github'],
11
+ [/\b(playwright|screenshot|visual)\b/i, 'playwright'],
12
+ [/\b(vitest|jest|test)\b/i, 'test'],
13
+ [/\b(pnpm|npm|yarn)\b/i, 'package-manager'],
14
+ [/\b(hub|publish|marketplace)\b/i, 'hub-publish'],
15
+ [/\b(release|checksum|sha256)\b/i, 'release'],
16
+ [/\b(api|http|endpoint)\b/i, 'api'],
17
+ [/\b(document|docs?|markdown)\b/i, 'docs'],
18
+ ];
19
+ function cleanText(value, max = 220) {
20
+ const text = redactString(String(value ?? '').replace(/\s+/g, ' ').trim());
21
+ return text.length > max ? `${text.slice(0, max - 3)}...` : text;
22
+ }
23
+ function turnText(turn) {
24
+ return cleanText([turn.text, turn.toolResult, turn.errorMessage].filter(Boolean).join('\n'), 500);
25
+ }
26
+ function stableId(parts) {
27
+ return `conversation_capability_${createHash('sha1').update(JSON.stringify(parts), 'utf8').digest('hex').slice(0, 12)}`;
28
+ }
29
+ function normalizeSignal(value) {
30
+ return value.toLowerCase().replace(/[^a-z0-9]+/g, '_').replace(/^_+|_+$/g, '').slice(0, 48);
31
+ }
32
+ function collectToolCalls(turns) {
33
+ const seen = new Set();
34
+ for (const turn of turns) {
35
+ const tool = cleanText(turn.toolName ?? '', 80);
36
+ if (tool)
37
+ seen.add(tool);
38
+ }
39
+ return [...seen].slice(0, 8);
40
+ }
41
+ function domainSignals(text, toolCalls) {
42
+ const out = new Set();
43
+ for (const [re, signal] of DOMAIN_TOKENS) {
44
+ if (re.test(text))
45
+ out.add(signal);
46
+ }
47
+ for (const tool of toolCalls) {
48
+ const normalized = normalizeSignal(tool);
49
+ if (normalized)
50
+ out.add(normalized);
51
+ }
52
+ return [...out].slice(0, 6);
53
+ }
54
+ function isToolTurn(turn) {
55
+ return turn.role === 'tool' || Boolean(turn.toolName);
56
+ }
57
+ function classifyToolOutcome(turn) {
58
+ const text = turnText(turn);
59
+ if (EXIT_NON_ZERO_RE.test(text))
60
+ return 'failure';
61
+ if (EXIT_ZERO_RE.test(text))
62
+ return 'success';
63
+ if (PROOF_RE.test(text) && !FAILURE_RE.test(text))
64
+ return 'success';
65
+ if (FAILURE_RE.test(text))
66
+ return 'failure';
67
+ return 'unknown';
68
+ }
69
+ function hasTerminalSuccessfulToolRun(turns) {
70
+ let lastDecisive = 'unknown';
71
+ for (const turn of turns) {
72
+ if (!isToolTurn(turn))
73
+ continue;
74
+ const outcome = classifyToolOutcome(turn);
75
+ if (outcome !== 'unknown')
76
+ lastDecisive = outcome;
77
+ }
78
+ return lastDecisive === 'success';
79
+ }
80
+ function pickEvidence(turns, matcher, max = 3) {
81
+ const evidence = [];
82
+ for (const turn of turns) {
83
+ const text = turnText(turn);
84
+ if (!text || !matcher.test(text))
85
+ continue;
86
+ evidence.push(cleanText(text, 180));
87
+ if (evidence.length >= max)
88
+ break;
89
+ }
90
+ return evidence;
91
+ }
92
+ function pickSummary(turns, reusableEvidence, proofEvidence) {
93
+ const assistant = turns
94
+ .filter((turn) => turn.role === 'assistant' && !turn.isMeta)
95
+ .map(turnText)
96
+ .find((text) => text.length >= 40 && (REUSABLE_RE.test(text) || PROOF_RE.test(text)));
97
+ return cleanText(assistant ?? reusableEvidence[0] ?? proofEvidence[0] ?? 'Verified reusable conversation capability.', 180);
98
+ }
99
+ export function sniffConversationCapabilities(turns, opts = {}) {
100
+ const usableTurns = turns.filter((turn) => !turn.isMeta);
101
+ if (usableTurns.length === 0)
102
+ return [];
103
+ const allText = usableTurns.map(turnText).filter(Boolean).join('\n');
104
+ if (!allText.trim())
105
+ return [];
106
+ const reusableEvidence = pickEvidence(usableTurns, REUSABLE_RE);
107
+ const proofEvidence = pickEvidence(usableTurns, PROOF_RE);
108
+ if (reusableEvidence.length === 0 || proofEvidence.length === 0)
109
+ return [];
110
+ const failureOnly = FAILURE_RE.test(allText) && !PROOF_RE.test(allText);
111
+ if (failureOnly)
112
+ return [];
113
+ if (!hasTerminalSuccessfulToolRun(usableTurns))
114
+ return [];
115
+ const toolCalls = collectToolCalls(usableTurns);
116
+ const domain = domainSignals(allText, toolCalls);
117
+ const signals = [...new Set(['reusable_capability', 'verified_workflow', ...domain])].slice(0, 8);
118
+ const score = 4 + Math.min(2, reusableEvidence.length) + Math.min(2, proofEvidence.length) + Math.min(2, toolCalls.length);
119
+ const summary = pickSummary(usableTurns, reusableEvidence, proofEvidence);
120
+ const titleSignal = domain[0] ?? 'verified workflow';
121
+ const hit = {
122
+ id: stableId({ summary, signals, evidence: [...reusableEvidence, ...proofEvidence].slice(0, 4) }),
123
+ title: `Verified reusable capability: ${titleSignal}`,
124
+ summary,
125
+ signals,
126
+ evidence: [...reusableEvidence, ...proofEvidence].slice(0, 4),
127
+ score,
128
+ reasons: ['reusable_marker', 'proof_marker', ...(toolCalls.length > 0 ? ['tool_evidence'] : [])],
129
+ toolCalls,
130
+ };
131
+ return [hit].slice(0, opts.maxHits ?? 1);
132
+ }
@@ -10,11 +10,13 @@ import type { PersonalityStore } from '../personality/store.js';
10
10
  import { type ResolutionStatus } from './solidify.js';
11
11
  import type { ProofOfWork } from '../schema/proofOfWork.js';
12
12
  import { type ClassifyRecentEvent } from './cycleFailureClassifier.js';
13
+ import type { MemoryGraphGeneEvidence } from './memoryGraph.js';
13
14
  export interface TriggerEval {
14
15
  trigger: boolean;
15
16
  reasons: string[];
16
17
  valueScore: number;
17
18
  }
19
+ export type ExecutionFailureKind = 'spawn_failed' | 'timeout' | 'cancelled' | 'permission_denied' | 'non_zero_exit' | 'invalid_output' | 'runtime_error';
18
20
  export interface ExecutionResult {
19
21
  outcome: {
20
22
  status: 'success' | 'failed';
@@ -23,6 +25,9 @@ export interface ExecutionResult {
23
25
  };
24
26
  proofOfWork?: ProofOfWork;
25
27
  strongEvidence?: boolean;
28
+ /** Structured runner failure metadata. Safe to persist; unlike sessionLog it contains no transcript text. */
29
+ failureKind?: ExecutionFailureKind;
30
+ exitCode?: number | null;
26
31
  /**
27
32
  * The agent run's transcript (stdout, plus stderr when present), attached by the execution layer on a FAILED
28
33
  * outcome. It is the host-side context classifyCycleFailure needs to reach the host_no_transcript /
@@ -102,6 +107,8 @@ export interface CycleInput {
102
107
  distilledFallback?: readonly GeneCandidateInput[];
103
108
  /** Advisory-only AntiGene warnings matched upstream for this cycle's base signals. */
104
109
  antiWarnings?: readonly AntiWarning[];
110
+ /** Bounded structured outcome evidence for explainability and prompt enrichment. */
111
+ memoryEvidence?: readonly MemoryGraphGeneEvidence[];
105
112
  /**
106
113
  * Optional triage context for the cycle.failed event (PORT v1 #279 issue-reporter half). When the caller can
107
114
  * supply a session transcript or a list of recent failed cycles, classifyCycleFailure runs and the resulting
@@ -125,11 +132,15 @@ export interface CycleResult {
125
132
  cycleId: string;
126
133
  triggered: boolean;
127
134
  finalStage: CycleStage;
135
+ /** Real CycleEngine returns this on every path; optional keeps injected legacy engines source-compatible. */
136
+ producedValue?: boolean;
128
137
  decision?: GeneDecision;
129
138
  mutation?: Mutation;
130
139
  capsule?: Capsule;
131
140
  event?: EvolutionEvent;
132
141
  resolutionStatus?: ResolutionStatus;
142
+ failureKind?: ExecutionFailureKind;
143
+ exitCode?: number | null;
133
144
  reasons: string[];
134
145
  }
135
146
  /**
@@ -183,6 +183,8 @@ export class CycleEngine {
183
183
  // Locality must recognise V2's real gene prefix `gene_distilled_` (isDistilledGeneId), not just the
184
184
  // V1-only `gene_auto_` / content-hash `sha256:` forms — otherwise this gate stays shut for every real V2
185
185
  // gene and classifyCycleFailure never runs (mirror of isLocalGeneratedGene in cycleFailureClassifier.ts).
186
+ // NB the prefix is now a NAMESPACE marker, not a provenance tag (the authoritative source is generation_meta,
187
+ // V1 #302); this call site only has a gene id, so it falls back to the prefix.
186
188
  const localCurrentGene = current?.geneId !== undefined && (isDistilledGeneId(current.geneId) || /^sha256:/.test(current.geneId) || /^gene_auto_/.test(current.geneId));
187
189
  // Run classification when we have ANY host context (an exec transcript or an injected failureContext) OR a
188
190
  // local-gene blast to judge; otherwise stay default-open (unclassified, no payload).
@@ -198,6 +200,10 @@ export class CycleEngine {
198
200
  });
199
201
  return r.reason ? { failure_class: r.failureClass, failure_class_reason: r.reason } : { failure_class: r.failureClass };
200
202
  };
203
+ const executionMetadata = (exec) => ({
204
+ ...(exec.failureKind !== undefined ? { failureKind: exec.failureKind } : {}),
205
+ ...(exec.exitCode !== undefined ? { exitCode: exec.exitCode } : {}),
206
+ });
201
207
  const strategy = resolveStrategy({ name: input.strategyName, signals: cycleSignals, cycleCount: historyCycleCount(ingestor, 1000, input.cycleId) });
202
208
  const cycleCategory = categoryForStrategy(input.category, strategy);
203
209
  let stage = 'none';
@@ -224,7 +230,7 @@ export class CycleEngine {
224
230
  if (!trig.trigger) {
225
231
  await ingestor.ingest({ type: 'cycle.aborted', human: { title: `cycle ${input.cycleId} 抑制` }, payload: { cycleId: input.cycleId, reasons: trig.reasons } });
226
232
  advance('aborted');
227
- return { cycleId: input.cycleId, triggered: false, finalStage: stage, reasons: trig.reasons };
233
+ return { cycleId: input.cycleId, triggered: false, finalStage: stage, producedValue: false, reasons: trig.reasons };
228
234
  }
229
235
  // 选 gene(可解释决策). Exploration: when recent cycles plateau, enable drift to escape local optima.
230
236
  const recentOutcomes = recentCycleOutcomes(ingestor, 100);
@@ -295,11 +301,11 @@ export class CycleEngine {
295
301
  const epi = epigeneticPenaltyForIds(candidateIds(c), envKey, geneOutcomes);
296
302
  return epi > 0 ? { ...c, epigeneticPenalty: epi } : c;
297
303
  });
298
- const decision = (await this.deps.selection.run({ signals: baseSignals, candidates, floor: input.selectionFloor, ...(input.forcedGeneId !== undefined ? { forcedGeneId: input.forcedGeneId } : {}), ...(exploration ? { exploration } : {}), ...(distilledFallback.length > 0 ? { distilledFallback } : {}), ...(input.antiWarnings && input.antiWarnings.length > 0 ? { antiWarnings: input.antiWarnings } : {}) }, { now, cycleId: input.cycleId, ...(this.deps.rng ? { rng: this.deps.rng } : {}) }));
304
+ const decision = (await this.deps.selection.run({ signals: baseSignals, candidates, floor: input.selectionFloor, ...(input.forcedGeneId !== undefined ? { forcedGeneId: input.forcedGeneId } : {}), ...(exploration ? { exploration } : {}), ...(distilledFallback.length > 0 ? { distilledFallback } : {}), ...(input.antiWarnings && input.antiWarnings.length > 0 ? { antiWarnings: input.antiWarnings } : {}), ...(input.memoryEvidence && input.memoryEvidence.length > 0 ? { memoryEvidence: input.memoryEvidence } : {}) }, { now, cycleId: input.cycleId, ...(this.deps.rng ? { rng: this.deps.rng } : {}) }));
299
305
  await ingestor.ingest({
300
306
  type: 'decision.gene_selected',
301
307
  human: { title: `选 gene ${decision.selectedGeneId ?? '(innovate)'}`, why: decision.candidates.map((c) => `${c.geneId}:${c.score.toFixed(3)}`).join(', ') || '无候选→innovate' },
302
- payload: { cycleId: input.cycleId, selectedGeneId: decision.selectedGeneId, ...(decision.selectedAssetId ? { selectedAssetId: decision.selectedAssetId } : {}), candidates: decision.candidates, ...(decision.antiWarnings && decision.antiWarnings.length > 0 ? { antiWarnings: decision.antiWarnings } : {}), weightsVersion: decision.weightsVersion, strategy: decision.strategyName, strategyPreset: strategy.name, ...(plateau.active ? { plateau } : {}), ...(inertBanned.size > 0 ? { inertBanned: [...inertBanned] } : {}) },
308
+ payload: { cycleId: input.cycleId, selectedGeneId: decision.selectedGeneId, ...(decision.selectedAssetId ? { selectedAssetId: decision.selectedAssetId } : {}), ...(decision.selectedReason ? { selectedReason: decision.selectedReason } : {}), candidates: decision.candidates, ...(decision.antiWarnings && decision.antiWarnings.length > 0 ? { antiWarnings: decision.antiWarnings } : {}), ...(decision.memoryEvidence && decision.memoryEvidence.length > 0 ? { memoryEvidence: decision.memoryEvidence } : {}), weightsVersion: decision.weightsVersion, strategy: decision.strategyName, strategyPreset: strategy.name, ...(plateau.active ? { plateau } : {}), ...(inertBanned.size > 0 ? { inertBanned: [...inertBanned] } : {}) },
303
309
  });
304
310
  advance('gene_selected');
305
311
  const geneId = decision.selectedGeneId ?? 'ad-hoc';
@@ -329,7 +335,7 @@ export class CycleEngine {
329
335
  await ingestor.ingest({ type: 'cycle.failed', human: { title: `cycle ${input.cycleId} 执行抛错` }, payload: { cycleId: input.cycleId, error: msg, gene: geneId, env: envKey, ...failureClassPayload({ geneId }, msg) } });
330
336
  advance('failed');
331
337
  await recordPersonalityOutcome('failed', null);
332
- return { cycleId: input.cycleId, triggered: true, finalStage: stage, decision, mutation, reasons: [...reasons, `执行异常: ${msg}`] };
338
+ return { cycleId: input.cycleId, triggered: true, finalStage: stage, producedValue: false, decision, mutation, reasons: [...reasons, `执行异常: ${msg}`] };
333
339
  }
334
340
  if (input.solidifyPermit) {
335
341
  let permit;
@@ -358,12 +364,13 @@ export class CycleEngine {
358
364
  reason: permit.reason,
359
365
  gene: geneId,
360
366
  env: envKey,
367
+ ...executionMetadata(exec),
361
368
  ...failureClassPayload({ geneId }),
362
369
  },
363
370
  });
364
371
  advance('failed');
365
372
  await recordPersonalityOutcome('failed', null);
366
- return { cycleId: input.cycleId, triggered: true, finalStage: stage, decision, mutation, reasons: [...reasons, reason] };
373
+ return { cycleId: input.cycleId, triggered: true, finalStage: stage, producedValue: false, decision, mutation, ...executionMetadata(exec), reasons: [...reasons, reason] };
367
374
  }
368
375
  }
369
376
  // solidify → Capsule
@@ -392,6 +399,7 @@ export class CycleEngine {
392
399
  outcome: exec.outcome,
393
400
  gene: geneId,
394
401
  env: envKey,
402
+ ...executionMetadata(exec),
395
403
  // Only forward blast radius as a no-op signal when it was measured from a git_diff proof. solidify.ts
396
404
  // forces blast to {0,0} for non-git_diff proofs (artifact_hash/external_receipt/tool_call_trace),
397
405
  // where {0,0} means "blast unknown", NOT "no change" — forwarding it would mis-classify a productive
@@ -412,6 +420,6 @@ export class CycleEngine {
412
420
  }
413
421
  // 人格统计回写(用途②): 用本轮真实 outcome/score 回写当轮人格桶, 供下一轮自然选择靠拢. 未注入 ⇒ no-op.
414
422
  await recordPersonalityOutcome(exec.outcome.status, exec.outcome.score);
415
- return { cycleId: input.cycleId, triggered: true, finalStage: stage, decision, mutation, capsule: sol.capsule, event, resolutionStatus: sol.resolutionStatus, reasons };
423
+ return { cycleId: input.cycleId, triggered: true, finalStage: stage, producedValue: sol.producedValue, decision, mutation, capsule: sol.capsule, event, resolutionStatus: sol.resolutionStatus, ...executionMetadata(exec), reasons };
416
424
  }
417
425
  }
@@ -1,5 +1,5 @@
1
1
  /** Bucket the failure falls into. unclassified = default-open (treat as a real evolver bug worth filing).
2
- * Bucket string matches V1 (EvoMap/evolver-private-dev#279 commit a89e123e) byte-for-byte so wire / UI
2
+ * Bucket string matches V1 byte-for-byte so wire / UI
3
3
  * consumers can branch on the same value regardless of producer. */
4
4
  export type CycleFailureClass = 'host_no_transcript' | 'host_provider_error' | 'local_gene_no_blast' | 'unclassified';
5
5
  /** Shape of a recent cycle event the classifier reads. Loose so callers can pass partial DTOs. */
@@ -12,13 +12,17 @@
12
12
  import { isDistilledGeneId } from './geneIntake.js';
13
13
  // "[NO SESSION LOGS FOUND]" et al. — the host gave evolver nothing to evolve from.
14
14
  const HOST_NO_TRANSCRIPT_RE = /\[no session logs? found\]|no session log|no transcript/i;
15
+ // Interactive runner gates can stop execution before the provider is invoked. They still belong to the
16
+ // host_provider_error compatibility bucket because the local gene never had a chance to run.
17
+ const HOST_RUNNER_ERR_RE = /Workspace Trust Required/i;
15
18
  // Host LLM-provider errors. None of these strings are emitted by evolver core (v1 #279 verified the same;
16
19
  // keeping the regex byte-for-byte equivalent), so their presence means the host Agent's provider call failed.
17
- const HOST_PROVIDER_ERR_RE = /\bLLM ERROR\b|\bMaxTokens\b|field MaxTokens|max_tokens[^\n]{0,24}invalid|insufficient_quota|invalid_api_key|\brate limit(?:ed| exceeded)?\b|quota exceeded|context length exceeded|maximum context length/i;
20
+ const HOST_PROVIDER_ERR_RE = /\bLLM ERROR\b|\bMaxTokens\b|field MaxTokens|max_tokens[^\n]{0,24}invalid|insufficient_quota|invalid_api_key|\brate limit(?:ed| exceeded)?\b|quota exceeded|context length exceeded|maximum context length|disabled Claude subscription access|Use an Anthropic API key|not authenticated|authentication required|login required|not logged in/i;
18
21
  // Locality prefixes a "locally-generated" gene id can carry:
19
- // - `gene_distilled_` — the ONLY prefix V2 actually mints (geneIntake.DISTILLED_ID_PREFIX,
20
- // surfaced via isDistilledGeneId). Without this the whole `local_gene_no_blast` bucket never
21
- // fired for real V2 genes (they all carry this prefix). Reuse the single-source-of-truth helper.
22
+ // - `gene_distilled_` — the namespace V2 mints for every intaken gene (geneIntake.DISTILLED_ID_PREFIX,
23
+ // surfaced via isDistilledGeneId). NB this is a NAMESPACE marker now, not a provenance tag: the authoritative
24
+ // source is generation_meta (V1 #302, read via geneGenerationSource), but this call site only has a gene id, so
25
+ // it falls back to the prefix. Without this the whole `local_gene_no_blast` bucket never fired for real V2 genes.
22
26
  // - `gene_auto_` — V1-only legacy form (V2 never produces it); kept so V1-shaped inputs still match.
23
27
  // - `sha256:` — a content asset_id (not a logical gene id); kept for back-compat / loose callers.
24
28
  function isLocalGeneratedGene(geneId) {
@@ -60,7 +64,11 @@ export function classifyCycleFailure(opts) {
60
64
  if (hasSessionLog && (!log.trim() || HOST_NO_TRANSCRIPT_RE.test(log))) {
61
65
  return { failureClass: 'host_no_transcript', reason: 'no session transcript to evolve from' };
62
66
  }
63
- // (b) Host LLM-provider error surfaced in the transcript.
67
+ // (b) Host runner preflight or LLM-provider error surfaced in the transcript.
68
+ const runner = hasSessionLog ? log.match(HOST_RUNNER_ERR_RE) : null;
69
+ if (runner) {
70
+ return { failureClass: 'host_provider_error', reason: `host runner error: ${String(runner[0]).slice(0, 60)}` };
71
+ }
64
72
  const prov = hasSessionLog ? log.match(HOST_PROVIDER_ERR_RE) : null;
65
73
  if (prov) {
66
74
  return { failureClass: 'host_provider_error', reason: `host LLM-provider error: ${String(prov[0]).slice(0, 60)}` };
@@ -1,10 +1,19 @@
1
- import { type Gene } from '../wire/index.js';
1
+ import { type Gene, type GenerationSource } from '../wire/index.js';
2
2
  /**
3
- * True when a gene id is a distilled (skill-derived) gene. Single source of truth for the prefix check also
4
- * consumed by candidate assembly / selection to recognise the broadly-applicable distilled genes that may be
5
- * reused as a last-resort fallback when no gene matches the live signals (ported from v1 #97).
3
+ * True when a gene id is in the `gene_distilled_` namespace. NB this prefix is now a NAMESPACE marker, NOT a
4
+ * provenance tag: v1 used it to mean "skill-derived" (auto-evolved genes were `gene_auto_`), but v2's intakeGene
5
+ * tags EVERY intaken gene this way regardless of origin. The authoritative provenance is {@link geneGenerationSource};
6
+ * this prefix check remains as a back-compat fallback for legacy pooled genes that predate generation_meta, and for
7
+ * call sites that only have a gene id (not the full record).
6
8
  */
7
9
  export declare function isDistilledGeneId(id: string | undefined | null): boolean;
10
+ /**
11
+ * The authoritative gene-generation source: read `generation_meta.source` from a gene-shaped record (V1 #302). Falls
12
+ * back to the id-prefix namespace when the record carries no generation_meta (legacy pooled genes predate the field),
13
+ * returning 'distilled' for a `gene_distilled_` id and 'manual' otherwise — so the fallback never claims a higher
14
+ * provenance tier than the prefix can vouch for. `null` means "undetermined" (no record, no recognizable id).
15
+ */
16
+ export declare function geneGenerationSource(gene: Record<string, unknown> | null | undefined, geneId?: string | null): GenerationSource | null;
8
17
  export interface GeneCandidate {
9
18
  id?: string;
10
19
  category?: string;
@@ -19,6 +28,7 @@ export interface GeneCandidate {
19
28
  validation?: readonly string[];
20
29
  routing_hint?: unknown;
21
30
  tool_policy?: unknown;
31
+ generation_meta?: unknown;
22
32
  }
23
33
  /** Minimal shape of an existing gene needed for dedup. */
24
34
  export interface ExistingGeneRef {
@@ -5,20 +5,44 @@
5
5
  // signals fully overlap an existing one (dedup, so the pool does not grow redundant). The gene's CONTENT
6
6
  // is generated upstream (the agent runtime / a distillation prompt — generative, out of core scope); this
7
7
  // slice is the structural gate. Pairs with capabilityCandidates (which proposes what to distill).
8
- import { computeAssetId, validateWire, SCHEMA_VERSION, normalizeRoutingHint, normalizeToolPolicy, stripGeneHints, } from '../wire/index.js';
8
+ import { computeAssetId, validateWire, SCHEMA_VERSION, normalizeRoutingHint, normalizeToolPolicy, normalizeGenerationMeta, stripGeneHints, } from '../wire/index.js';
9
9
  const VALID_CATEGORIES = ['repair', 'optimize', 'innovate', 'explore'];
10
10
  const DEFAULT_FORBIDDEN_PATHS = ['.git', 'node_modules'];
11
11
  const DEFAULT_MAX_FILES = 12;
12
12
  /** Id prefix every distilled (skill-derived) gene carries. Recognised via {@link isDistilledGeneId}. */
13
13
  const DISTILLED_ID_PREFIX = 'gene_distilled_';
14
14
  /**
15
- * True when a gene id is a distilled (skill-derived) gene. Single source of truth for the prefix check also
16
- * consumed by candidate assembly / selection to recognise the broadly-applicable distilled genes that may be
17
- * reused as a last-resort fallback when no gene matches the live signals (ported from v1 #97).
15
+ * True when a gene id is in the `gene_distilled_` namespace. NB this prefix is now a NAMESPACE marker, NOT a
16
+ * provenance tag: v1 used it to mean "skill-derived" (auto-evolved genes were `gene_auto_`), but v2's intakeGene
17
+ * tags EVERY intaken gene this way regardless of origin. The authoritative provenance is {@link geneGenerationSource};
18
+ * this prefix check remains as a back-compat fallback for legacy pooled genes that predate generation_meta, and for
19
+ * call sites that only have a gene id (not the full record).
18
20
  */
19
21
  export function isDistilledGeneId(id) {
20
22
  return typeof id === 'string' && id.startsWith(DISTILLED_ID_PREFIX);
21
23
  }
24
+ /**
25
+ * The authoritative gene-generation source: read `generation_meta.source` from a gene-shaped record (V1 #302). Falls
26
+ * back to the id-prefix namespace when the record carries no generation_meta (legacy pooled genes predate the field),
27
+ * returning 'distilled' for a `gene_distilled_` id and 'manual' otherwise — so the fallback never claims a higher
28
+ * provenance tier than the prefix can vouch for. `null` means "undetermined" (no record, no recognizable id).
29
+ */
30
+ export function geneGenerationSource(gene, geneId) {
31
+ const meta = gene?.['generation_meta'];
32
+ if (meta && typeof meta === 'object') {
33
+ const src = meta['source'];
34
+ if (typeof src === 'string' && (src === 'evolved' || src === 'distilled' || src === 'manual'))
35
+ return src;
36
+ }
37
+ // Legacy fallback: a `gene_distilled_` id is at best a distilled gene (v2 minted it for ALL intake sources, so it
38
+ // cannot be trusted as "evolved"); any other id is treated as manual (human-authored / externally seeded).
39
+ const id = geneId ?? (typeof gene?.['id'] === 'string' ? String(gene['id']) : null);
40
+ if (id && isDistilledGeneId(id))
41
+ return 'distilled';
42
+ if (id)
43
+ return 'manual';
44
+ return null;
45
+ }
22
46
  function fnv1a(s) {
23
47
  let h = 2166136261;
24
48
  for (let i = 0; i < s.length; i++) {
@@ -65,6 +89,9 @@ export function intakeGene(candidate, existing = []) {
65
89
  // absent when malformed/empty. The router / tool-gate reads absent === "no opinion".
66
90
  const routingHint = normalizeRoutingHint(candidate.routing_hint);
67
91
  const toolPolicy = normalizeToolPolicy(candidate.tool_policy);
92
+ // v2-delta provenance + quality metadata (v1 #302): normalized so a candidate carrying it lands canonical,
93
+ // dropped to absent when malformed/no-recognized-source.
94
+ const generationMeta = normalizeGenerationMeta(candidate.generation_meta);
68
95
  const gene = {
69
96
  type: 'Gene',
70
97
  schema_version: SCHEMA_VERSION,
@@ -81,16 +108,17 @@ export function intakeGene(candidate, existing = []) {
81
108
  ...(candidate.preconditions ? { preconditions: [...candidate.preconditions] } : {}),
82
109
  ...(routingHint ? { routing_hint: routingHint } : {}),
83
110
  ...(toolPolicy ? { tool_policy: toolPolicy } : {}),
111
+ ...(generationMeta ? { generation_meta: generationMeta } : {}),
84
112
  asset_id: '',
85
113
  };
86
- // asset_id folds in the hints (intake's own canonical shape — gep-sdk canonicalize hashes every own key).
87
- // NB: this is NOT byte-equal to what v1's createGene would hash for the same logical gene — v1 always emits
114
+ // asset_id folds in the hints + generation_meta (intake's own canonical shape — gep-sdk canonicalize hashes every
115
+ // own key). NB: this is NOT byte-equal to what v1's createGene would hash for the same logical gene — v1 always emits
88
116
  // routing_hint/tool_policy:null plus empty epigenetic_marks/learning_history/anti_patterns/preconditions that
89
117
  // v2 intake omits, so the canonical shapes differ. Self-consistent here; not a cross-impl parity guarantee.
90
118
  gene.asset_id = computeAssetId(gene) ?? '';
91
- // Structural gate validates the gep-sdk-known CORE. routing_hint / tool_policy are v2-delta (pending a
92
- // gene-schema bump), so strip them before the check — else additionalProperties:false rejects the gene at
93
- // intake. The full hinted gene stays schema-invalid until the SDK catches up — surfaced by the advisory
119
+ // Structural gate validates the gep-sdk-known CORE. routing_hint / tool_policy / generation_meta are v2-delta
120
+ // (pending a gene-schema bump), so strip them before the check — else additionalProperties:false rejects the
121
+ // gene at intake. The full hinted gene stays schema-invalid until the SDK catches up — surfaced by the advisory
94
122
  // validateWire preview (evolver_gep_build) and enforced by the hub on receipt, NOT by a local egress gate
95
123
  // (the publish/egress path is sanitize-only). Same v2-delta contract Capsule's proof_of_work had pre-1.11.0.
96
124
  const v = validateWire(stripGeneHints(gene));