superlocalmemory 3.8.13 → 4.0.0

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 (212) hide show
  1. package/ATTRIBUTION.md +4 -4
  2. package/CHANGELOG.md +113 -121
  3. package/README.md +65 -63
  4. package/docs/pi-dev-integration.md +1 -1
  5. package/package.json +6 -1
  6. package/plugin/.claude-plugin/plugin.json +1 -1
  7. package/plugin/CLAUDE.md +3 -3
  8. package/plugin/agents/slm-governance-advisor.md +1 -1
  9. package/plugin/agents/slm-loop-runner.md +1 -1
  10. package/plugin/agents/slm-memory-advisor.md +1 -1
  11. package/plugin/agents/slm-optimize-advisor.md +1 -1
  12. package/plugin/requirements.txt +1 -1
  13. package/plugin/skills/slm-cache/SKILL.md +1 -1
  14. package/plugin/skills/slm-compress/SKILL.md +1 -1
  15. package/plugin/skills/slm-governance/SKILL.md +1 -1
  16. package/plugin/skills/slm-graph/SKILL.md +1 -1
  17. package/plugin/skills/slm-loop/SKILL.md +1 -1
  18. package/plugin/skills/slm-mesh/SKILL.md +1 -1
  19. package/plugin/skills/slm-profile/SKILL.md +1 -1
  20. package/plugin/skills/slm-recall/SKILL.md +1 -1
  21. package/plugin/skills/slm-remember/SKILL.md +1 -1
  22. package/plugin/skills/slm-scope/SKILL.md +1 -1
  23. package/plugin/skills/slm-session/SKILL.md +1 -1
  24. package/plugin/skills/slm-status/SKILL.md +1 -1
  25. package/plugin-src/rules/AGENTS.md +1 -1
  26. package/plugin-src/skills/slm-cache/SKILL.md +1 -1
  27. package/plugin-src/skills/slm-compress/SKILL.md +1 -1
  28. package/plugin-src/skills/slm-governance/SKILL.md +248 -0
  29. package/plugin-src/skills/slm-graph/SKILL.md +1 -1
  30. package/plugin-src/skills/slm-loop/SKILL.md +99 -0
  31. package/plugin-src/skills/slm-mesh/SKILL.md +282 -0
  32. package/plugin-src/skills/slm-profile/SKILL.md +148 -0
  33. package/plugin-src/skills/slm-recall/SKILL.md +1 -1
  34. package/plugin-src/skills/slm-remember/SKILL.md +1 -1
  35. package/plugin-src/skills/slm-scope/SKILL.md +176 -0
  36. package/plugin-src/skills/slm-session/SKILL.md +1 -1
  37. package/plugin-src/skills/slm-status/SKILL.md +1 -1
  38. package/pyproject.toml +11 -4
  39. package/src/superlocalmemory/__init__.py +1 -1
  40. package/src/superlocalmemory/cli/commands.py +125 -11
  41. package/src/superlocalmemory/cli/daemon.py +5 -1
  42. package/src/superlocalmemory/cli/main.py +35 -2
  43. package/src/superlocalmemory/cli/ops_cmd.py +281 -0
  44. package/src/superlocalmemory/cli/setup_wizard.py +1 -1
  45. package/src/superlocalmemory/compliance/audit.py +65 -0
  46. package/src/superlocalmemory/compliance/eu_ai_act.py +27 -57
  47. package/src/superlocalmemory/compliance/gdpr.py +416 -20
  48. package/src/superlocalmemory/compliance/retention.py +74 -22
  49. package/src/superlocalmemory/compliance/scheduler.py +78 -9
  50. package/src/superlocalmemory/core/actor_context.py +166 -0
  51. package/src/superlocalmemory/core/admission.py +549 -0
  52. package/src/superlocalmemory/core/backend_orchestrator.py +23 -10
  53. package/src/superlocalmemory/core/config.py +202 -24
  54. package/src/superlocalmemory/core/consolidation_engine.py +13 -13
  55. package/src/superlocalmemory/core/context_cache.py +28 -0
  56. package/src/superlocalmemory/core/embeddings.py +64 -2
  57. package/src/superlocalmemory/core/engine.py +7 -2
  58. package/src/superlocalmemory/core/engine_ingestion.py +65 -3
  59. package/src/superlocalmemory/core/engine_wiring.py +36 -9
  60. package/src/superlocalmemory/core/ingest_policy.py +38 -0
  61. package/src/superlocalmemory/core/maintenance.py +255 -0
  62. package/src/superlocalmemory/core/modes.py +40 -13
  63. package/src/superlocalmemory/core/mutations.py +437 -44
  64. package/src/superlocalmemory/core/operation_policy.py +92 -0
  65. package/src/superlocalmemory/core/operation_policy_registry.py +542 -0
  66. package/src/superlocalmemory/core/operation_request.py +127 -0
  67. package/src/superlocalmemory/core/ops_remediation.py +542 -0
  68. package/src/superlocalmemory/core/recall_pipeline.py +7 -0
  69. package/src/superlocalmemory/core/remember_runtime.py +202 -4
  70. package/src/superlocalmemory/core/remote_mode.py +20 -5
  71. package/src/superlocalmemory/core/store_pipeline.py +150 -0
  72. package/src/superlocalmemory/core/topic_signature.py +19 -4
  73. package/src/superlocalmemory/core/transactions/__init__.py +78 -0
  74. package/src/superlocalmemory/core/transactions/concrete_owners.py +597 -0
  75. package/src/superlocalmemory/core/transactions/erasure.py +825 -0
  76. package/src/superlocalmemory/core/transactions/manifest.py +255 -0
  77. package/src/superlocalmemory/core/transactions/manifest_key.py +155 -0
  78. package/src/superlocalmemory/core/transactions/obligations.py +272 -0
  79. package/src/superlocalmemory/core/transactions/owners.py +114 -0
  80. package/src/superlocalmemory/core/transactions/reconciler.py +285 -0
  81. package/src/superlocalmemory/core/transactions/service.py +330 -0
  82. package/src/superlocalmemory/core/worker_pool.py +33 -5
  83. package/src/superlocalmemory/encoding/cognitive_consolidator.py +70 -28
  84. package/src/superlocalmemory/encoding/emotional.py +75 -14
  85. package/src/superlocalmemory/encoding/scene_builder.py +115 -13
  86. package/src/superlocalmemory/encoding/temporal_parser.py +4 -0
  87. package/src/superlocalmemory/evolution/blind_verifier.py +11 -4
  88. package/src/superlocalmemory/evolution/evolution_store.py +244 -4
  89. package/src/superlocalmemory/evolution/llm_dispatch.py +40 -0
  90. package/src/superlocalmemory/evolution/model_selection.py +18 -3
  91. package/src/superlocalmemory/evolution/mutation_generator.py +3 -0
  92. package/src/superlocalmemory/evolution/skill_activator.py +270 -0
  93. package/src/superlocalmemory/evolution/skill_evolver.py +281 -59
  94. package/src/superlocalmemory/evolution/types.py +30 -8
  95. package/src/superlocalmemory/graph/cozo_backend.py +17 -9
  96. package/src/superlocalmemory/hooks/auto_invoker.py +2 -1
  97. package/src/superlocalmemory/hooks/auto_recall.py +64 -30
  98. package/src/superlocalmemory/hooks/codex_assets.py +14 -1
  99. package/src/superlocalmemory/infra/backup.py +434 -7
  100. package/src/superlocalmemory/infra/process_reaper.py +18 -0
  101. package/src/superlocalmemory/infra/self_heal.py +401 -0
  102. package/src/superlocalmemory/learning/feedback.py +52 -9
  103. package/src/superlocalmemory/loops/engine.py +10 -0
  104. package/src/superlocalmemory/mcp/_daemon_proxy.py +3 -0
  105. package/src/superlocalmemory/mcp/http_transport.py +30 -331
  106. package/src/superlocalmemory/mcp/profiles.py +5 -0
  107. package/src/superlocalmemory/mcp/resources.py +8 -0
  108. package/src/superlocalmemory/mcp/server.py +51 -4
  109. package/src/superlocalmemory/mcp/shared.py +19 -0
  110. package/src/superlocalmemory/mcp/tools_active.py +25 -4
  111. package/src/superlocalmemory/mcp/tools_code_graph.py +26 -18
  112. package/src/superlocalmemory/mcp/tools_context.py +50 -8
  113. package/src/superlocalmemory/mcp/tools_core.py +69 -21
  114. package/src/superlocalmemory/mcp/tools_evolution.py +9 -2
  115. package/src/superlocalmemory/mcp/tools_learning.py +21 -10
  116. package/src/superlocalmemory/mcp/tools_loops.py +29 -18
  117. package/src/superlocalmemory/mcp/tools_mesh.py +8 -0
  118. package/src/superlocalmemory/mcp/tools_ops.py +115 -0
  119. package/src/superlocalmemory/mcp/tools_optimize.py +4 -0
  120. package/src/superlocalmemory/mcp/tools_v28.py +10 -3
  121. package/src/superlocalmemory/mcp/tools_v3.py +34 -14
  122. package/src/superlocalmemory/mcp/tools_v33.py +18 -33
  123. package/src/superlocalmemory/mesh/broker.py +124 -46
  124. package/src/superlocalmemory/mesh/broker_security.py +470 -0
  125. package/src/superlocalmemory/mesh/discovery.py +365 -0
  126. package/src/superlocalmemory/mesh/lock_protocol.py +313 -0
  127. package/src/superlocalmemory/mesh/node_identity.py +97 -0
  128. package/src/superlocalmemory/mesh/outbox_remote.py +429 -0
  129. package/src/superlocalmemory/mesh/remote_sync.py +511 -28
  130. package/src/superlocalmemory/mesh/state_sync.py +286 -0
  131. package/src/superlocalmemory/optimize/config/store.py +45 -0
  132. package/src/superlocalmemory/parameterization/cross_project.py +12 -0
  133. package/src/superlocalmemory/parameterization/prompt_injector.py +13 -11
  134. package/src/superlocalmemory/parameterization/prompt_lifecycle.py +8 -2
  135. package/src/superlocalmemory/parameterization/workflow_miner.py +17 -0
  136. package/src/superlocalmemory/retrieval/ann_index.py +5 -0
  137. package/src/superlocalmemory/retrieval/bm25_channel.py +49 -2
  138. package/src/superlocalmemory/retrieval/engine.py +19 -4
  139. package/src/superlocalmemory/retrieval/fusion.py +4 -1
  140. package/src/superlocalmemory/retrieval/hopfield_channel.py +9 -3
  141. package/src/superlocalmemory/retrieval/remote_reranker.py +47 -22
  142. package/src/superlocalmemory/retrieval/reranker.py +32 -1
  143. package/src/superlocalmemory/retrieval/temporal_channel.py +16 -3
  144. package/src/superlocalmemory/retrieval/temporal_utils.py +107 -0
  145. package/src/superlocalmemory/retrieval/temporal_validity_filter.py +155 -42
  146. package/src/superlocalmemory/retrieval/vector_store.py +214 -8
  147. package/src/superlocalmemory/server/api.py +5 -5
  148. package/src/superlocalmemory/server/egress_policy.py +258 -0
  149. package/src/superlocalmemory/server/rbac_enforce.py +32 -0
  150. package/src/superlocalmemory/server/route_mutations.py +20 -0
  151. package/src/superlocalmemory/server/routes/compliance.py +153 -7
  152. package/src/superlocalmemory/server/routes/data_io.py +43 -2
  153. package/src/superlocalmemory/server/routes/events.py +15 -0
  154. package/src/superlocalmemory/server/routes/memories.py +56 -3
  155. package/src/superlocalmemory/server/routes/mesh.py +82 -1
  156. package/src/superlocalmemory/server/routes/mesh_lock.py +54 -0
  157. package/src/superlocalmemory/server/routes/mesh_state.py +63 -0
  158. package/src/superlocalmemory/server/routes/v3_api.py +50 -27
  159. package/src/superlocalmemory/server/routes/ws.py +86 -0
  160. package/src/superlocalmemory/server/ui.py +6 -6
  161. package/src/superlocalmemory/server/unified_daemon.py +942 -119
  162. package/src/superlocalmemory/storage/_migration_internals.py +568 -0
  163. package/src/superlocalmemory/storage/_schema_version.py +110 -0
  164. package/src/superlocalmemory/storage/database.py +329 -24
  165. package/src/superlocalmemory/storage/embedding_migrator.py +246 -51
  166. package/src/superlocalmemory/storage/erasure_fence.py +45 -0
  167. package/src/superlocalmemory/storage/generation_fence.py +63 -0
  168. package/src/superlocalmemory/storage/migration_runner.py +140 -417
  169. package/src/superlocalmemory/storage/migrations/M009_model_lineage.py +40 -0
  170. package/src/superlocalmemory/storage/migrations/M033_projection_transactions.py +148 -0
  171. package/src/superlocalmemory/storage/migrations/M034_obligation_integrity.py +58 -0
  172. package/src/superlocalmemory/storage/migrations/M035_erasure_receipts.py +113 -0
  173. package/src/superlocalmemory/storage/migrations/M036_vector_row_map.py +107 -0
  174. package/src/superlocalmemory/storage/migrations/M037_manifest_hmac_version.py +162 -0
  175. package/src/superlocalmemory/storage/migrations/{M033_learning_feedback_channel.py → M038_learning_feedback_channel.py} +3 -3
  176. package/src/superlocalmemory/storage/migrations/M039_scene_fact_members.py +137 -0
  177. package/src/superlocalmemory/storage/migrations/__init__.py +4 -2
  178. package/src/superlocalmemory/storage/schema.py +67 -0
  179. package/src/superlocalmemory/storage/write_coordinator.py +125 -0
  180. package/src/superlocalmemory/trust/scorer.py +28 -4
  181. package/src/superlocalmemory/ui/index.html +14 -3
  182. package/src/superlocalmemory/ui/js/auto-settings.js +12 -1
  183. package/src/superlocalmemory/ui/js/brain.js +6 -4
  184. package/src/superlocalmemory/ui/js/compliance.js +66 -12
  185. package/src/superlocalmemory/ui/js/dashboard.js +13 -3
  186. package/src/superlocalmemory/ui/js/feedback.js +8 -2
  187. package/src/superlocalmemory/ui/js/lifecycle.js +7 -1
  188. package/src/superlocalmemory/ui/js/modal.js +272 -5
  189. package/src/superlocalmemory/ui/js/od-backup.js +9 -2
  190. package/src/superlocalmemory/ui/js/od-compliance-ext.js +301 -0
  191. package/src/superlocalmemory/ui/js/od-operations.js +154 -23
  192. package/src/superlocalmemory/ui/js/od-ops-health.js +417 -0
  193. package/src/superlocalmemory/ui/js/od-optimize.js +35 -21
  194. package/src/superlocalmemory/ui/js/od-team.js +9 -2
  195. package/src/superlocalmemory/ui/js/optimize.js +13 -16
  196. package/src/superlocalmemory/ui/js/profiles.js +7 -3
  197. package/src/superlocalmemory/ui/js/settings.js +7 -1
  198. package/src/superlocalmemory/vector/lancedb_backend.py +19 -9
  199. package/src/superlocalmemory/attribution/mathematical_dna.py +0 -235
  200. package/src/superlocalmemory/cli/post_install.py +0 -114
  201. package/src/superlocalmemory/core/clock_monitor.py +0 -45
  202. package/src/superlocalmemory/core/db_pool.py +0 -80
  203. package/src/superlocalmemory/core/error_catalog.py +0 -113
  204. package/src/superlocalmemory/core/loop_watchdog.py +0 -56
  205. package/src/superlocalmemory/core/priority_queue.py +0 -61
  206. package/src/superlocalmemory/core/pruning_engine.py +0 -216
  207. package/src/superlocalmemory/core/queue_dispatcher.py +0 -73
  208. package/src/superlocalmemory/core/slmignore.py +0 -125
  209. package/src/superlocalmemory/infra/heartbeat_monitor.py +0 -140
  210. package/src/superlocalmemory/infra/webhook_dispatcher.py +0 -247
  211. package/src/superlocalmemory/learning/quantization_scheduler.py +0 -320
  212. package/src/superlocalmemory/storage/access_control.py +0 -182
