skillpool 4.3.0__py3-none-any.whl
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.
- skillpool/__init__.py +74 -0
- skillpool/__main__.py +6 -0
- skillpool/adapters/__init__.py +8 -0
- skillpool/adapters/base.py +41 -0
- skillpool/adapters/claude_adapter.py +36 -0
- skillpool/adapters/codex_adapter.py +92 -0
- skillpool/adapters/hermes_adapter.py +38 -0
- skillpool/audit/__init__.py +651 -0
- skillpool/bridge/__init__.py +16 -0
- skillpool/bridge/freeze_detector.py +134 -0
- skillpool/bridge/maintenance.py +119 -0
- skillpool/bridge/wal_manager.py +136 -0
- skillpool/clawmem_client.py +176 -0
- skillpool/cli.py +700 -0
- skillpool/combiner/__init__.py +31 -0
- skillpool/combiner/lifecycle.py +453 -0
- skillpool/combiner/models.py +99 -0
- skillpool/config.py +34 -0
- skillpool/cost/__init__.py +111 -0
- skillpool/cost/audit_hash.py +51 -0
- skillpool/cost/budget_tracker.py +66 -0
- skillpool/cost/dashboard.py +189 -0
- skillpool/cost/models.py +129 -0
- skillpool/cost/token_governor.py +264 -0
- skillpool/cost/trace_ceiling.py +38 -0
- skillpool/csdf.py +126 -0
- skillpool/evolver/__init__.py +978 -0
- skillpool/gain/__init__.py +285 -0
- skillpool/gate.py +282 -0
- skillpool/gate_policy/__init__.py +31 -0
- skillpool/gate_policy/incremental.py +157 -0
- skillpool/gate_policy/parser.py +258 -0
- skillpool/gate_policy/state_machine.py +432 -0
- skillpool/graph/__init__.py +14 -0
- skillpool/graph/ppr.py +279 -0
- skillpool/health/__init__.py +73 -0
- skillpool/health/check.py +85 -0
- skillpool/health/degradation.py +90 -0
- skillpool/health/models.py +43 -0
- skillpool/hooks/__init__.py +4 -0
- skillpool/hooks/security_scanner.py +288 -0
- skillpool/lifecycle.py +150 -0
- skillpool/materializer/__init__.py +124 -0
- skillpool/materializer/budget_cropper.py +178 -0
- skillpool/materializer/csdf_loader.py +114 -0
- skillpool/materializer/lazy_loader.py +265 -0
- skillpool/materializer/lifecycle_filter.py +93 -0
- skillpool/materializer/mapper.py +178 -0
- skillpool/materializer/models.py +66 -0
- skillpool/mcp_server.py +2005 -0
- skillpool/monitor/__init__.py +576 -0
- skillpool/monitor/bug_collector.py +392 -0
- skillpool/monitor/defect_classifier.py +218 -0
- skillpool/monitor/self_healing.py +530 -0
- skillpool/monitor/telemetry_bridge.py +197 -0
- skillpool/paradigm/__init__.py +312 -0
- skillpool/paradigm/override.py +285 -0
- skillpool/profile.py +94 -0
- skillpool/quality.py +254 -0
- skillpool/registry/__init__.py +509 -0
- skillpool/registry/models.py +98 -0
- skillpool/resolver/__init__.py +320 -0
- skillpool/resolver/cache.py +103 -0
- skillpool/resolver/circuit_breaker.py +103 -0
- skillpool/resolver/conflict_detector.py +111 -0
- skillpool/resolver/health_filter.py +38 -0
- skillpool/resolver/models.py +154 -0
- skillpool/resolver/rate_limiter.py +48 -0
- skillpool/resolver/skill_graph.py +183 -0
- skillpool/review/__init__.py +242 -0
- skillpool/review/async_queue.py +96 -0
- skillpool/review/checkpoint_runner.py +345 -0
- skillpool/review/models.py +164 -0
- skillpool/review/suspect_marker.py +39 -0
- skillpool/review/veto_evaluator.py +94 -0
- skillpool/router/__init__.py +481 -0
- skillpool/schemas.py +119 -0
- skillpool/synergy/__init__.py +240 -0
- skillpool/synergy/detector.py +5 -0
- skillpool/telemetry.py +126 -0
- skillpool/utils/__init__.py +21 -0
- skillpool/utils/changelog.py +218 -0
- skillpool/utils/logger.py +273 -0
- skillpool/utils/runtime_audit.py +163 -0
- skillpool/utils/time_utils.py +13 -0
- skillpool-4.3.0.dist-info/METADATA +21 -0
- skillpool-4.3.0.dist-info/RECORD +90 -0
- skillpool-4.3.0.dist-info/WHEEL +5 -0
- skillpool-4.3.0.dist-info/entry_points.txt +3 -0
- skillpool-4.3.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,978 @@
|
|
|
1
|
+
"""Evolver Layer — Evolution recommendations with defect accumulation.
|
|
2
|
+
|
|
3
|
+
Architecture constraint:
|
|
4
|
+
- Evolver MUST only recommend, NOT auto-publish
|
|
5
|
+
- EvolutionProposal.recommendation_only = true
|
|
6
|
+
- Evolver MUST NOT mutate Registry enabled state
|
|
7
|
+
|
|
8
|
+
Open source enhancements:
|
|
9
|
+
- Defect accumulation threshold trigger (AutoSkill)
|
|
10
|
+
- Add/Merge/Discard tri-state update (AutoSkill)
|
|
11
|
+
- Dual-loop architecture support (AutoSkill + SkillClaw)
|
|
12
|
+
|
|
13
|
+
V4.1 additions:
|
|
14
|
+
- VERIFY phase (BDD regression + 12-dim scoring + VETO V1-V6)
|
|
15
|
+
- 6 safety constraints (rate limit, MAJOR approval, rollback, cooldown, global CB lock, regression monitor)
|
|
16
|
+
- approval_token for MAJOR upgrades
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
from __future__ import annotations
|
|
20
|
+
|
|
21
|
+
__all__ = [
|
|
22
|
+
"DefectAccumulator",
|
|
23
|
+
"DefectRecord",
|
|
24
|
+
"DefectSeverity",
|
|
25
|
+
"EvolutionAction",
|
|
26
|
+
"EvolverLayer",
|
|
27
|
+
"EvolutionProposal",
|
|
28
|
+
"VerificationReport",
|
|
29
|
+
"VerificationStatus",
|
|
30
|
+
]
|
|
31
|
+
|
|
32
|
+
from dataclasses import dataclass, field
|
|
33
|
+
from datetime import UTC, datetime, timedelta
|
|
34
|
+
from enum import StrEnum
|
|
35
|
+
from pathlib import Path
|
|
36
|
+
from typing import Any
|
|
37
|
+
import hashlib
|
|
38
|
+
import logging
|
|
39
|
+
import secrets
|
|
40
|
+
|
|
41
|
+
import yaml
|
|
42
|
+
|
|
43
|
+
from skillpool.config import get_data_dir
|
|
44
|
+
|
|
45
|
+
logger = logging.getLogger(__name__)
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
class DefectSeverity(StrEnum):
|
|
49
|
+
"""Defect severity levels."""
|
|
50
|
+
|
|
51
|
+
CRITICAL = "critical" # 1 trigger
|
|
52
|
+
MAJOR = "major" # 5 trigger
|
|
53
|
+
MINOR = "minor" # 20 trigger
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
class EvolutionAction(StrEnum):
|
|
57
|
+
"""Evolution action types (from AutoSkill)."""
|
|
58
|
+
|
|
59
|
+
ADD = "add" # Create new skill
|
|
60
|
+
MERGE = "merge" # Merge with existing
|
|
61
|
+
DISCARD = "discard" # Discard candidate
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
class VerificationStatus(StrEnum):
|
|
65
|
+
"""VERIFY phase result status."""
|
|
66
|
+
|
|
67
|
+
PASSED = "passed"
|
|
68
|
+
FAILED = "failed"
|
|
69
|
+
ROLLED_BACK = "rolled_back"
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
@dataclass
|
|
73
|
+
class DefectRecord:
|
|
74
|
+
"""Single defect record."""
|
|
75
|
+
|
|
76
|
+
defect_id: str
|
|
77
|
+
skill_id: str
|
|
78
|
+
version: str
|
|
79
|
+
severity: DefectSeverity
|
|
80
|
+
description: str
|
|
81
|
+
timestamp: datetime = field(default_factory=lambda: datetime.now(UTC))
|
|
82
|
+
resolved: bool = False
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
@dataclass
|
|
86
|
+
class EvolutionProposal:
|
|
87
|
+
"""Recommendation-only evolution proposal."""
|
|
88
|
+
|
|
89
|
+
context: dict[str, Any]
|
|
90
|
+
proposal_id: str = ""
|
|
91
|
+
recommendation_only: bool = True # ALWAYS true
|
|
92
|
+
risk: str = "medium"
|
|
93
|
+
audit_ref: str = ""
|
|
94
|
+
action: EvolutionAction = EvolutionAction.ADD
|
|
95
|
+
created_at: str = ""
|
|
96
|
+
approval_token: str = "" # MAJOR upgrades require token verification
|
|
97
|
+
upgrade_type: str = "PATCH" # PATCH / MINOR / MAJOR / NONE
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
@dataclass
|
|
101
|
+
class VerificationReport:
|
|
102
|
+
"""VERIFY phase output — validates evolution results."""
|
|
103
|
+
|
|
104
|
+
proposal_id: str
|
|
105
|
+
status: VerificationStatus = VerificationStatus.PASSED
|
|
106
|
+
bdd_regression_passed: bool = True
|
|
107
|
+
dimension_scores_before: dict[str, float] = field(default_factory=dict)
|
|
108
|
+
dimension_scores_after: dict[str, float] = field(default_factory=dict)
|
|
109
|
+
new_blind_spots: list[str] = field(default_factory=list)
|
|
110
|
+
veto_triggered: bool = False
|
|
111
|
+
veto_details: list[str] = field(default_factory=list)
|
|
112
|
+
rollback_performed: bool = False
|
|
113
|
+
verified_at: str = ""
|
|
114
|
+
|
|
115
|
+
def score_improved(self, dimension: str) -> bool:
|
|
116
|
+
"""Check if a dimension score improved after evolution."""
|
|
117
|
+
before = self.dimension_scores_before.get(dimension, 0.0)
|
|
118
|
+
after = self.dimension_scores_after.get(dimension, 0.0)
|
|
119
|
+
return after > before
|
|
120
|
+
|
|
121
|
+
def any_regression(self) -> bool:
|
|
122
|
+
"""Check if any dimension regressed."""
|
|
123
|
+
for dim in self.dimension_scores_before:
|
|
124
|
+
if self.dimension_scores_after.get(dim, 0.0) < self.dimension_scores_before.get(dim, 0.0):
|
|
125
|
+
return True
|
|
126
|
+
return False
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
@dataclass
|
|
130
|
+
class DefectAccumulator:
|
|
131
|
+
"""Defect accumulation tracker (from open source design)."""
|
|
132
|
+
|
|
133
|
+
THRESHOLDS: dict[DefectSeverity, int] = field(
|
|
134
|
+
default_factory=lambda: {
|
|
135
|
+
DefectSeverity.CRITICAL: 1,
|
|
136
|
+
DefectSeverity.MAJOR: 5,
|
|
137
|
+
DefectSeverity.MINOR: 20,
|
|
138
|
+
}
|
|
139
|
+
)
|
|
140
|
+
|
|
141
|
+
counts: dict[str, dict[DefectSeverity, int]] = field(default_factory=dict)
|
|
142
|
+
defects: list[DefectRecord] = field(default_factory=list)
|
|
143
|
+
|
|
144
|
+
def accumulate(self, defect: DefectRecord) -> None:
|
|
145
|
+
"""Accumulate a defect, grouped by skill@version."""
|
|
146
|
+
key = f"{defect.skill_id}@{defect.version}"
|
|
147
|
+
if key not in self.counts:
|
|
148
|
+
self.counts[key] = dict.fromkeys(DefectSeverity, 0)
|
|
149
|
+
self.counts[key][defect.severity] += 1
|
|
150
|
+
self.defects.append(defect)
|
|
151
|
+
|
|
152
|
+
def should_trigger_evolution(self, skill_id: str, version: str) -> bool:
|
|
153
|
+
"""Check if accumulated defects trigger evolution."""
|
|
154
|
+
key = f"{skill_id}@{version}"
|
|
155
|
+
if key not in self.counts:
|
|
156
|
+
return False
|
|
157
|
+
counts = self.counts[key]
|
|
158
|
+
return any(counts[sev] >= self.THRESHOLDS[sev] for sev in DefectSeverity)
|
|
159
|
+
|
|
160
|
+
def get_pending_defects(self, skill_id: str, version: str) -> list[DefectRecord]:
|
|
161
|
+
"""Get unresolved defects for a skill version."""
|
|
162
|
+
return [d for d in self.defects if d.skill_id == skill_id and d.version == version and not d.resolved]
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
class EvolverLayer:
|
|
166
|
+
"""
|
|
167
|
+
Evolver layer — evolution recommendations.
|
|
168
|
+
|
|
169
|
+
Hard rules:
|
|
170
|
+
- recommendation_only = true
|
|
171
|
+
- MUST NOT auto-publish
|
|
172
|
+
- MUST NOT mutate Registry enabled state
|
|
173
|
+
- Sandbox pass alone does not enable production release
|
|
174
|
+
|
|
175
|
+
V4.1 additions:
|
|
176
|
+
- VERIFY phase (Detect → Adapt → Verify)
|
|
177
|
+
- 6 safety constraints from evolution-loop-spec.yaml §4
|
|
178
|
+
- approval_token for MAJOR upgrades
|
|
179
|
+
"""
|
|
180
|
+
|
|
181
|
+
# Safety constraint #1: max auto-evolution frequency
|
|
182
|
+
MAX_PATCH_PER_DAY = 10
|
|
183
|
+
MAX_MINOR_PER_DAY = 3
|
|
184
|
+
|
|
185
|
+
# Safety constraint #4: isolation cooldown (24h)
|
|
186
|
+
EVOLUTION_COOLDOWN_HOURS = 24
|
|
187
|
+
|
|
188
|
+
# Safety constraint #6: regression monitor window (7 days)
|
|
189
|
+
REGRESSION_MONITOR_DAYS = 7
|
|
190
|
+
|
|
191
|
+
def __init__(self, audit_layer=None, skills_dir: Path | None = None, evolver_dir: Path | None = None) -> None:
|
|
192
|
+
self._audit = audit_layer
|
|
193
|
+
self._skills_dir = skills_dir or get_data_dir() / "skills"
|
|
194
|
+
self._evolver_dir = evolver_dir or get_data_dir() / "evolver"
|
|
195
|
+
self._proposals: dict[str, EvolutionProposal] = {}
|
|
196
|
+
self._defect_accumulator = DefectAccumulator()
|
|
197
|
+
self._evolution_queue: list[dict] = []
|
|
198
|
+
# Safety constraint #1: daily counters
|
|
199
|
+
self._daily_patch_count: int = 0
|
|
200
|
+
self._daily_minor_count: int = 0
|
|
201
|
+
self._daily_reset_date: str = datetime.now(UTC).strftime("%Y-%m-%d")
|
|
202
|
+
# Safety constraint #4: per-skill cooldown tracking
|
|
203
|
+
self._last_evolution_time: dict[str, datetime] = {}
|
|
204
|
+
# Safety constraint #5: global CB lock
|
|
205
|
+
self._global_cb_locked: bool = False
|
|
206
|
+
# Safety constraint #6: regression monitor
|
|
207
|
+
self._regression_monitors: dict[str, dict] = {}
|
|
208
|
+
# VERIFY phase: verification reports
|
|
209
|
+
self._verification_reports: dict[str, VerificationReport] = {}
|
|
210
|
+
# Rollback snapshots
|
|
211
|
+
self._snapshots: dict[str, dict] = {}
|
|
212
|
+
# Auto-load from disk on init
|
|
213
|
+
self._load_from_disk()
|
|
214
|
+
|
|
215
|
+
# === Defect Management ===
|
|
216
|
+
|
|
217
|
+
def record_defect(
|
|
218
|
+
self,
|
|
219
|
+
skill_id: str,
|
|
220
|
+
version: str,
|
|
221
|
+
severity: DefectSeverity,
|
|
222
|
+
description: str,
|
|
223
|
+
) -> DefectRecord:
|
|
224
|
+
"""
|
|
225
|
+
Record a defect for accumulation.
|
|
226
|
+
|
|
227
|
+
Defects accumulate until threshold triggers evolution.
|
|
228
|
+
"""
|
|
229
|
+
defect = DefectRecord(
|
|
230
|
+
defect_id=f"defect-{len(self._defect_accumulator.defects) + 1}",
|
|
231
|
+
skill_id=skill_id,
|
|
232
|
+
version=version,
|
|
233
|
+
severity=severity,
|
|
234
|
+
description=description,
|
|
235
|
+
)
|
|
236
|
+
|
|
237
|
+
self._defect_accumulator.accumulate(defect)
|
|
238
|
+
|
|
239
|
+
# Check if evolution should trigger
|
|
240
|
+
if self._defect_accumulator.should_trigger_evolution(skill_id, version):
|
|
241
|
+
self._queue_evolution(skill_id, version, defect.severity)
|
|
242
|
+
|
|
243
|
+
if self._audit:
|
|
244
|
+
self._audit.append(
|
|
245
|
+
action="record_defect",
|
|
246
|
+
object_id=skill_id,
|
|
247
|
+
result=f"{severity.value}_accumulated",
|
|
248
|
+
)
|
|
249
|
+
|
|
250
|
+
return defect
|
|
251
|
+
|
|
252
|
+
def _queue_evolution(
|
|
253
|
+
self,
|
|
254
|
+
skill_id: str,
|
|
255
|
+
version: str,
|
|
256
|
+
trigger_severity: DefectSeverity,
|
|
257
|
+
) -> None:
|
|
258
|
+
"""Queue an evolution task."""
|
|
259
|
+
evolution = {
|
|
260
|
+
"skill_id": skill_id,
|
|
261
|
+
"version": version,
|
|
262
|
+
"trigger": trigger_severity.value,
|
|
263
|
+
"defects": self._defect_accumulator.get_pending_defects(skill_id, version),
|
|
264
|
+
"queued_at": datetime.now(UTC).isoformat(),
|
|
265
|
+
}
|
|
266
|
+
self._evolution_queue.append(evolution)
|
|
267
|
+
|
|
268
|
+
def get_pending_evolutions(self) -> list[dict]:
|
|
269
|
+
"""Get pending evolution tasks."""
|
|
270
|
+
return list(self._evolution_queue)
|
|
271
|
+
|
|
272
|
+
# === Tri-State Update (from AutoSkill) ===
|
|
273
|
+
|
|
274
|
+
def judge_skill_candidate(
|
|
275
|
+
self,
|
|
276
|
+
candidate_id: str,
|
|
277
|
+
existing_skills: list[str],
|
|
278
|
+
similarity_threshold: float = 0.8,
|
|
279
|
+
) -> EvolutionAction:
|
|
280
|
+
"""
|
|
281
|
+
Judge what action to take for a skill candidate.
|
|
282
|
+
|
|
283
|
+
AutoSkill Add/Merge/Discard tri-state logic:
|
|
284
|
+
- ADD: No similar existing skills
|
|
285
|
+
- MERGE: Similar to existing, combine versions
|
|
286
|
+
- DISCARD: Nearly identical to existing, skip
|
|
287
|
+
"""
|
|
288
|
+
if not existing_skills:
|
|
289
|
+
return EvolutionAction.ADD
|
|
290
|
+
|
|
291
|
+
# Exact match → discard
|
|
292
|
+
if candidate_id in existing_skills:
|
|
293
|
+
return EvolutionAction.DISCARD
|
|
294
|
+
|
|
295
|
+
# Similar skills → merge
|
|
296
|
+
similar_exists = any(
|
|
297
|
+
self._calculate_similarity(candidate_id, existing) > similarity_threshold for existing in existing_skills
|
|
298
|
+
)
|
|
299
|
+
|
|
300
|
+
if similar_exists:
|
|
301
|
+
return EvolutionAction.MERGE
|
|
302
|
+
|
|
303
|
+
return EvolutionAction.ADD
|
|
304
|
+
|
|
305
|
+
def _calculate_similarity(self, skill_a: str, skill_b: str) -> float:
|
|
306
|
+
"""Calculate similarity between two skills based on ID structure.
|
|
307
|
+
|
|
308
|
+
Skill IDs follow patterns like S05a, S09, S13a. Similarity is based on:
|
|
309
|
+
1. Exact match → 1.0
|
|
310
|
+
2. Same base number (e.g. S05a vs S05b) → 0.85
|
|
311
|
+
3. Same dimension (derived from DIMENSION_SKILLS mapping) → 0.6
|
|
312
|
+
4. Otherwise → 0.0
|
|
313
|
+
"""
|
|
314
|
+
if skill_a == skill_b:
|
|
315
|
+
return 1.0
|
|
316
|
+
|
|
317
|
+
# Extract base number: S05a → S05, S13b → S13
|
|
318
|
+
import re
|
|
319
|
+
|
|
320
|
+
match_a = re.match(r"S(\d+)", skill_a)
|
|
321
|
+
match_b = re.match(r"S(\d+)", skill_b)
|
|
322
|
+
if not match_a or not match_b:
|
|
323
|
+
# Fallback to Jaccard on characters for non-standard IDs
|
|
324
|
+
set_a = set(skill_a)
|
|
325
|
+
set_b = set(skill_b)
|
|
326
|
+
intersection = len(set_a & set_b)
|
|
327
|
+
union = len(set_a | set_b)
|
|
328
|
+
return intersection / union if union else 0.0
|
|
329
|
+
|
|
330
|
+
num_a = match_a.group(1)
|
|
331
|
+
num_b = match_b.group(1)
|
|
332
|
+
if num_a == num_b:
|
|
333
|
+
return 0.85 # Same skill family (e.g. S05a, S05b)
|
|
334
|
+
|
|
335
|
+
# Check if skills share a dimension
|
|
336
|
+
from skillpool.review.checkpoint_runner import DIMENSION_SKILLS
|
|
337
|
+
|
|
338
|
+
dims_a = {d for d, skills in DIMENSION_SKILLS.items() if skill_a in skills}
|
|
339
|
+
dims_b = {d for d, skills in DIMENSION_SKILLS.items() if skill_b in skills}
|
|
340
|
+
if dims_a & dims_b:
|
|
341
|
+
return 0.6 # Same dimension
|
|
342
|
+
|
|
343
|
+
return 0.0
|
|
344
|
+
|
|
345
|
+
# === Proposal Creation ===
|
|
346
|
+
|
|
347
|
+
def create_proposal(
|
|
348
|
+
self,
|
|
349
|
+
context: dict[str, Any],
|
|
350
|
+
evidence_refs: list[str] | None = None,
|
|
351
|
+
candidate_summary: str = "",
|
|
352
|
+
risk: str = "medium",
|
|
353
|
+
upgrade_type: str = "PATCH",
|
|
354
|
+
) -> EvolutionProposal:
|
|
355
|
+
"""
|
|
356
|
+
Create a recommendation-only evolution proposal.
|
|
357
|
+
|
|
358
|
+
IMPORTANT: This does NOT mutate any Registry state.
|
|
359
|
+
It only creates a proposal for human review.
|
|
360
|
+
|
|
361
|
+
Safety constraints applied:
|
|
362
|
+
- #1: Rate limit (PATCH ≤10/day, MINOR ≤3/day)
|
|
363
|
+
- #2: MAJOR requires approval_token
|
|
364
|
+
- #4: 24h cooldown per skill
|
|
365
|
+
- #5: Global CB lock blocks auto-evolution
|
|
366
|
+
"""
|
|
367
|
+
# Reset daily counters if date changed
|
|
368
|
+
self._check_daily_reset()
|
|
369
|
+
|
|
370
|
+
# Safety constraint #5: global CB lock
|
|
371
|
+
if self._global_cb_locked and upgrade_type in ("PATCH", "MINOR"):
|
|
372
|
+
return self._create_blocked_proposal(context, "global_cb_locked", upgrade_type)
|
|
373
|
+
|
|
374
|
+
# Safety constraint #1: rate limit
|
|
375
|
+
if upgrade_type == "PATCH" and self._daily_patch_count >= self.MAX_PATCH_PER_DAY:
|
|
376
|
+
return self._create_blocked_proposal(context, "patch_rate_exceeded", upgrade_type)
|
|
377
|
+
if upgrade_type == "MINOR" and self._daily_minor_count >= self.MAX_MINOR_PER_DAY:
|
|
378
|
+
return self._create_blocked_proposal(context, "minor_rate_exceeded", upgrade_type)
|
|
379
|
+
|
|
380
|
+
# Safety constraint #4: 24h cooldown per skill
|
|
381
|
+
skill_id = context.get("skill_id", "")
|
|
382
|
+
if skill_id and self._is_in_cooldown(skill_id):
|
|
383
|
+
return self._create_blocked_proposal(context, "cooldown_active", upgrade_type)
|
|
384
|
+
|
|
385
|
+
proposal_id = f"proposal-{len(self._proposals) + 1}"
|
|
386
|
+
|
|
387
|
+
# Safety constraint #2: MAJOR requires approval_token
|
|
388
|
+
approval_token = ""
|
|
389
|
+
if upgrade_type == "MAJOR":
|
|
390
|
+
approval_token = secrets.token_urlsafe(32)
|
|
391
|
+
|
|
392
|
+
audit_ref = ""
|
|
393
|
+
if self._audit:
|
|
394
|
+
audit_ref = self._audit.append(
|
|
395
|
+
action="create_evolution_proposal",
|
|
396
|
+
result="success",
|
|
397
|
+
)
|
|
398
|
+
|
|
399
|
+
proposal = EvolutionProposal(
|
|
400
|
+
context=context,
|
|
401
|
+
proposal_id=proposal_id,
|
|
402
|
+
recommendation_only=True, # ALWAYS true
|
|
403
|
+
risk=risk,
|
|
404
|
+
audit_ref=audit_ref,
|
|
405
|
+
created_at=datetime.now(UTC).isoformat(),
|
|
406
|
+
approval_token=approval_token,
|
|
407
|
+
upgrade_type=upgrade_type,
|
|
408
|
+
)
|
|
409
|
+
|
|
410
|
+
self._proposals[proposal_id] = proposal
|
|
411
|
+
|
|
412
|
+
# Track cooldown
|
|
413
|
+
if skill_id:
|
|
414
|
+
self._last_evolution_time[skill_id] = datetime.now(UTC)
|
|
415
|
+
|
|
416
|
+
# Increment daily counters
|
|
417
|
+
if upgrade_type == "PATCH":
|
|
418
|
+
self._daily_patch_count += 1
|
|
419
|
+
elif upgrade_type == "MINOR":
|
|
420
|
+
self._daily_minor_count += 1
|
|
421
|
+
|
|
422
|
+
self._save_to_disk()
|
|
423
|
+
return proposal
|
|
424
|
+
|
|
425
|
+
def _create_blocked_proposal(self, context: dict, reason: str, upgrade_type: str) -> EvolutionProposal:
|
|
426
|
+
"""Create a proposal blocked by safety constraints."""
|
|
427
|
+
proposal_id = f"proposal-{len(self._proposals) + 1}"
|
|
428
|
+
proposal = EvolutionProposal(
|
|
429
|
+
context={**context, "blocked_reason": reason},
|
|
430
|
+
proposal_id=proposal_id,
|
|
431
|
+
recommendation_only=True,
|
|
432
|
+
risk="blocked",
|
|
433
|
+
created_at=datetime.now(UTC).isoformat(),
|
|
434
|
+
upgrade_type=upgrade_type,
|
|
435
|
+
)
|
|
436
|
+
self._proposals[proposal_id] = proposal
|
|
437
|
+
return proposal
|
|
438
|
+
|
|
439
|
+
def verify_approval_token(self, proposal_id: str, token: str) -> bool:
|
|
440
|
+
"""Verify approval token for MAJOR upgrades (safety constraint #2)."""
|
|
441
|
+
proposal = self._proposals.get(proposal_id)
|
|
442
|
+
if proposal is None:
|
|
443
|
+
return False
|
|
444
|
+
if proposal.upgrade_type != "MAJOR":
|
|
445
|
+
return True # Non-MAJOR doesn't need token
|
|
446
|
+
return proposal.approval_token == token and token != ""
|
|
447
|
+
|
|
448
|
+
def can_auto_enable(self, proposal_id: str) -> bool:
|
|
449
|
+
"""Check if a proposal can auto-enable a skill. Returns: ALWAYS False."""
|
|
450
|
+
return False # Hard rule: Evolver cannot auto-enable
|
|
451
|
+
|
|
452
|
+
def get_proposal(self, proposal_id: str) -> EvolutionProposal | None:
|
|
453
|
+
"""Get proposal by ID."""
|
|
454
|
+
return self._proposals.get(proposal_id)
|
|
455
|
+
|
|
456
|
+
# === Safety Constraint Helpers ===
|
|
457
|
+
|
|
458
|
+
def _check_daily_reset(self) -> None:
|
|
459
|
+
"""Reset daily counters if date changed."""
|
|
460
|
+
today = datetime.now(UTC).strftime("%Y-%m-%d")
|
|
461
|
+
if today != self._daily_reset_date:
|
|
462
|
+
self._daily_patch_count = 0
|
|
463
|
+
self._daily_minor_count = 0
|
|
464
|
+
self._daily_reset_date = today
|
|
465
|
+
|
|
466
|
+
def _is_in_cooldown(self, skill_id: str) -> bool:
|
|
467
|
+
"""Check if a skill is in 24h evolution cooldown (safety constraint #4)."""
|
|
468
|
+
last_time = self._last_evolution_time.get(skill_id)
|
|
469
|
+
if last_time is None:
|
|
470
|
+
return False
|
|
471
|
+
return datetime.now(UTC) < last_time + timedelta(hours=self.EVOLUTION_COOLDOWN_HOURS)
|
|
472
|
+
|
|
473
|
+
def set_global_cb_lock(self, locked: bool) -> None:
|
|
474
|
+
"""Set global CB lock (safety constraint #5).
|
|
475
|
+
|
|
476
|
+
When locked, PATCH/MINOR auto-evolutions are blocked.
|
|
477
|
+
"""
|
|
478
|
+
self._global_cb_locked = locked
|
|
479
|
+
|
|
480
|
+
def is_global_cb_locked(self) -> bool:
|
|
481
|
+
"""Check if global CB lock is active."""
|
|
482
|
+
return self._global_cb_locked
|
|
483
|
+
|
|
484
|
+
def get_daily_counts(self) -> dict[str, int]:
|
|
485
|
+
"""Get current daily evolution counts."""
|
|
486
|
+
self._check_daily_reset()
|
|
487
|
+
return {"patch": self._daily_patch_count, "minor": self._daily_minor_count}
|
|
488
|
+
|
|
489
|
+
# === Evolution Execution (V4.3) ===
|
|
490
|
+
|
|
491
|
+
def execute_evolution(
|
|
492
|
+
self,
|
|
493
|
+
proposal_id: str,
|
|
494
|
+
updates: dict[str, Any] | None = None,
|
|
495
|
+
) -> dict[str, Any]:
|
|
496
|
+
"""Execute an evolution proposal: write changes to CSDF YAML + re-materialize.
|
|
497
|
+
|
|
498
|
+
Steps:
|
|
499
|
+
1. Validate proposal exists
|
|
500
|
+
2. Find the skill's CSDF YAML file
|
|
501
|
+
3. Save pre-evolution snapshot for rollback
|
|
502
|
+
4. Apply updates to the YAML data
|
|
503
|
+
5. Write updated YAML back to disk
|
|
504
|
+
6. Trigger re-materialization
|
|
505
|
+
|
|
506
|
+
Args:
|
|
507
|
+
proposal_id: The evolution proposal ID to execute.
|
|
508
|
+
updates: Optional dict of field updates to apply to the CSDF.
|
|
509
|
+
|
|
510
|
+
Returns:
|
|
511
|
+
Dict with execution result (status, yaml_updated, materialized).
|
|
512
|
+
"""
|
|
513
|
+
proposal = self._proposals.get(proposal_id)
|
|
514
|
+
if proposal is None:
|
|
515
|
+
return {"status": "not_found", "error": f"No proposal with id {proposal_id}"}
|
|
516
|
+
|
|
517
|
+
skill_id = proposal.context.get("skill_id", "")
|
|
518
|
+
if not skill_id:
|
|
519
|
+
return {"status": "error", "error": "Proposal has no skill_id in context"}
|
|
520
|
+
|
|
521
|
+
# Find CSDF YAML
|
|
522
|
+
yaml_path = self._find_skill_yaml(skill_id)
|
|
523
|
+
if yaml_path is None:
|
|
524
|
+
return {"status": "no_yaml", "error": f"No CSDF YAML found for {skill_id}"}
|
|
525
|
+
|
|
526
|
+
# Read current data
|
|
527
|
+
try:
|
|
528
|
+
data = yaml.safe_load(yaml_path.read_text(encoding="utf-8"))
|
|
529
|
+
except (yaml.YAMLError, OSError) as e:
|
|
530
|
+
return {"status": "yaml_error", "error": str(e)}
|
|
531
|
+
|
|
532
|
+
if not isinstance(data, dict):
|
|
533
|
+
return {"status": "yaml_error", "error": "CSDF is not a dict"}
|
|
534
|
+
|
|
535
|
+
# Save snapshot for rollback (deep copy before mutation)
|
|
536
|
+
import copy
|
|
537
|
+
|
|
538
|
+
self.save_snapshot(proposal_id, copy.deepcopy(data))
|
|
539
|
+
|
|
540
|
+
# Apply updates
|
|
541
|
+
if updates:
|
|
542
|
+
data.update(updates)
|
|
543
|
+
|
|
544
|
+
# Bump version based on upgrade_type
|
|
545
|
+
version = str(data.get("version", "0.0.0"))
|
|
546
|
+
data["version"] = self._bump_version(version, proposal.upgrade_type)
|
|
547
|
+
data["last_evolved"] = datetime.now(UTC).isoformat()
|
|
548
|
+
data["evolution_proposal"] = proposal_id
|
|
549
|
+
|
|
550
|
+
# Write back to disk
|
|
551
|
+
try:
|
|
552
|
+
yaml_path.write_text(
|
|
553
|
+
yaml.dump(data, default_flow_style=False, allow_unicode=True, sort_keys=False),
|
|
554
|
+
encoding="utf-8",
|
|
555
|
+
)
|
|
556
|
+
except OSError as e:
|
|
557
|
+
return {"status": "write_error", "error": str(e)}
|
|
558
|
+
|
|
559
|
+
# Trigger re-materialization
|
|
560
|
+
materialized = self._rematerialize(skill_id, yaml_path)
|
|
561
|
+
|
|
562
|
+
if self._audit:
|
|
563
|
+
self._audit.append(
|
|
564
|
+
action="execute_evolution",
|
|
565
|
+
object_id=proposal_id,
|
|
566
|
+
result="success",
|
|
567
|
+
)
|
|
568
|
+
|
|
569
|
+
return {
|
|
570
|
+
"status": "success",
|
|
571
|
+
"skill_id": skill_id,
|
|
572
|
+
"version": data["version"],
|
|
573
|
+
"yaml_updated": True,
|
|
574
|
+
"materialized": materialized,
|
|
575
|
+
}
|
|
576
|
+
|
|
577
|
+
def _find_skill_yaml(self, skill_id: str) -> Path | None:
|
|
578
|
+
"""Find the CSDF YAML file for a skill ID."""
|
|
579
|
+
if not self._skills_dir.exists():
|
|
580
|
+
return None
|
|
581
|
+
exact = self._skills_dir / f"{skill_id}.yaml"
|
|
582
|
+
if exact.exists():
|
|
583
|
+
return exact
|
|
584
|
+
for p in self._skills_dir.iterdir():
|
|
585
|
+
if p.name.startswith(f"{skill_id}-") and p.suffix == ".yaml":
|
|
586
|
+
return p
|
|
587
|
+
return None
|
|
588
|
+
|
|
589
|
+
@staticmethod
|
|
590
|
+
def _bump_version(version: str, upgrade_type: str) -> str:
|
|
591
|
+
"""Bump a semver version based on upgrade type."""
|
|
592
|
+
import re
|
|
593
|
+
|
|
594
|
+
match = re.match(r"(\d+)\.(\d+)\.(\d+)", version)
|
|
595
|
+
if not match:
|
|
596
|
+
return version
|
|
597
|
+
major, minor, patch = int(match.group(1)), int(match.group(2)), int(match.group(3))
|
|
598
|
+
if upgrade_type == "MAJOR":
|
|
599
|
+
return f"{major + 1}.0.0"
|
|
600
|
+
elif upgrade_type == "MINOR":
|
|
601
|
+
return f"{major}.{minor + 1}.0"
|
|
602
|
+
else: # PATCH
|
|
603
|
+
return f"{major}.{minor}.{patch + 1}"
|
|
604
|
+
|
|
605
|
+
def _rematerialize(self, skill_id: str, yaml_path: Path) -> bool:
|
|
606
|
+
"""Trigger re-materialization of a skill after evolution.
|
|
607
|
+
|
|
608
|
+
Re-materializes to the default Claude Code skills directory.
|
|
609
|
+
"""
|
|
610
|
+
try:
|
|
611
|
+
from skillpool.materializer import Materializer
|
|
612
|
+
from skillpool.profile import CLAUDE_CODE_PROFILE
|
|
613
|
+
|
|
614
|
+
mat = Materializer(profile=CLAUDE_CODE_PROFILE)
|
|
615
|
+
result = mat.materialize(csdf_path=yaml_path)
|
|
616
|
+
if result.status == "success" and result.skill:
|
|
617
|
+
out_dir = Path.home() / ".claude" / "skills"
|
|
618
|
+
out_dir.mkdir(parents=True, exist_ok=True)
|
|
619
|
+
skill_file = out_dir / f"{result.skill.id}.md"
|
|
620
|
+
skill_file.write_text(result.skill.markdown, encoding="utf-8")
|
|
621
|
+
return True
|
|
622
|
+
except Exception as e:
|
|
623
|
+
logger.warning("Rematerialization failed for %s: %s", yaml_path, e)
|
|
624
|
+
return False
|
|
625
|
+
|
|
626
|
+
# === VERIFY Phase (V4.1) ===
|
|
627
|
+
|
|
628
|
+
def verify_evolution(
|
|
629
|
+
self,
|
|
630
|
+
proposal_id: str,
|
|
631
|
+
bdd_results: dict[str, bool] | None = None,
|
|
632
|
+
scores_before: dict[str, float] | None = None,
|
|
633
|
+
scores_after: dict[str, float] | None = None,
|
|
634
|
+
new_blind_spots: list[str] | None = None,
|
|
635
|
+
veto_details: list[str] | None = None,
|
|
636
|
+
) -> VerificationReport:
|
|
637
|
+
"""
|
|
638
|
+
VERIFY phase — validate evolution results.
|
|
639
|
+
|
|
640
|
+
Per evolution-loop-spec.yaml §1 VERIFY:
|
|
641
|
+
1. Run BDD regression test suite
|
|
642
|
+
2. Compare 12-dim scores before/after
|
|
643
|
+
3. Check for new blind spots introduced
|
|
644
|
+
4. VETO rules V1-V6 must all pass
|
|
645
|
+
|
|
646
|
+
If VERIFY fails → automatic rollback (safety constraint #3).
|
|
647
|
+
"""
|
|
648
|
+
proposal = self._proposals.get(proposal_id)
|
|
649
|
+
if proposal is None:
|
|
650
|
+
return VerificationReport(
|
|
651
|
+
proposal_id=proposal_id,
|
|
652
|
+
status=VerificationStatus.FAILED,
|
|
653
|
+
bdd_regression_passed=False,
|
|
654
|
+
verified_at=datetime.now(UTC).isoformat(),
|
|
655
|
+
)
|
|
656
|
+
|
|
657
|
+
bdd_results = bdd_results or {}
|
|
658
|
+
scores_before = scores_before or {}
|
|
659
|
+
scores_after = scores_after or {}
|
|
660
|
+
new_blind_spots = new_blind_spots or []
|
|
661
|
+
veto_details = veto_details or []
|
|
662
|
+
|
|
663
|
+
# Step 1: BDD regression
|
|
664
|
+
bdd_passed = all(bdd_results.values()) if bdd_results else True
|
|
665
|
+
|
|
666
|
+
# Step 2: Score comparison — no regression allowed
|
|
667
|
+
score_regression = False
|
|
668
|
+
for dim, before_score in scores_before.items():
|
|
669
|
+
after_score = scores_after.get(dim, before_score)
|
|
670
|
+
if after_score < before_score:
|
|
671
|
+
score_regression = True
|
|
672
|
+
break
|
|
673
|
+
|
|
674
|
+
# Step 3: New blind spots check
|
|
675
|
+
has_new_blind_spots = len(new_blind_spots) > 0
|
|
676
|
+
|
|
677
|
+
# Step 4: VETO check
|
|
678
|
+
veto_triggered = len(veto_details) > 0
|
|
679
|
+
|
|
680
|
+
# Determine overall status
|
|
681
|
+
all_passed = bdd_passed and not score_regression and not has_new_blind_spots and not veto_triggered
|
|
682
|
+
|
|
683
|
+
if all_passed:
|
|
684
|
+
status = VerificationStatus.PASSED
|
|
685
|
+
rollback = False
|
|
686
|
+
else:
|
|
687
|
+
# Safety constraint #3: VERIFY failed → rollback
|
|
688
|
+
status = VerificationStatus.ROLLED_BACK
|
|
689
|
+
rollback = True
|
|
690
|
+
self._perform_rollback(proposal_id)
|
|
691
|
+
|
|
692
|
+
report = VerificationReport(
|
|
693
|
+
proposal_id=proposal_id,
|
|
694
|
+
status=status,
|
|
695
|
+
bdd_regression_passed=bdd_passed,
|
|
696
|
+
dimension_scores_before=scores_before,
|
|
697
|
+
dimension_scores_after=scores_after,
|
|
698
|
+
new_blind_spots=new_blind_spots,
|
|
699
|
+
veto_triggered=veto_triggered,
|
|
700
|
+
veto_details=veto_details,
|
|
701
|
+
rollback_performed=rollback,
|
|
702
|
+
verified_at=datetime.now(UTC).isoformat(),
|
|
703
|
+
)
|
|
704
|
+
|
|
705
|
+
self._verification_reports[proposal_id] = report
|
|
706
|
+
|
|
707
|
+
# Safety constraint #6: start regression monitor for passed evolutions
|
|
708
|
+
if status == VerificationStatus.PASSED:
|
|
709
|
+
skill_id = proposal.context.get("skill_id", "")
|
|
710
|
+
if skill_id:
|
|
711
|
+
self._regression_monitors[skill_id] = {
|
|
712
|
+
"proposal_id": proposal_id,
|
|
713
|
+
"started_at": datetime.now(UTC).isoformat(),
|
|
714
|
+
"monitor_until": (datetime.now(UTC) + timedelta(days=self.REGRESSION_MONITOR_DAYS)).isoformat(),
|
|
715
|
+
"dimensions": list(scores_after.keys()),
|
|
716
|
+
}
|
|
717
|
+
|
|
718
|
+
if self._audit:
|
|
719
|
+
self._audit.append(
|
|
720
|
+
action="verify_evolution",
|
|
721
|
+
object_id=proposal_id,
|
|
722
|
+
result=status.value,
|
|
723
|
+
)
|
|
724
|
+
|
|
725
|
+
self._save_to_disk()
|
|
726
|
+
return report
|
|
727
|
+
|
|
728
|
+
def get_verification_report(self, proposal_id: str) -> VerificationReport | None:
|
|
729
|
+
"""Get verification report for a proposal."""
|
|
730
|
+
return self._verification_reports.get(proposal_id)
|
|
731
|
+
|
|
732
|
+
def _perform_rollback(self, proposal_id: str) -> None:
|
|
733
|
+
"""Perform rollback for failed verification (safety constraint #3).
|
|
734
|
+
|
|
735
|
+
Restores skill CSDF YAML from the pre-evolution snapshot,
|
|
736
|
+
then re-materializes the original skill definition.
|
|
737
|
+
"""
|
|
738
|
+
snapshot = self._snapshots.get(proposal_id)
|
|
739
|
+
if snapshot and "data" in snapshot:
|
|
740
|
+
restored_data = snapshot["data"]
|
|
741
|
+
skill_id = restored_data.get("id", "")
|
|
742
|
+
if skill_id:
|
|
743
|
+
# Find and restore the YAML file
|
|
744
|
+
yaml_path = self._find_skill_yaml(skill_id)
|
|
745
|
+
if yaml_path is not None:
|
|
746
|
+
try:
|
|
747
|
+
yaml_path.write_text(
|
|
748
|
+
yaml.dump(restored_data, default_flow_style=False, allow_unicode=True, sort_keys=False),
|
|
749
|
+
encoding="utf-8",
|
|
750
|
+
)
|
|
751
|
+
# Re-materialize the restored skill
|
|
752
|
+
self._rematerialize(skill_id, yaml_path)
|
|
753
|
+
except OSError:
|
|
754
|
+
pass # Best-effort rollback
|
|
755
|
+
if self._audit:
|
|
756
|
+
self._audit.append(
|
|
757
|
+
action="evolution_rollback",
|
|
758
|
+
object_id=proposal_id,
|
|
759
|
+
result="rolled_back",
|
|
760
|
+
)
|
|
761
|
+
|
|
762
|
+
def save_snapshot(self, proposal_id: str, skill_data: dict) -> None:
|
|
763
|
+
"""Save a pre-evolution snapshot for potential rollback."""
|
|
764
|
+
self._snapshots[proposal_id] = {
|
|
765
|
+
"data": skill_data,
|
|
766
|
+
"saved_at": datetime.now(UTC).isoformat(),
|
|
767
|
+
"hash": hashlib.sha256(str(skill_data).encode()).hexdigest()[:16],
|
|
768
|
+
}
|
|
769
|
+
self._save_to_disk()
|
|
770
|
+
|
|
771
|
+
def get_snapshot(self, proposal_id: str) -> dict | None:
|
|
772
|
+
"""Get a saved snapshot."""
|
|
773
|
+
snap = self._snapshots.get(proposal_id)
|
|
774
|
+
return snap["data"] if snap else None
|
|
775
|
+
|
|
776
|
+
# === Regression Monitor (Safety Constraint #6) ===
|
|
777
|
+
|
|
778
|
+
def check_regression_monitor(self, skill_id: str) -> dict | None:
|
|
779
|
+
"""Check if a skill is under regression monitoring.
|
|
780
|
+
|
|
781
|
+
Returns monitor info if active, None if expired or not monitored.
|
|
782
|
+
"""
|
|
783
|
+
monitor = self._regression_monitors.get(skill_id)
|
|
784
|
+
if monitor is None:
|
|
785
|
+
return None
|
|
786
|
+
until = datetime.fromisoformat(monitor["monitor_until"])
|
|
787
|
+
if datetime.now(UTC) > until:
|
|
788
|
+
del self._regression_monitors[skill_id]
|
|
789
|
+
return None
|
|
790
|
+
return monitor
|
|
791
|
+
|
|
792
|
+
def report_regression_recurrence(self, skill_id: str, dimension: str) -> str:
|
|
793
|
+
"""Report a regression recurrence during monitoring period.
|
|
794
|
+
|
|
795
|
+
Per safety constraint #6: if blind spot recurs within 7 days,
|
|
796
|
+
upgrade evolution: PATCH → MINOR → MAJOR.
|
|
797
|
+
"""
|
|
798
|
+
monitor = self._regression_monitors.get(skill_id)
|
|
799
|
+
if monitor is None:
|
|
800
|
+
return "no_monitor"
|
|
801
|
+
|
|
802
|
+
# Escalate: current level → next level
|
|
803
|
+
current_proposal = self._proposals.get(monitor["proposal_id"])
|
|
804
|
+
if current_proposal is None:
|
|
805
|
+
return "no_proposal"
|
|
806
|
+
|
|
807
|
+
if current_proposal.upgrade_type == "PATCH":
|
|
808
|
+
return "escalate_to_minor"
|
|
809
|
+
elif current_proposal.upgrade_type == "MINOR":
|
|
810
|
+
return "escalate_to_major"
|
|
811
|
+
else:
|
|
812
|
+
return "already_major"
|
|
813
|
+
|
|
814
|
+
# === Dual-Loop Architecture Support ===
|
|
815
|
+
|
|
816
|
+
# === Disk Persistence (V4.3) ===
|
|
817
|
+
|
|
818
|
+
def _save_to_disk(self) -> None:
|
|
819
|
+
"""Persist proposals, snapshots, and verification reports to disk."""
|
|
820
|
+
try:
|
|
821
|
+
self._evolver_dir.mkdir(parents=True, exist_ok=True)
|
|
822
|
+
|
|
823
|
+
# Save proposals
|
|
824
|
+
proposals_path = self._evolver_dir / "proposals.yaml"
|
|
825
|
+
proposals_data = {}
|
|
826
|
+
for pid, p in self._proposals.items():
|
|
827
|
+
proposals_data[pid] = {
|
|
828
|
+
"context": p.context,
|
|
829
|
+
"proposal_id": p.proposal_id,
|
|
830
|
+
"risk": p.risk,
|
|
831
|
+
"audit_ref": p.audit_ref,
|
|
832
|
+
"action": p.action.value,
|
|
833
|
+
"created_at": p.created_at,
|
|
834
|
+
"upgrade_type": p.upgrade_type,
|
|
835
|
+
}
|
|
836
|
+
proposals_path.write_text(
|
|
837
|
+
yaml.dump(proposals_data, default_flow_style=False, allow_unicode=True),
|
|
838
|
+
encoding="utf-8",
|
|
839
|
+
)
|
|
840
|
+
|
|
841
|
+
# Save snapshots
|
|
842
|
+
snapshots_path = self._evolver_dir / "snapshots.yaml"
|
|
843
|
+
snapshots_path.write_text(
|
|
844
|
+
yaml.dump(self._snapshots, default_flow_style=False, allow_unicode=True),
|
|
845
|
+
encoding="utf-8",
|
|
846
|
+
)
|
|
847
|
+
|
|
848
|
+
# Save verification reports
|
|
849
|
+
reports_path = self._evolver_dir / "verification_reports.yaml"
|
|
850
|
+
reports_data = {}
|
|
851
|
+
for pid, r in self._verification_reports.items():
|
|
852
|
+
reports_data[pid] = {
|
|
853
|
+
"proposal_id": r.proposal_id,
|
|
854
|
+
"status": r.status.value,
|
|
855
|
+
"bdd_regression_passed": r.bdd_regression_passed,
|
|
856
|
+
"dimension_scores_before": r.dimension_scores_before,
|
|
857
|
+
"dimension_scores_after": r.dimension_scores_after,
|
|
858
|
+
"new_blind_spots": r.new_blind_spots,
|
|
859
|
+
"veto_triggered": r.veto_triggered,
|
|
860
|
+
"veto_details": r.veto_details,
|
|
861
|
+
"rollback_performed": r.rollback_performed,
|
|
862
|
+
"verified_at": r.verified_at,
|
|
863
|
+
}
|
|
864
|
+
reports_path.write_text(
|
|
865
|
+
yaml.dump(reports_data, default_flow_style=False, allow_unicode=True),
|
|
866
|
+
encoding="utf-8",
|
|
867
|
+
)
|
|
868
|
+
except OSError:
|
|
869
|
+
pass # Disk persistence is best-effort
|
|
870
|
+
|
|
871
|
+
def _load_from_disk(self) -> None:
|
|
872
|
+
"""Load proposals, snapshots, and verification reports from disk."""
|
|
873
|
+
try:
|
|
874
|
+
if not self._evolver_dir.exists():
|
|
875
|
+
return
|
|
876
|
+
|
|
877
|
+
# Load proposals
|
|
878
|
+
proposals_path = self._evolver_dir / "proposals.yaml"
|
|
879
|
+
if proposals_path.exists():
|
|
880
|
+
data = yaml.safe_load(proposals_path.read_text(encoding="utf-8"))
|
|
881
|
+
if isinstance(data, dict):
|
|
882
|
+
for pid, p_data in data.items():
|
|
883
|
+
self._proposals[pid] = EvolutionProposal(
|
|
884
|
+
context=p_data.get("context", {}),
|
|
885
|
+
proposal_id=p_data.get("proposal_id", pid),
|
|
886
|
+
risk=p_data.get("risk", "medium"),
|
|
887
|
+
audit_ref=p_data.get("audit_ref", ""),
|
|
888
|
+
action=EvolutionAction(p_data.get("action", "add")),
|
|
889
|
+
created_at=p_data.get("created_at", ""),
|
|
890
|
+
upgrade_type=p_data.get("upgrade_type", "PATCH"),
|
|
891
|
+
)
|
|
892
|
+
|
|
893
|
+
# Load snapshots
|
|
894
|
+
snapshots_path = self._evolver_dir / "snapshots.yaml"
|
|
895
|
+
if snapshots_path.exists():
|
|
896
|
+
data = yaml.safe_load(snapshots_path.read_text(encoding="utf-8"))
|
|
897
|
+
if isinstance(data, dict):
|
|
898
|
+
self._snapshots = data
|
|
899
|
+
|
|
900
|
+
# Load verification reports
|
|
901
|
+
reports_path = self._evolver_dir / "verification_reports.yaml"
|
|
902
|
+
if reports_path.exists():
|
|
903
|
+
data = yaml.safe_load(reports_path.read_text(encoding="utf-8"))
|
|
904
|
+
if isinstance(data, dict):
|
|
905
|
+
for pid, r_data in data.items():
|
|
906
|
+
self._verification_reports[pid] = VerificationReport(
|
|
907
|
+
proposal_id=r_data.get("proposal_id", pid),
|
|
908
|
+
status=VerificationStatus(r_data.get("status", "passed")),
|
|
909
|
+
bdd_regression_passed=r_data.get("bdd_regression_passed", True),
|
|
910
|
+
dimension_scores_before=r_data.get("dimension_scores_before", {}),
|
|
911
|
+
dimension_scores_after=r_data.get("dimension_scores_after", {}),
|
|
912
|
+
new_blind_spots=r_data.get("new_blind_spots", []),
|
|
913
|
+
veto_triggered=r_data.get("veto_triggered", False),
|
|
914
|
+
veto_details=r_data.get("veto_details", []),
|
|
915
|
+
rollback_performed=r_data.get("rollback_performed", False),
|
|
916
|
+
verified_at=r_data.get("verified_at", ""),
|
|
917
|
+
)
|
|
918
|
+
except (yaml.YAMLError, OSError):
|
|
919
|
+
pass # Disk load is best-effort
|
|
920
|
+
|
|
921
|
+
def process_internal_feedback(
|
|
922
|
+
self,
|
|
923
|
+
skill_id: str,
|
|
924
|
+
feedback_data: dict,
|
|
925
|
+
) -> dict | None:
|
|
926
|
+
"""
|
|
927
|
+
Process internal feedback loop (left loop of dual-loop).
|
|
928
|
+
|
|
929
|
+
Synchronous, real-time processing path.
|
|
930
|
+
Returns evolution suggestions based on immediate feedback.
|
|
931
|
+
"""
|
|
932
|
+
success_rate = feedback_data.get("success_rate", 1.0)
|
|
933
|
+
error_patterns = feedback_data.get("error_patterns", [])
|
|
934
|
+
|
|
935
|
+
suggestions = []
|
|
936
|
+
|
|
937
|
+
if success_rate < 0.9:
|
|
938
|
+
suggestions.append(
|
|
939
|
+
{
|
|
940
|
+
"type": "performance_degradation",
|
|
941
|
+
"skill_id": skill_id,
|
|
942
|
+
"severity": DefectSeverity.MAJOR if success_rate < 0.7 else DefectSeverity.MINOR,
|
|
943
|
+
"action": "review_and_optimize",
|
|
944
|
+
}
|
|
945
|
+
)
|
|
946
|
+
|
|
947
|
+
for pattern in error_patterns:
|
|
948
|
+
suggestions.append(
|
|
949
|
+
{
|
|
950
|
+
"type": "error_pattern",
|
|
951
|
+
"skill_id": skill_id,
|
|
952
|
+
"pattern": pattern,
|
|
953
|
+
"action": "investigate",
|
|
954
|
+
}
|
|
955
|
+
)
|
|
956
|
+
|
|
957
|
+
return {"suggestions": suggestions} if suggestions else None
|
|
958
|
+
|
|
959
|
+
def process_external_evolution(
|
|
960
|
+
self,
|
|
961
|
+
skill_id: str,
|
|
962
|
+
external_update: dict,
|
|
963
|
+
) -> dict | None:
|
|
964
|
+
"""
|
|
965
|
+
Process external evolution signals (right loop of dual-loop).
|
|
966
|
+
|
|
967
|
+
Asynchronous, batch processing path.
|
|
968
|
+
"""
|
|
969
|
+
update_version = external_update.get("version")
|
|
970
|
+
update_type = external_update.get("type")
|
|
971
|
+
|
|
972
|
+
return {
|
|
973
|
+
"skill_id": skill_id,
|
|
974
|
+
"external_version": update_version,
|
|
975
|
+
"update_type": update_type,
|
|
976
|
+
"recommendation": "review_for_adoption",
|
|
977
|
+
"source": "external_evolution",
|
|
978
|
+
}
|