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.
Files changed (90) hide show
  1. skillpool/__init__.py +74 -0
  2. skillpool/__main__.py +6 -0
  3. skillpool/adapters/__init__.py +8 -0
  4. skillpool/adapters/base.py +41 -0
  5. skillpool/adapters/claude_adapter.py +36 -0
  6. skillpool/adapters/codex_adapter.py +92 -0
  7. skillpool/adapters/hermes_adapter.py +38 -0
  8. skillpool/audit/__init__.py +651 -0
  9. skillpool/bridge/__init__.py +16 -0
  10. skillpool/bridge/freeze_detector.py +134 -0
  11. skillpool/bridge/maintenance.py +119 -0
  12. skillpool/bridge/wal_manager.py +136 -0
  13. skillpool/clawmem_client.py +176 -0
  14. skillpool/cli.py +700 -0
  15. skillpool/combiner/__init__.py +31 -0
  16. skillpool/combiner/lifecycle.py +453 -0
  17. skillpool/combiner/models.py +99 -0
  18. skillpool/config.py +34 -0
  19. skillpool/cost/__init__.py +111 -0
  20. skillpool/cost/audit_hash.py +51 -0
  21. skillpool/cost/budget_tracker.py +66 -0
  22. skillpool/cost/dashboard.py +189 -0
  23. skillpool/cost/models.py +129 -0
  24. skillpool/cost/token_governor.py +264 -0
  25. skillpool/cost/trace_ceiling.py +38 -0
  26. skillpool/csdf.py +126 -0
  27. skillpool/evolver/__init__.py +978 -0
  28. skillpool/gain/__init__.py +285 -0
  29. skillpool/gate.py +282 -0
  30. skillpool/gate_policy/__init__.py +31 -0
  31. skillpool/gate_policy/incremental.py +157 -0
  32. skillpool/gate_policy/parser.py +258 -0
  33. skillpool/gate_policy/state_machine.py +432 -0
  34. skillpool/graph/__init__.py +14 -0
  35. skillpool/graph/ppr.py +279 -0
  36. skillpool/health/__init__.py +73 -0
  37. skillpool/health/check.py +85 -0
  38. skillpool/health/degradation.py +90 -0
  39. skillpool/health/models.py +43 -0
  40. skillpool/hooks/__init__.py +4 -0
  41. skillpool/hooks/security_scanner.py +288 -0
  42. skillpool/lifecycle.py +150 -0
  43. skillpool/materializer/__init__.py +124 -0
  44. skillpool/materializer/budget_cropper.py +178 -0
  45. skillpool/materializer/csdf_loader.py +114 -0
  46. skillpool/materializer/lazy_loader.py +265 -0
  47. skillpool/materializer/lifecycle_filter.py +93 -0
  48. skillpool/materializer/mapper.py +178 -0
  49. skillpool/materializer/models.py +66 -0
  50. skillpool/mcp_server.py +2005 -0
  51. skillpool/monitor/__init__.py +576 -0
  52. skillpool/monitor/bug_collector.py +392 -0
  53. skillpool/monitor/defect_classifier.py +218 -0
  54. skillpool/monitor/self_healing.py +530 -0
  55. skillpool/monitor/telemetry_bridge.py +197 -0
  56. skillpool/paradigm/__init__.py +312 -0
  57. skillpool/paradigm/override.py +285 -0
  58. skillpool/profile.py +94 -0
  59. skillpool/quality.py +254 -0
  60. skillpool/registry/__init__.py +509 -0
  61. skillpool/registry/models.py +98 -0
  62. skillpool/resolver/__init__.py +320 -0
  63. skillpool/resolver/cache.py +103 -0
  64. skillpool/resolver/circuit_breaker.py +103 -0
  65. skillpool/resolver/conflict_detector.py +111 -0
  66. skillpool/resolver/health_filter.py +38 -0
  67. skillpool/resolver/models.py +154 -0
  68. skillpool/resolver/rate_limiter.py +48 -0
  69. skillpool/resolver/skill_graph.py +183 -0
  70. skillpool/review/__init__.py +242 -0
  71. skillpool/review/async_queue.py +96 -0
  72. skillpool/review/checkpoint_runner.py +345 -0
  73. skillpool/review/models.py +164 -0
  74. skillpool/review/suspect_marker.py +39 -0
  75. skillpool/review/veto_evaluator.py +94 -0
  76. skillpool/router/__init__.py +481 -0
  77. skillpool/schemas.py +119 -0
  78. skillpool/synergy/__init__.py +240 -0
  79. skillpool/synergy/detector.py +5 -0
  80. skillpool/telemetry.py +126 -0
  81. skillpool/utils/__init__.py +21 -0
  82. skillpool/utils/changelog.py +218 -0
  83. skillpool/utils/logger.py +273 -0
  84. skillpool/utils/runtime_audit.py +163 -0
  85. skillpool/utils/time_utils.py +13 -0
  86. skillpool-4.3.0.dist-info/METADATA +21 -0
  87. skillpool-4.3.0.dist-info/RECORD +90 -0
  88. skillpool-4.3.0.dist-info/WHEEL +5 -0
  89. skillpool-4.3.0.dist-info/entry_points.txt +3 -0
  90. 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
+ }