@@ -54,7 +54,9 @@ from superlocalmemory.evolution.model_selection import (
54
54
 
55
55
  logger = logging.getLogger(__name__)
56
56
 
57
- EVOLVED_SKILLS_DIR = Path.home() / ".claude" / "skills" / "evolved"
57
+ # Resolve the quarantine location under the selected runtime data root so a
58
+ # custom SLM_DATA_DIR is honored rather than always writing under the home dir.
59
+ EVOLVED_SKILLS_DIR = canonical_data_root() / "quarantine" / "skills"
58
60
 
59
61
  # Model aliasing + per-step selection live in ``model_selection`` — a single
60
62
  # source of truth shared with the config layer. ``_MODEL_ALIASES`` and
@@ -117,6 +119,8 @@ class SkillEvolver:
117
119
  *,
118
120
  profile_id: str = "default",
119
121
  budget: EvolutionBudget | None = None,
122
+ audit_chain=None,
123
+ activator=None,
120
124
  ):
121
125
  self._db_path = str(db_path)
122
126
  self._store = EvolutionStore(db_path)
@@ -128,6 +132,8 @@ class SkillEvolver:
128
132
  self._models = None
129
133
  self._profile_id = profile_id
130
134
  self._current_cycle_id: str | None = None
135
+ # Injectable activator for testing (CRIT-2).
136
+ self._activator = activator
131
137
 
