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
@@ -0,0 +1,270 @@
1
+ # Copyright (c) 2026 Varun Pratap Bhardwaj / Qualixar
2
+ # Licensed under AGPL-3.0-or-later — see LICENSE file
3
+ # Part of SuperLocalMemory V4 | https://qualixar.com | https://varunpratap.com
4
+
5
+ """SkillActivator — atomic activation of quarantined evolved skills.
6
+
7
+ Copies a VERIFIED_QUARANTINED skill artifact into the live skill directory
8
+ (~/.claude/skills/{skill_name}/SKILL.md), retains the prior artifact as a
9
+ .bak file, and provides a tested rollback path.
10
+
11
+ Invariants:
12
+ 1. The destination directory is a child of live_root — path traversal raises ValueError.
13
+ 2. The backup is written BEFORE the live copy is overwritten.
14
+ 3. Activation is atomic via a .tmp file → os.replace (POSIX atomic on same fs).
15
+ 4. Rollback restores from the .bak file and leaves the live copy unchanged.
16
+ 5. Only the quarantined artifact identified by quarantine_dir_name is copied;
17
+ no arbitrary file can be activated.
18
+
19
+ CRIT-2 note: skill_name and quarantine_dir_name are ALWAYS different strings:
20
+ - skill_name: the original skill identifier (e.g. "brainstorming")
21
+ - quarantine_dir_name: the sanitized quarantine subdir (e.g. "brainstorming-vabc12")
22
+ Both are required; neither defaults from the other.
23
+
24
+ Part of Qualixar | Author: Varun Pratap Bhardwaj
25
+ """
26
+
27
+ from __future__ import annotations
28
+
29
+ import hashlib
30
+ import logging
31
+ import os
32
+ import re
33
+ import shutil
34
+ from datetime import datetime, timezone
35
+ from pathlib import Path
36
+
37
+ from superlocalmemory.infra.data_root import canonical_data_root
38
+
39
+ logger = logging.getLogger(__name__)
40
+
41
+ LIVE_SKILLS_ROOT: Path = Path.home() / ".claude" / "skills"
42
+ BACKUP_ROOT: Path = canonical_data_root() / "skill_backups"
43
+ QUARANTINE_ROOT: Path = canonical_data_root() / "quarantine" / "skills"
44
+
45
+ _SAFE_NAME_RE = re.compile(r"[^a-zA-Z0-9_-]")
46
+
47
+
48
+ class SkillActivationError(Exception):
49
+ """Raised when activation fails after writing the backup.
50
+
51
+ The caller must invoke rollback() after receiving this error to restore
52
+ the previous live artifact.
53
+ """
54
+
55
+
56
+ class SkillActivator:
57
+ """Activates a quarantined evolved skill into the live skill directory."""
58
+
59
+ def __init__(
60
+ self,
61
+ *,
62
+ live_root: Path | None = None,
63
+ backup_root: Path | None = None,
64
+ quarantine_root: Path | None = None,
65
+ ) -> None:
66
+ self._live_root = live_root or LIVE_SKILLS_ROOT
67
+ self._backup_root = backup_root or BACKUP_ROOT
68
+ self._quarantine_root = quarantine_root or QUARANTINE_ROOT
69
+
70
+ # ------------------------------------------------------------------
71
+ # Path helpers
72
+ # ------------------------------------------------------------------
73
+
74
+ def _safe_skill_dir(self, skill_name: str, base: Path) -> Path:
75
+ """Return base / sanitized_name, raising ValueError on traversal.
76
+
77
+ Two-layer check (fail-fast + belt-and-suspenders):
78
+ 1. Reject the raw skill_name if it contains '..' or is absolute.
79
+ 2. After sanitization, resolve the target path and verify it remains
80
+ inside base. This catches any edge case the regex misses.
81
+ """
82
+ # Fail-fast: detect traversal in raw name before sanitization.
83
+ raw = Path(skill_name)
84
+ if raw.is_absolute() or ".." in raw.parts:
85
+ raise ValueError(
86
+ f"skill_name {skill_name!r} contains path traversal sequences"
87
+ )
88
+ safe = _SAFE_NAME_RE.sub("-", skill_name).lower()[:50] or "skill"
89
+ target = (base / safe).resolve()
90
+ base_resolved = base.resolve()
91
+ # Audit P2-5: is_relative_to is not prefix-fragile (a sibling like
92
+ # ".../skills-evil" cannot masquerade as being under ".../skills").
93
+ if not target.is_relative_to(base_resolved):
94
+ raise ValueError(
95
+ f"skill_name {skill_name!r} escapes sandbox: {target}"
96
+ )
97
+ return target
98
+
99
+ def _quarantine_path(self, quarantine_dir_name: str) -> Path:
100
+ """Return the SKILL.md path inside quarantine for a given dir name.
101
+
102
+ quarantine_dir_name is the sanitized directory name (CRIT-2), e.g.
103
+ 'brainstorming-vabc12'. It is NOT the skill_name.
104
+
105
+ Hardened (audit P0-1 / P1-4): reject absolute paths and '..' traversal,
106
+ then resolve() and require containment inside the quarantine root. The
107
+ resolve() step also defeats a symlinked quarantine dir that would
108
+ otherwise redirect the read outside the sandbox and inject arbitrary
109
+ content into a live skill.
110
+ """
111
+ raw = Path(quarantine_dir_name)
112
+ if raw.is_absolute() or ".." in raw.parts:
113
+ raise ValueError(
114
+ f"quarantine_dir_name {quarantine_dir_name!r} contains path traversal"
115
+ )
116
+ root = self._quarantine_root.resolve()
117
+ q_dir = (self._quarantine_root / quarantine_dir_name).resolve()
118
+ if not q_dir.is_relative_to(root):
119
+ raise ValueError(
120
+ f"quarantine_dir_name {quarantine_dir_name!r} escapes sandbox: {q_dir}"
121
+ )
122
+ q_path = (q_dir / "SKILL.md").resolve()
123
+ if not q_path.is_relative_to(root):
124
+ raise ValueError(
125
+ f"quarantine artifact escapes sandbox: {q_path}"
126
+ )
127
+ return q_path
128
+
129
+ def _live_path(self, skill_name: str) -> Path:
130
+ return self._safe_skill_dir(skill_name, self._live_root) / "SKILL.md"
131
+
132
+ def _backup_path(self, skill_name: str) -> Path:
133
+ return self._safe_skill_dir(skill_name, self._backup_root) / "SKILL.md.bak"
134
+
135
+ # ------------------------------------------------------------------
136
+ # Public API
137
+ # ------------------------------------------------------------------
138
+
139
+ def activate(
140
+ self,
141
+ skill_name: str,
142
+ quarantine_dir_name: str,
143
+ *,
144
+ actor_id: str = "",
145
+ ) -> dict:
146
+ """Atomically activate a quarantined skill.
147
+
148
+ Steps:
149
+ 1. Resolve quarantine_path using quarantine_dir_name and verify it exists.
150
+ 2. Read quarantine content and compute SHA-256.
151
+ 3. Write backup of existing live SKILL.md (if any) to backup_root.
152
+ 4. Write quarantine content to live path via .tmp → os.replace.
153
+ 5. Return activation metadata.
154
+
155
+ Args:
156
+ skill_name: The original skill identifier (CRIT-2), e.g. "brainstorming".
157
+ quarantine_dir_name: The sanitized quarantine subdir, e.g. "brainstorming-vabc12".
158
+ actor_id: Who triggered activation (e.g. "auto" or a user id).
159
+
160
+ Raises:
161
+ FileNotFoundError: Quarantine artifact does not exist.
162
+ ValueError: Path traversal detected in skill_name.
163
+ SkillActivationError: Any OS error after the backup was already written.
164
+ """
165
+ q_path = self._quarantine_path(quarantine_dir_name)
166
+ live_path = self._live_path(skill_name) # raises ValueError on traversal
167
+ backup_path = self._backup_path(skill_name)
168
+
169
+ if not q_path.exists():
170
+ raise FileNotFoundError(
171
+ f"Quarantine artifact not found: {q_path}"
172
+ )
173
+
174
+ content = q_path.read_bytes()
175
+ content_hash = hashlib.sha256(content).hexdigest()
176
+ ts = datetime.now(timezone.utc).isoformat()
177
+
178
+ # Step 3: backup existing live artifact BEFORE any overwrite
179
+ backup_path.parent.mkdir(parents=True, exist_ok=True)
180
+ backup_written = False
181
+ if live_path.exists():
182
+ shutil.copy2(live_path, backup_path)
183
+ backup_written = True
184
+ logger.info(
185
+ "skill_activator: backed up %s → %s",
186
+ live_path, backup_path,
187
+ )
188
+
189
+ # Step 4: atomic write via .tmp → rename (same directory = same fs)
190
+ live_path.parent.mkdir(parents=True, exist_ok=True)
191
+ tmp_path = live_path.with_suffix(".tmp")
192
+ try:
193
+ tmp_path.write_bytes(content)
194
+ os.replace(tmp_path, live_path)
195
+ except OSError as exc:
196
+ try:
197
+ tmp_path.unlink(missing_ok=True)
198
+ except OSError:
199
+ pass
200
+ raise SkillActivationError(
201
+ f"atomic write failed for {live_path}: {exc}"
202
+ ) from exc
203
+
204
+ logger.info(
205
+ "skill_activator: activated %s @ %s (sha256=%s, actor=%s)",
206
+ skill_name, live_path, content_hash[:12], actor_id,
207
+ )
208
+ return {
209
+ "skill_name": skill_name,
210
+ "live_path": str(live_path),
211
+ "backup_path": str(backup_path) if backup_written else None,
212
+ "content_hash": content_hash,
213
+ "activated_at": ts,
214
+ "actor_id": actor_id,
215
+ }
216
+
217
+ def rollback(self, skill_name: str) -> dict:
218
+ """Restore the live SKILL.md from the backup written by activate().
219
+
220
+ If no backup exists, removes the live file (the skill was new).
221
+ Never raises on missing backup — logs a warning instead.
222
+
223
+ Args:
224
+ skill_name: The original skill identifier, same value used in activate().
225
+ """
226
+ live_path = self._live_path(skill_name)
227
+ backup_path = self._backup_path(skill_name)
228
+ ts = datetime.now(timezone.utc).isoformat()
229
+
230
+ if backup_path.exists():
231
+ # Audit P2-4: restore atomically (tmp → os.replace) so a crash
232
+ # mid-rollback cannot leave a torn live SKILL.md.
233
+ live_path.parent.mkdir(parents=True, exist_ok=True)
234
+ tmp_path = live_path.with_suffix(".tmp")
235
+ tmp_path.write_bytes(backup_path.read_bytes())
236
+ os.replace(tmp_path, live_path)
237
+ logger.info(
238
+ "skill_activator: rolled back %s ← %s",
239
+ live_path, backup_path,
240
+ )
241
+ return {
242
+ "skill_name": skill_name,
243
+ "rolled_back": True,
244
+ "restored_from": str(backup_path),
245
+ "rolled_back_at": ts,
246
+ }
247
+ elif live_path.exists():
248
+ live_path.unlink()
249
+ logger.warning(
250
+ "skill_activator: no backup for %s; removed live file",
251
+ skill_name,
252
+ )
253
+ return {
254
+ "skill_name": skill_name,
255
+ "rolled_back": True,
256
+ "restored_from": None,
257
+ "note": "no backup; live file removed",
258
+ "rolled_back_at": ts,
259
+ }
260
+ else:
261
+ logger.warning(
262
+ "skill_activator: rollback called but no live file for %s",
263
+ skill_name,
264
+ )
265
+ return {
266
+ "skill_name": skill_name,
267
+ "rolled_back": False,
268
+ "note": "nothing to rollback",
269
+ "rolled_back_at": ts,
270
+ }