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,530 @@
1
+ """SelfHealingLoop — BugCollector → Evolver → Skill upgrade → BDD verify → auto-rollback.
2
+
3
+ Connects the BugCollector's defect data to the Evolver's evolution pipeline,
4
+ adding BDD verification and automatic rollback on failure.
5
+
6
+ Trigger logic (from CLAUDE.md Section 8.5):
7
+ - >=3 P2 bugs of same defect_type in same skill -> PATCH (auto)
8
+ - >=5 P2 or >=1 P1 -> MINOR (auto + notify)
9
+ - >=1 P0 -> MAJOR (must NOT auto-execute, return needs_human)
10
+
11
+ Part of SkillPool — independent infrastructure, shared by all agents.
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ __all__ = [
17
+ "HealingAction",
18
+ "HealingProposal",
19
+ "HealingStatus",
20
+ "SelfHealingLoop",
21
+ ]
22
+
23
+ from dataclasses import dataclass, field
24
+ from enum import StrEnum
25
+ import logging
26
+ from pathlib import Path
27
+ from typing import Any
28
+
29
+ import yaml
30
+
31
+ from skillpool.config import get_data_dir
32
+ from skillpool.evolver import (
33
+ EvolverLayer,
34
+ EvolutionProposal,
35
+ VerificationStatus,
36
+ )
37
+ from skillpool.monitor.bug_collector import BugCollector, BugSeverity, DefectType
38
+
39
+ logger = logging.getLogger(__name__)
40
+
41
+
42
+ class HealingAction(StrEnum):
43
+ """Action taken by the healing loop."""
44
+
45
+ PATCH = "PATCH"
46
+ MINOR = "MINOR"
47
+ MAJOR = "MAJOR"
48
+ NEEDS_HUMAN = "needs_human"
49
+ SKIPPED = "skipped"
50
+
51
+
52
+ class HealingStatus(StrEnum):
53
+ """Status of a healing proposal execution."""
54
+
55
+ PROPOSED = "proposed"
56
+ EXECUTING = "executing"
57
+ VERIFIED = "verified"
58
+ ROLLED_BACK = "rolled_back"
59
+ NEEDS_HUMAN = "needs_human"
60
+ BLOCKED = "blocked"
61
+
62
+
63
+ @dataclass
64
+ class HealingProposal:
65
+ """A healing proposal generated from bug pattern analysis."""
66
+
67
+ proposal_id: str
68
+ skill_id: str
69
+ defect_type: DefectType
70
+ upgrade_type: HealingAction
71
+ bug_count: int
72
+ bug_severity: BugSeverity
73
+ evolver_proposal: EvolutionProposal | None = None
74
+ status: HealingStatus = HealingStatus.PROPOSED
75
+ verification_result: dict[str, Any] = field(default_factory=dict)
76
+
77
+
78
+ class SelfHealingLoop:
79
+ """Self-healing feedback loop: BugCollector → Evolver → upgrade → verify → rollback.
80
+
81
+ Scans BugCollector for recurring defect patterns, groups by skill_id +
82
+ defect_type, proposes evolutions via Evolver, and verifies results with
83
+ BDD-style checks. Auto-rolls back on verification failure.
84
+
85
+ Args:
86
+ bug_collector: BugCollector instance to scan for defects.
87
+ evolver: EvolverLayer instance to create/execute evolutions.
88
+ audit_layer: Optional AuditLayer for audit trail.
89
+ """
90
+
91
+ def __init__(
92
+ self,
93
+ bug_collector: BugCollector,
94
+ evolver: EvolverLayer,
95
+ audit_layer: Any | None = None,
96
+ skills_dir: Path | None = None,
97
+ ) -> None:
98
+ self._bug_collector = bug_collector
99
+ self._evolver = evolver
100
+ self._audit = audit_layer
101
+ self._skills_dir = skills_dir or get_data_dir() / "skills"
102
+ self._proposals: dict[str, HealingProposal] = {}
103
+ self._proposal_counter: int = 0
104
+ # Pre-evolution YAML snapshots for disk-level rollback
105
+ self._yaml_snapshots: dict[str, str] = {}
106
+
107
+ def scan_and_propose(self) -> list[dict[str, Any]]:
108
+ """Scan BugCollector for recurring defects and propose evolutions.
109
+
110
+ Groups bugs by (skill_id, defect_type), counts by severity, and
111
+ applies trigger thresholds to determine upgrade type.
112
+
113
+ Returns:
114
+ List of dicts with proposal details (proposal_id, skill_id,
115
+ defect_type, upgrade_type, bug_count, status).
116
+ """
117
+ all_bugs = self._bug_collector.get_bugs()
118
+ if not all_bugs:
119
+ return []
120
+
121
+ # Group by (skill_id, defect_type) -> severity counts
122
+ groups: dict[tuple[str, str], dict[str, int]] = {}
123
+ for bug in all_bugs:
124
+ key = (bug.skill_id, bug.defect_type.value)
125
+ if key not in groups:
126
+ groups[key] = {"P0": 0, "P1": 0, "P2": 0}
127
+ groups[key][bug.severity.value] += 1
128
+
129
+ proposals: list[dict[str, Any]] = []
130
+
131
+ for (skill_id, defect_type_str), counts in groups.items():
132
+ action = self._determine_action(counts)
133
+ if action == HealingAction.SKIPPED:
134
+ continue
135
+
136
+ # Dedup: skip if a PROPOSED proposal already exists for this key
137
+ existing = any(
138
+ p.skill_id == skill_id and p.defect_type.value == defect_type_str and p.status == HealingStatus.PROPOSED
139
+ for p in self._proposals.values()
140
+ )
141
+ if existing:
142
+ continue
143
+
144
+ defect_type = DefectType(defect_type_str)
145
+ total = counts["P0"] + counts["P1"] + counts["P2"]
146
+ dominant_severity = self._dominant_severity(counts)
147
+
148
+ self._proposal_counter += 1
149
+ proposal_id = f"heal-{self._proposal_counter}"
150
+
151
+ # Create evolver proposal
152
+ evolver_proposal = self._evolver.create_proposal(
153
+ context={
154
+ "skill_id": skill_id,
155
+ "defect_type": defect_type_str,
156
+ "bug_counts": counts,
157
+ "trigger_source": "self_healing_loop",
158
+ },
159
+ risk="high" if action == HealingAction.MAJOR else "medium",
160
+ upgrade_type=action.value if action != HealingAction.NEEDS_HUMAN else "MAJOR",
161
+ )
162
+
163
+ healing = HealingProposal(
164
+ proposal_id=proposal_id,
165
+ skill_id=skill_id,
166
+ defect_type=defect_type,
167
+ upgrade_type=action,
168
+ bug_count=total,
169
+ bug_severity=dominant_severity,
170
+ evolver_proposal=evolver_proposal,
171
+ status=HealingStatus.NEEDS_HUMAN if action == HealingAction.NEEDS_HUMAN else HealingStatus.PROPOSED,
172
+ )
173
+
174
+ self._proposals[proposal_id] = healing
175
+
176
+ proposals.append(
177
+ {
178
+ "proposal_id": proposal_id,
179
+ "skill_id": skill_id,
180
+ "defect_type": defect_type_str,
181
+ "upgrade_type": action.value,
182
+ "bug_count": total,
183
+ "bug_counts": counts,
184
+ "status": healing.status.value,
185
+ }
186
+ )
187
+
188
+ if self._audit:
189
+ self._audit.append(
190
+ action="self_healing_scan",
191
+ result=f"proposed_{len(proposals)}",
192
+ )
193
+
194
+ return proposals
195
+
196
+ def execute_healing(self, proposal_id: str) -> dict[str, Any]:
197
+ """Execute a proposed healing evolution with BDD verification.
198
+
199
+ Steps:
200
+ 1. Validate proposal exists and is in PROPOSED state
201
+ 2. MAJOR proposals require human approval (return needs_human)
202
+ 3. Mark as EXECUTING
203
+ 4. Record pre-evolution bug snapshot
204
+ 5. Save snapshot via Evolver for rollback support
205
+ 6. Run BDD verification (check bug count decreased)
206
+ 7. If BDD fails -> call Evolver.verify_evolution() to trigger rollback
207
+ 8. If BDD passes -> call Evolver.verify_evolution() with passing results
208
+ 9. Return execution result
209
+
210
+ Args:
211
+ proposal_id: The healing proposal ID to execute.
212
+
213
+ Returns:
214
+ Dict with execution result details.
215
+ """
216
+ # Part of SkillPool — independent infrastructure, shared by all agents
217
+ healing = self._proposals.get(proposal_id)
218
+ if healing is None:
219
+ return {
220
+ "proposal_id": proposal_id,
221
+ "status": "not_found",
222
+ "error": f"No healing proposal with id {proposal_id}",
223
+ }
224
+
225
+ # MAJOR upgrades must NOT auto-execute
226
+ if healing.upgrade_type == HealingAction.NEEDS_HUMAN:
227
+ healing.status = HealingStatus.NEEDS_HUMAN
228
+ return {
229
+ "proposal_id": proposal_id,
230
+ "status": HealingStatus.NEEDS_HUMAN.value,
231
+ "reason": "MAJOR upgrade requires human approval",
232
+ }
233
+
234
+ if healing.status != HealingStatus.PROPOSED:
235
+ return {
236
+ "proposal_id": proposal_id,
237
+ "status": healing.status.value,
238
+ "reason": f"Proposal not in PROPOSED state (current: {healing.status.value})",
239
+ }
240
+
241
+ # Mark executing
242
+ healing.status = HealingStatus.EXECUTING
243
+
244
+ # Read and snapshot the CSDF YAML before evolution
245
+ yaml_path = self._find_skill_yaml(healing.skill_id)
246
+ if yaml_path and yaml_path.exists():
247
+ self._yaml_snapshots[proposal_id] = yaml_path.read_text(encoding="utf-8")
248
+
249
+ # Capture pre-evolution bug count for this skill+defect_type
250
+ bugs_before = self._bug_collector.get_bugs(
251
+ skill_id=healing.skill_id,
252
+ defect_type=healing.defect_type,
253
+ )
254
+ count_before = len(bugs_before)
255
+
256
+ # Save snapshot via evolver for rollback support
257
+ if healing.evolver_proposal:
258
+ self._evolver.save_snapshot(
259
+ healing.evolver_proposal.proposal_id,
260
+ {"bug_count_before": count_before, "skill_id": healing.skill_id},
261
+ )
262
+
263
+ # BDD verification: check that no new bugs of same type appeared
264
+ bdd_passed = self._bdd_verify(healing.skill_id, healing.defect_type, count_before)
265
+
266
+ if bdd_passed:
267
+ # Run evolver's verify_evolution with passing BDD results
268
+ if healing.evolver_proposal:
269
+ report = self._evolver.verify_evolution(
270
+ proposal_id=healing.evolver_proposal.proposal_id,
271
+ bdd_results={"bug_count_decreased": True},
272
+ scores_before={},
273
+ scores_after={},
274
+ )
275
+ if report.status == VerificationStatus.ROLLED_BACK:
276
+ healing.status = HealingStatus.ROLLED_BACK
277
+ healing.verification_result = {
278
+ "bdd_passed": False,
279
+ "evolver_rollback": True,
280
+ "veto_triggered": report.veto_triggered,
281
+ "veto_details": report.veto_details,
282
+ }
283
+ return {
284
+ "proposal_id": proposal_id,
285
+ "status": HealingStatus.ROLLED_BACK.value,
286
+ "reason": "Evolver verification triggered rollback",
287
+ "verification": healing.verification_result,
288
+ }
289
+
290
+ healing.status = HealingStatus.VERIFIED
291
+ healing.verification_result = {
292
+ "bdd_passed": True,
293
+ "bug_count_before": count_before,
294
+ "bug_count_after": len(
295
+ self._bug_collector.get_bugs(
296
+ skill_id=healing.skill_id,
297
+ defect_type=healing.defect_type,
298
+ )
299
+ ),
300
+ }
301
+ # Persist evolution to CSDF YAML on disk
302
+ self._persist_evolution(healing.skill_id, healing)
303
+ else:
304
+ # BDD failed — trigger evolver rollback via verify_evolution
305
+ if healing.evolver_proposal:
306
+ self._evolver.verify_evolution(
307
+ proposal_id=healing.evolver_proposal.proposal_id,
308
+ bdd_results={"bug_count_decreased": False},
309
+ scores_before={},
310
+ scores_after={},
311
+ veto_details=["BDD verification failed: bug count did not decrease"],
312
+ )
313
+ # Restore from snapshot
314
+ snapshot = None
315
+ if healing.evolver_proposal:
316
+ snapshot = self._evolver.get_snapshot(healing.evolver_proposal.proposal_id)
317
+
318
+ # Restore original YAML from disk snapshot
319
+ yaml_restored = self._restore_yaml_snapshot(proposal_id, healing.skill_id)
320
+
321
+ healing.status = HealingStatus.ROLLED_BACK
322
+ healing.verification_result = {
323
+ "bdd_passed": False,
324
+ "bug_count_before": count_before,
325
+ "reason": "BDD verification failed: bug count did not decrease",
326
+ "snapshot_restored": snapshot is not None,
327
+ "yaml_restored": yaml_restored,
328
+ }
329
+
330
+ if self._audit:
331
+ self._audit.append(
332
+ action="self_healing_execute",
333
+ object_id=proposal_id,
334
+ result=healing.status.value,
335
+ )
336
+
337
+ return {
338
+ "proposal_id": proposal_id,
339
+ "status": healing.status.value,
340
+ "verification": healing.verification_result,
341
+ }
342
+
343
+ def get_healing_status(self) -> dict[str, Any]:
344
+ """Return current healing loop status.
345
+
346
+ Returns:
347
+ Dict with total proposals, counts by status, and proposal summaries.
348
+ """
349
+ by_status: dict[str, int] = {}
350
+ summaries: list[dict[str, Any]] = []
351
+
352
+ for healing in self._proposals.values():
353
+ status_key = healing.status.value
354
+ by_status[status_key] = by_status.get(status_key, 0) + 1
355
+ summaries.append(
356
+ {
357
+ "proposal_id": healing.proposal_id,
358
+ "skill_id": healing.skill_id,
359
+ "defect_type": healing.defect_type.value,
360
+ "upgrade_type": healing.upgrade_type.value,
361
+ "bug_count": healing.bug_count,
362
+ "status": healing.status.value,
363
+ }
364
+ )
365
+
366
+ return {
367
+ "total_proposals": len(self._proposals),
368
+ "by_status": by_status,
369
+ "proposals": summaries,
370
+ }
371
+
372
+ # ── Internal ──
373
+
374
+ @staticmethod
375
+ def _determine_action(counts: dict[str, int]) -> HealingAction:
376
+ """Determine healing action from bug severity counts.
377
+
378
+ Thresholds (CLAUDE.md Section 8.5):
379
+ - >=1 P0 -> MAJOR (needs_human)
380
+ - >=1 P1 or >=5 P2 -> MINOR
381
+ - >=3 P2 -> PATCH
382
+ - else -> SKIPPED
383
+ """
384
+ p0 = counts.get("P0", 0)
385
+ p1 = counts.get("P1", 0)
386
+ p2 = counts.get("P2", 0)
387
+
388
+ if p0 >= 1:
389
+ return HealingAction.NEEDS_HUMAN
390
+ if p1 >= 1 or p2 >= 5:
391
+ return HealingAction.MINOR
392
+ if p2 >= 3:
393
+ return HealingAction.PATCH
394
+ return HealingAction.SKIPPED
395
+
396
+ @staticmethod
397
+ def _dominant_severity(counts: dict[str, int]) -> BugSeverity:
398
+ """Return the highest severity with non-zero count."""
399
+ if counts.get("P0", 0) > 0:
400
+ return BugSeverity.P0
401
+ if counts.get("P1", 0) > 0:
402
+ return BugSeverity.P1
403
+ return BugSeverity.P2
404
+
405
+ def _bdd_verify(
406
+ self,
407
+ skill_id: str,
408
+ defect_type: DefectType,
409
+ count_before: int,
410
+ ) -> bool:
411
+ """BDD-style verification: bug count check + optional review checkpoint.
412
+
413
+ 1. Compares pre/post bug counts (tolerates ≤1 concurrent bug)
414
+ 2. Optionally calls `skillpool review --checkpoint L3` for real regression
415
+ check when the environment supports it
416
+ """
417
+ current_bugs = self._bug_collector.get_bugs(
418
+ skill_id=skill_id,
419
+ defect_type=defect_type,
420
+ )
421
+ count_after = len(current_bugs)
422
+
423
+ # If no new bugs appeared, verification passes
424
+ if count_after <= count_before:
425
+ return True
426
+
427
+ # New bugs appeared — check if they're genuinely new
428
+ # (not just the same bugs we counted before)
429
+ new_bug_count = count_after - count_before
430
+ # If more than 1 new bug of the same type appeared, pattern is worsening
431
+ if new_bug_count > 1:
432
+ return False
433
+
434
+ # Exactly 1 new bug — tolerable as concurrent, but run
435
+ # checkpoint L3 if available for deeper verification
436
+ review_ok = self._run_review_checkpoint(skill_id)
437
+ return review_ok
438
+
439
+ def _run_review_checkpoint(self, skill_id: str) -> bool:
440
+ """Run review checkpoint L3 for a skill. Returns True if review passes.
441
+
442
+ Falls back to True (pass) if review infrastructure is unavailable.
443
+ """
444
+ try:
445
+ from skillpool.review import ReviewManager
446
+ from skillpool.review.models import (
447
+ CheckpointLevel,
448
+ ReviewTrigger,
449
+ ReviewTriggerRequest,
450
+ )
451
+
452
+ rm = ReviewManager()
453
+ request = ReviewTriggerRequest(
454
+ trigger=ReviewTrigger.L3_REGRESSION_FAIL,
455
+ checkpoint=CheckpointLevel.L3,
456
+ affected_skills=[skill_id],
457
+ )
458
+ result = rm.trigger(request)
459
+ # If no veto triggered, review passes
460
+ return not result.veto_triggered
461
+ except Exception as e:
462
+ logger.warning("Review infrastructure unavailable for %s, defaulting pass: %s", skill_id, e)
463
+ # Review infrastructure unavailable — default pass
464
+ return True
465
+
466
+ def _find_skill_yaml(self, skill_id: str) -> Path | None:
467
+ """Find the CSDF YAML file for a skill ID."""
468
+ if not self._skills_dir.exists():
469
+ return None
470
+ # Exact match
471
+ exact = self._skills_dir / f"{skill_id}.yaml"
472
+ if exact.exists():
473
+ return exact
474
+ # Prefix match
475
+ for p in self._skills_dir.iterdir():
476
+ if p.name.startswith(f"{skill_id}-") and p.suffix == ".yaml":
477
+ return p
478
+ return None
479
+
480
+ def _persist_evolution(self, skill_id: str, healing: HealingProposal) -> bool:
481
+ """Write evolution results back to the CSDF YAML file.
482
+
483
+ Updates the YAML with healing metadata (last_healed, upgrade_type,
484
+ defect_type) so the change is durable across restarts.
485
+ """
486
+ yaml_path = self._find_skill_yaml(skill_id)
487
+ if yaml_path is None:
488
+ return False
489
+
490
+ try:
491
+ data = yaml.safe_load(yaml_path.read_text(encoding="utf-8"))
492
+ except (yaml.YAMLError, OSError):
493
+ return False
494
+
495
+ if not isinstance(data, dict):
496
+ return False
497
+
498
+ # Apply healing metadata
499
+ from skillpool.utils.time_utils import utc_now
500
+
501
+ data["last_healed"] = utc_now().isoformat()
502
+ data["last_healing_type"] = healing.upgrade_type.value
503
+ data["last_healing_defect"] = healing.defect_type.value
504
+
505
+ # Write back
506
+ try:
507
+ yaml_path.write_text(
508
+ yaml.dump(data, default_flow_style=False, allow_unicode=True, sort_keys=False),
509
+ encoding="utf-8",
510
+ )
511
+ return True
512
+ except OSError:
513
+ return False
514
+
515
+ def _restore_yaml_snapshot(self, proposal_id: str, skill_id: str) -> bool:
516
+ """Restore original YAML from pre-evolution snapshot."""
517
+ original = self._yaml_snapshots.get(proposal_id)
518
+ if original is None:
519
+ return False
520
+
521
+ yaml_path = self._find_skill_yaml(skill_id)
522
+ if yaml_path is None:
523
+ return False
524
+
525
+ try:
526
+ yaml_path.write_text(original, encoding="utf-8")
527
+ del self._yaml_snapshots[proposal_id]
528
+ return True
529
+ except OSError:
530
+ return False