132
138
  # SB-3: SkillEvolver always holds a budget. Default one is rooted
133
139
  # at ~/.superlocalmemory so production callers pick it up
@@ -141,12 +147,43 @@ class SkillEvolver:
141
147
  )
142
148
  self._budget = budget
143
149
 
150
+ # Phase 2 (Module 8): tamper-evident audit chain for evolution events.
151
+ # Never use ":memory:" in production — use file path next to db_path.
152
+ if audit_chain is None:
153
+ from superlocalmemory.compliance.audit import AuditChain
154
+ if self._db_path == ":memory:":
155
+ # In-memory DB is test-only; use in-memory audit too.
156
+ audit_chain = AuditChain(":memory:")
157
+ else:
158
+ audit_db = Path(self._db_path).parent / "audit.db"
159
+ audit_chain = AuditChain(audit_db)
160
+ self._audit = audit_chain
161
+
144
162
  def _is_enabled(self) -> bool:
145
163
  """Check if evolution is enabled in config."""
146
164
  if self._config and hasattr(self._config, "evolution"):
147
165
  return self._config.evolution.enabled
148
166
  return False
149
167
 
168
+ def _is_auto_approve(self) -> bool:
169
+ """Check evolution.auto_approve config key (LLD Decision A2, default False).
170
+
171
+ Config key wins. Falls back to SLM_EVO_AUTO_APPROVE env var for CI.
172
+ """
173
+ evo_cfg = getattr(self._config, "evolution", None)
174
+ if evo_cfg is not None and hasattr(evo_cfg, "auto_approve"):
175
+ return bool(evo_cfg.auto_approve)
176
+ return os.environ.get("SLM_EVO_AUTO_APPROVE", "").strip() in (
177
+ "1", "true", "True",
178
+ )
179
+
180
+ def _get_activator(self):
181
+ """Return the SkillActivator instance (injectable for tests)."""
182
+ if self._activator is not None:
183
+ return self._activator
184
+ from superlocalmemory.evolution.skill_activator import SkillActivator
185
+ return SkillActivator()
186
+
150
187
  def _get_backend(self) -> str:
151
188
  """Get or detect the LLM backend."""
152
189
  if self._backend:
@@ -225,7 +262,10 @@ class SkillEvolver:
225
262
  ) -> dict:
226
263
  """Inner consolidation loop — runs under an open budget cycle."""
227
264
  self._store.reset_cycle(profile_id)
228
- results = {"candidates": 0, "evolved": 0, "rejected": 0, "skipped": 0, "backend": backend}
265
+ results = {
266
+ "candidates": 0, "evolved": 0, "rejected": 0,
267
+ "skipped": 0, "quarantined": 0, "backend": backend,
268
+ }
229
269
 
230
270
  # Prune recovered skills from anti-loop tracking
231
271
  active_degraded = self._degradation.get_active_degraded(profile_id)
@@ -248,6 +288,8 @@ class SkillEvolver:
248
288
  results["evolved"] += 1
249
289
  elif outcome == "rejected":
250
290
  results["rejected"] += 1
291
+ elif outcome == "quarantined":
292
+ results["quarantined"] += 1
251
293
  else:
252
294
  results["skipped"] += 1
253
295
 
@@ -332,10 +374,32 @@ class SkillEvolver:
332
374
  def _process_candidate(
333
375
  self, candidate: EvolutionCandidate, profile_id: str,
334
376
  ) -> str:
335
- """Process a single evolution candidate through the full pipeline.
336
-
337
- Returns: "evolved", "rejected", or "skipped"
377
+ """Process a single evolution candidate through the governed state machine.
378
+
379
+ State machine (Phase 2):
380
+ CANDIDATE (insert_record once)
381
+ → LLM confirm gate
382
+ fail → append_transition(CANDIDATE, REJECTED) → return "rejected"
383
+ → Generate mutation
384
+ fail → append_transition(CANDIDATE, FAILED) → return "rejected"
385
+ → Blind verify
386
+ fail → append_transition(CANDIDATE, REJECTED) → return "rejected"
387
+ pass → append_transition(CANDIDATE, VERIFIED_QUARANTINED)
388
+ → emit audit "skill_promotion"
389
+ VERIFIED_QUARANTINED
390
+ → no auto_approve → return "quarantined"
391
+ → auto_approve → append_transition(VQ, APPROVED)
392
+ APPROVED
393
+ → activate()
394
+ success → append_transition(APPROVED, ACTIVE) → emit "skill_activation"
395
+ → return "evolved"
396
+ fail → append_transition(APPROVED, ROLLED_BACK)
397
+ → emit "skill_activation_failed" → return "rejected"
398
+
399
+ Returns one of: "evolved", "rejected", "skipped", "quarantined"
338
400
  """
401
+ from superlocalmemory.evolution.skill_activator import SkillActivationError
402
+
339
403
  now = datetime.now(timezone.utc).isoformat()
340
404
  record_id = hashlib.sha256(
341
405
  f"{candidate.skill_name}:{candidate.trigger.value}:{now}".encode(),
@@ -350,16 +414,20 @@ class SkillEvolver:
350
414
  return "skipped"
351
415
 
352
416
  if self._store.has_exceeded_attempts(candidate.skill_name, profile_id):
353
- logger.info("Skill %s exceeded max attempts, flagging for review", candidate.skill_name)
417
+ logger.info(
418
+ "Skill %s exceeded max attempts, flagging for review",
419
+ candidate.skill_name,
420
+ )
354
421
  return "skipped"
355
422
 
356
- # Mark as addressed (even if we reject — prevents repeated checks)
423
+ # Mark as addressed (even on reject — prevents repeated scans)
357
424
  self._store.mark_addressed(candidate.skill_name, context_hash)
358
425
 
359
426
  # Step 1: Read original skill content
360
427
  original_content = self._read_skill_content(candidate.skill_name)
361
428
 
362
- # Create initial record
429
+ # CRIT-1: insert_record is called EXACTLY ONCE per candidate.
430
+ # All subsequent state changes use append_transition with this record_id.
363
431
  record = EvolutionRecord(
364
432
  id=record_id,
365
433
  skill_name=candidate.skill_name,
@@ -371,56 +439,50 @@ class SkillEvolver:
371
439
  original_content=original_content[:2000],
372
440
  created_at=now,
373
441
  )
374
- self._store.save_record(record, profile_id)
442
+ self._store.insert_record(record, profile_id)
375
443
 
376
- # Step 2: LLM confirmation gate (uses Haiku for cost)
444
+ # Step 2: LLM confirmation gate (strict JSON parse)
377
445
  confirmed = self._llm_confirm(candidate, original_content)
378
446
  if not confirmed:
379
- record = dataclasses.replace(
380
- record,
381
- status=EvolutionStatus.REJECTED,
382
- rejection_reason="LLM confirmation gate rejected",
383
- completed_at=datetime.now(timezone.utc).isoformat(),
447
+ self._store.append_transition(
448
+ record_id, profile_id,
449
+ EvolutionStatus.CANDIDATE, EvolutionStatus.REJECTED,
450
+ reason="llm_confirmation_rejected",
384
451
  )
385
- self._store.save_record(record, profile_id)
386
452
  return "rejected"
387
453
 
388
- # Step 3: Generate mutation (uses Sonnet for quality)
454
+ # Step 3: Generate mutation
389
455
  prompt = mutgen.build_mutation_prompt(candidate, original_content)
390
456
  evolved_content = self._generate_mutation(prompt)
391
457
  if not evolved_content:
392
- record = dataclasses.replace(
393
- record,
394
- status=EvolutionStatus.FAILED,
395
- rejection_reason="Mutation generation failed",
396
- completed_at=datetime.now(timezone.utc).isoformat(),
458
+ self._store.append_transition(
459
+ record_id, profile_id,
460
+ EvolutionStatus.CANDIDATE, EvolutionStatus.FAILED,
461
+ reason="mutation_generation_failed",
397
462
  )
398
- self._store.save_record(record, profile_id)
399
463
  return "rejected"
400
464
 
401
- # Step 4: Blind verification (uses Haiku — different model from generator)
465
+ # Step 4: Blind verification (independent model from generator)
402
466
  description = self._extract_description(evolved_content)
403
467
  v_prompt = verifier.build_verification_prompt(
404
468
  candidate.skill_name, description, evolved_content,
405
469
  )
406
470
  v_result = self._blind_verify(v_prompt)
407
471
  if not v_result.passed:
408
- record = dataclasses.replace(
409
- record,
410
- status=EvolutionStatus.REJECTED,
411
- rejection_reason=f"Blind verification failed: {v_result.reasoning}",
412
- evolved_content=evolved_content[:2000],
413
- blind_verified=False,
414
- completed_at=datetime.now(timezone.utc).isoformat(),
472
+ self._store.append_transition(
473
+ record_id, profile_id,
474
+ EvolutionStatus.CANDIDATE, EvolutionStatus.REJECTED,
475
+ reason=f"blind_verification: {v_result.reasoning}",
415
476
  )
416
- self._store.save_record(record, profile_id)
417
477
  return "rejected"
418
478
 
419
- # Step 5: Persist evolved skill
479
+ # Step 5: Write to quarantine (returns path + dir_name — CRIT-2)
420
480
  diff = self._compute_diff(original_content, evolved_content)
421
- skill_path = self._write_evolved_skill(candidate, evolved_content, record_id)
481
+ skill_path, dir_name = self._write_evolved_skill(
482
+ candidate, evolved_content, record_id,
483
+ )
422
484
 
423
- # M-GENERATION: Compute generation from parent's history
485
+ # Compute generation from parent history
424
486
  parent_history = self._store.get_skill_history(
425
487
  candidate.skill_name, profile_id, limit=1,
426
488
  )
@@ -430,25 +492,122 @@ class SkillEvolver:
430
492
  else 0
431
493
  )
432
494
 
433
- record = dataclasses.replace(
434
- record,
435
- status=EvolutionStatus.PROMOTED,
436
- evolved_content=evolved_content[:2000],
437
- content_diff=diff[:2000],
438
- mutation_summary=self._summarize_diff(diff),
439
- blind_verified=True,
440
- generation=parent_gen + 1,
441
- completed_at=datetime.now(timezone.utc).isoformat(),
495
+ # CANDIDATE → VERIFIED_QUARANTINED
496
+ self._store.append_transition(
497
+ record_id, profile_id,
498
+ EvolutionStatus.CANDIDATE, EvolutionStatus.VERIFIED_QUARANTINED,
499
+ reason="blind_verified",
500
+ metadata={
501
+ "quarantine_dir_name": dir_name,
502
+ "diff_summary": self._summarize_diff(diff),
503
+ "generation": parent_gen + 1,
504
+ },
442
505
  )
443
- self._store.save_record(record, profile_id)
444
506
  self._store.record_evolution_attempt(profile_id)
445
507
 
508
+ # Phase 2 (Module 8): emit "skill_promotion" audit event
509
+ evolved_hash = hashlib.sha256(evolved_content.encode()).hexdigest()
510
+ self._audit.log(
511
+ operation="skill_promotion",
512
+ agent_id=self._profile_id,
513
+ profile_id=self._profile_id,
514
+ content_hash=evolved_hash,
515
+ metadata={
516
+ "record_id": record_id,
517
+ "skill_name": candidate.skill_name,
518
+ "evolution_type": candidate.evolution_type.value,
519
+ "trigger": candidate.trigger.value,
520
+ "quarantine_dir": dir_name,
521
+ },
522
+ )
523
+
446
524
  logger.info(
447
- "Evolved skill: %s (%s via %s) → %s",
525
+ "Evolved skill: %s (%s via %s) → quarantine %s",
448
526
  candidate.skill_name, candidate.evolution_type.value,
449
527
  candidate.trigger.value, skill_path,
450
528
  )
451
- return "evolved"
529
+
530
+ # Check auto-approve — if off, stop here for human review
531
+ if not self._is_auto_approve():
532
+ return "quarantined"
533
+
534
+ # VERIFIED_QUARANTINED → APPROVED (auto)
535
+ actor_id = "auto"
536
+ self._store.append_transition(
537
+ record_id, profile_id,
538
+ EvolutionStatus.VERIFIED_QUARANTINED, EvolutionStatus.APPROVED,
539
+ actor_id=actor_id, reason="auto_approved",
540
+ )
541
+
542
+ # APPROVED → ACTIVE or ROLLED_BACK
543
+ activator = self._get_activator()
544
+ try:
545
+ activation_result = activator.activate(
546
+ candidate.skill_name, dir_name, actor_id=actor_id,
547
+ )
548
+ self._store.append_transition(
549
+ record_id, profile_id,
550
+ EvolutionStatus.APPROVED, EvolutionStatus.ACTIVE,
551
+ actor_id=actor_id,
552
+ metadata={"live_path": activation_result.get("live_path", "")},
553
+ )
554
+ self._audit.log(
555
+ operation="skill_activation",
556
+ agent_id=actor_id,
557
+ profile_id=self._profile_id,
558
+ content_hash=activation_result["content_hash"],
559
+ metadata={
560
+ "record_id": record_id,
561
+ "skill_name": candidate.skill_name,
562
+ "live_path": activation_result["live_path"],
563
+ "backup_path": activation_result.get("backup_path"),
564
+ },
565
+ )
566
+ return "evolved"
567
+ except (SkillActivationError, FileNotFoundError, ValueError, OSError) as exc:
568
+ # Audit P1-1: broaden the catch. A missing quarantine artifact raises
569
+ # FileNotFoundError and a path-traversal guard raises ValueError; both
570
+ # previously escaped after APPROVED was recorded, leaving the machine
571
+ # stuck at APPROVED with no terminal transition.
572
+ # Audit P1-2: actually PERFORM the rollback instead of only logging
573
+ # one. The terminal status is honest — ROLLED_BACK only if a restore
574
+ # happened, otherwise FAILED (nothing was written to live).
575
+ try:
576
+ rb = activator.rollback(candidate.skill_name)
577
+ rolled_back = bool(rb.get("rolled_back"))
578
+ except Exception as rb_exc: # rollback must never mask the original error
579
+ logger.error(
580
+ "skill rollback errored for %s: %s",
581
+ candidate.skill_name, rb_exc,
582
+ )
583
+ rolled_back = False
584
+ end_status = (
585
+ EvolutionStatus.ROLLED_BACK if rolled_back
586
+ else EvolutionStatus.FAILED
587
+ )
588
+ self._store.append_transition(
589
+ record_id, profile_id,
590
+ EvolutionStatus.APPROVED, end_status,
591
+ actor_id=actor_id, reason=str(exc),
592
+ )
593
+ self._audit.log(
594
+ operation="skill_activation_failed",
595
+ agent_id=actor_id,
596
+ profile_id=self._profile_id,
597
+ content_hash="",
598
+ metadata={
599
+ "record_id": record_id,
600
+ "skill_name": candidate.skill_name,
601
+ "error": str(exc),
602
+ "rolled_back": rolled_back,
603
+ },
604
+ )
605
+ logger.error(
606
+ "skill activation failed for %s: %s (%s)",
607
+ candidate.skill_name, exc,
608
+ "rolled back" if rolled_back else "failed, nothing to roll back",
609
+ )
610
+ return "rejected"
452
611
 
453
612
  # ------------------------------------------------------------------
454
613
  # LLM calls — single-line funnel through evolution.llm_dispatch
@@ -520,22 +679,76 @@ class SkillEvolver:
520
679
  logger.debug("evolution dispatch failed: %s", exc)
521
680
  return ""
522
681
 
523
- def _llm_confirm(self, candidate: EvolutionCandidate, original: str) -> bool:
524
- """LLM confirmation gate."""
525
- prompt = (
682
+ def _parse_approval_decision(self, response: str) -> bool:
683
+ """Parse LLM approval decision from structured JSON.
684
+
685
+ Accepts ONLY {"decision": "approve"} (case-insensitive value).
686
+ Rejects: "yes", "maybe", "approve" as free-text, partial JSON, or any
687
+ other value including {"decision": "yes"} or {"decision": "approved"}.
688
+
689
+ LLMs sometimes emit leading text before the JSON object — the parser
690
+ finds the first '{' and last '}' to extract the JSON fragment.
691
+
692
+ Returns True only if decision is exactly "approve" (case-insensitive).
693
+ Returns False for all other inputs including empty string, malformed
694
+ JSON, or any decision value other than "approve".
695
+ """
696
+ if not response:
697
+ return False
698
+ stripped = response.strip()
699
+ # Find first JSON object — LLM may emit leading explanation text.
700
+ start = stripped.find("{")
701
+ end = stripped.rfind("}")
702
+ if start == -1 or end == -1 or end <= start:
703
+ return False
704
+ try:
705
+ obj = json.loads(stripped[start:end + 1])
706
+ except (json.JSONDecodeError, ValueError):
707
+ return False
708
+ if not isinstance(obj, dict):
709
+ return False
710
+ decision = obj.get("decision", "")
711
+ return isinstance(decision, str) and decision.lower() == "approve"
712
+
713
+ def _build_confirm_prompt(
714
+ self, candidate: EvolutionCandidate, original: str,
715
+ ) -> str:
716
+ """Build the strict-JSON LLM confirmation prompt."""
717
+ return (
526
718
  f"A skill '{candidate.skill_name}' has effective score "
527
- f"{candidate.effective_score:.0%} over {candidate.invocation_count} invocations.\n"
719
+ f"{candidate.effective_score:.0%} over "
720
+ f"{candidate.invocation_count} invocations.\n"
528
721
  f"Evidence: {'; '.join(candidate.evidence)}\n\n"
529
- f"Should this skill be evolved ({candidate.evolution_type.value})? "
530
- f"Reply YES or NO with brief reason."
722
+ f"Should this skill be evolved ({candidate.evolution_type.value})?\n"
723
+ f"Respond ONLY with a JSON object. No other text.\n"
724
+ f'Approve: {{"decision": "approve"}}\n'
725
+ f'Reject: {{"decision": "reject", "reason": "one sentence"}}'
531
726
  )
727
+
728
+ def _llm_confirm(self, candidate: EvolutionCandidate, original: str) -> bool:
729
+ """LLM confirmation gate — strict structured decision parse (Module 3).
730
+
731
+ Replaces the legacy 'yes' in response.lower() check with strict JSON
732
+ parsing. Only {"decision": "approve"} returns True.
733
+ """
734
+ prompt = self._build_confirm_prompt(candidate, original)
532
735
  response = self._llm_call(
533
736
  prompt, max_tokens=100, model=self._get_models().confirm,
534
737
  )
535
738
  if not response:
536
- logger.warning("LLM confirmation gate: empty response, skipping evolution for %s", candidate.skill_name)
739
+ logger.warning(
740
+ "LLM confirmation gate: empty response, "
741
+ "skipping evolution for %s",
742
+ candidate.skill_name,
743
+ )
537
744
  return False # Fail-closed: no LLM = no evolution
538
- return "yes" in response.lower()
745
+ approved = self._parse_approval_decision(response)
746
+ if not approved:
747
+ logger.info(
748
+ "LLM confirmation gate rejected %s (response: %.80r)",
749
+ candidate.skill_name, response,
750
+ )
751
+ return approved
539
752
 
540
753
  def _generate_mutation(self, prompt: str) -> Optional[str]:
541
754
  """Generate evolved SKILL.md via the configured mutation model.
@@ -619,8 +832,16 @@ class SkillEvolver:
619
832
  candidate: EvolutionCandidate,
620
833
  content: str,
621
834
  record_id: str,
622
- ) -> Path:
623
- """Write evolved SKILL.md to ~/.claude/skills/evolved/."""
835
+ ) -> tuple[Path, str]:
836
+ """Write evolved SKILL.md to the quarantine directory for review.
837
+
838
+ Returns (skill_path, dir_name) — both are needed by _process_candidate:
839
+ - skill_path: the Path to the written SKILL.md (for logging)
840
+ - dir_name: the quarantine subdirectory name (CRIT-2; passed to SkillActivator)
841
+
842
+ The dir_name is DISTINCT from candidate.skill_name. It is the sanitized,
843
+ versioned subdirectory inside the quarantine root, e.g. "brainstorming-vabc12".
844
+ """
624
845
  EVOLVED_SKILLS_DIR.mkdir(parents=True, exist_ok=True)
625
846
 
626
847
  # Build directory name. skill_name derives from a behavioral-assertion
@@ -648,20 +869,21 @@ class SkillEvolver:
648
869
  skill_path = skill_dir / "SKILL.md"
649
870
  skill_path.write_text(content, encoding="utf-8")
650
871
 
651
- # Write metadata sidecar
872
+ # Write metadata sidecar (CRIT-2: includes quarantine_dir_name for recovery)
652
873
  meta = {
653
874
  "skill_id": dir_name,
654
875
  "parent_skill_id": candidate.skill_name,
655
876
  "evolution_type": candidate.evolution_type.value,
656
877
  "trigger": candidate.trigger.value,
657
878
  "record_id": record_id,
879
+ "quarantine_dir_name": dir_name,
658
880
  "evidence": list(candidate.evidence),
659
881
  "created_at": datetime.now(timezone.utc).isoformat(),
660
882
  }
661
883
  meta_path = skill_dir / ".skill_meta.json"
662
884
  meta_path.write_text(json.dumps(meta, indent=2), encoding="utf-8")
663
885
 
664
- return skill_path
886
+ return skill_path, dir_name
665
887
 
666
888
  # ------------------------------------------------------------------
667
889
  # Utilities
@@ -32,14 +32,30 @@ class TriggerType(str, Enum):
32
32
 
33
33
 
34
34
  class EvolutionStatus(str, Enum):
35
- """Pipeline status."""
36
- CANDIDATE = "candidate" # Detected, not yet confirmed
37
- CONFIRMED = "confirmed" # LLM gate passed
38
- MUTATED = "mutated" # New SKILL.md generated
39
- VERIFIED = "verified" # Blind verification passed
40
- PROMOTED = "promoted" # Live — evolved skill active
41
- REJECTED = "rejected" # Failed verification or gate
42
- FAILED = "failed" # Error during evolution
35
+ """Pipeline status.
36
+
37
+ State machine (Phase 2):
38
+ CANDIDATE VERIFIED_QUARANTINED APPROVED → ACTIVE
39
+ ↓ ↓
40
+ REJECTED ROLLED_BACK
41
+
42
+ Legacy values (CONFIRMED, MUTATED, VERIFIED, PROMOTED) are retained for
43
+ DB-row compatibility with pre-Phase-2 records. New code uses the states
44
+ above. PROMOTED is an alias for what is now VERIFIED_QUARANTINED in the
45
+ live-in-quarantine sense; new records use VERIFIED_QUARANTINED explicitly.
46
+ """
47
+ CANDIDATE = "candidate" # Detected, not yet processed
48
+ CONFIRMED = "confirmed" # Legacy: LLM gate passed
49
+ MUTATED = "mutated" # Legacy: new SKILL.md generated
50
+ VERIFIED = "verified" # Legacy: blind verify passed
51
+ PROMOTED = "promoted" # Legacy alias for quarantined
52
+ REJECTED = "rejected" # Failed gate or verification
53
+ FAILED = "failed" # Error during evolution
54
+ # --- Phase 2 states ---
55
+ VERIFIED_QUARANTINED = "verified_quarantined" # Passed blind verify, in quarantine
56
+ APPROVED = "approved" # Human/policy approved; ready to activate
57
+ ACTIVE = "active" # Live in skill directory
58
+ ROLLED_BACK = "rolled_back" # Activation reverted; prior artifact restored
43
59
 
44
60
 
45
61
  @dataclass(frozen=True)
@@ -74,6 +90,12 @@ class EvolutionRecord:
74
90
  rejection_reason: str = ""
75
91
  created_at: str = ""
76
92
  completed_at: str = ""
93
+ # Phase 2 (CRIT-2): the sanitized directory name inside the quarantine root,
94
+ # e.g. "brainstorming-vabc12". Distinct from skill_name ("brainstorming").
95
+ # Stored in-memory; not persisted to skill_evolution_log (no schema column).
96
+ # The activator reads this to locate the artifact; it is also stored in the
97
+ # VERIFIED_QUARANTINED transition metadata for auditability.
98
+ quarantine_dir_name: str = ""
77
99
 
78
100
 
79
101
  @dataclass(frozen=True)
@@ -17,6 +17,7 @@ Part of Qualixar | Author: Varun Pratap Bhardwaj
17
17
 
18
18
  from __future__ import annotations
19
19
 
20
+ import importlib.util
20
21
  import json
21
22
  import logging
22
23
  import sqlite3
@@ -28,13 +29,17 @@ from superlocalmemory.storage.logical_edges import iter_logical_edges
28
29
 
29
30
  logger = logging.getLogger(__name__)
30
31
 
31
- # Optional importCozoDB is an optional dependency
32
- try:
33
- from pycozo.client import Client as _CozoClient
34
- _COZO_AVAILABLE = True
35
- except ImportError:
36
- _CozoClient = None # type: ignore[assignment]
37
- _COZO_AVAILABLE = False
32
+ # Availability via find_spec only never import pycozo at module load.
33
+ # Native bindings belong to the same risk class as lancedb (background
34
+ # runtimes / GC races). Real import is deferred to CozoDBGraphBackend.__init__.
35
+ def _pycozo_spec_present() -> bool:
36
+ try:
37
+ return importlib.util.find_spec("pycozo") is not None
38
+ except Exception:
39
+ return False
40
+
41
+
42
+ _COZO_AVAILABLE: bool = _pycozo_spec_present()
38
43
 
39
44
 
40
45
  class CozoDBError(Exception):
@@ -121,14 +126,17 @@ class CozoDBGraphBackend:
121
126
  """
122
127
 
123
128
  def __init__(self, db_path: str) -> None:
124
- if not _COZO_AVAILABLE:
129
+ if not _pycozo_spec_present():
125
130
  raise CozoDBNotAvailable(
126
131
  "CozoDB not installed. Run: pip install superlocalmemory[cozo]"
127
132
  )
133
+ # Lazy import: only construct-time loads the native binding.
134
+ from pycozo.client import Client as _CozoClient # noqa: PLC0415
135
+
128
136
  path = Path(db_path)
129
137
  path.parent.mkdir(parents=True, exist_ok=True)
130
138
  self._db_path = str(path)
131
- client = _CozoClient("rocksdb", self._db_path, dataframe=False) # type: ignore[misc]
139
+ client = _CozoClient("rocksdb", self._db_path, dataframe=False)
132
140
  self._db = _CozoClientAdapter(client)
133
141
  self._shadow_checks = 0
134
142
  self._shadow_mismatches = 0
@@ -516,5 +516,6 @@ class AutoInvoker:
516
516
  self._config.profile_id,
517
517
  )
518
518
  except Exception as exc:
519
- logger.debug("Soft prompt injection failed: %s", exc)
519
+ # warning — not debug so unexpected failures surface in prod logs
520
+ logger.warning("Soft prompt injection failed: %s", exc)
520
521
  return ""