skillpool 4.3.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- skillpool/__init__.py +74 -0
- skillpool/__main__.py +6 -0
- skillpool/adapters/__init__.py +8 -0
- skillpool/adapters/base.py +41 -0
- skillpool/adapters/claude_adapter.py +36 -0
- skillpool/adapters/codex_adapter.py +92 -0
- skillpool/adapters/hermes_adapter.py +38 -0
- skillpool/audit/__init__.py +651 -0
- skillpool/bridge/__init__.py +16 -0
- skillpool/bridge/freeze_detector.py +134 -0
- skillpool/bridge/maintenance.py +119 -0
- skillpool/bridge/wal_manager.py +136 -0
- skillpool/clawmem_client.py +176 -0
- skillpool/cli.py +700 -0
- skillpool/combiner/__init__.py +31 -0
- skillpool/combiner/lifecycle.py +453 -0
- skillpool/combiner/models.py +99 -0
- skillpool/config.py +34 -0
- skillpool/cost/__init__.py +111 -0
- skillpool/cost/audit_hash.py +51 -0
- skillpool/cost/budget_tracker.py +66 -0
- skillpool/cost/dashboard.py +189 -0
- skillpool/cost/models.py +129 -0
- skillpool/cost/token_governor.py +264 -0
- skillpool/cost/trace_ceiling.py +38 -0
- skillpool/csdf.py +126 -0
- skillpool/evolver/__init__.py +978 -0
- skillpool/gain/__init__.py +285 -0
- skillpool/gate.py +282 -0
- skillpool/gate_policy/__init__.py +31 -0
- skillpool/gate_policy/incremental.py +157 -0
- skillpool/gate_policy/parser.py +258 -0
- skillpool/gate_policy/state_machine.py +432 -0
- skillpool/graph/__init__.py +14 -0
- skillpool/graph/ppr.py +279 -0
- skillpool/health/__init__.py +73 -0
- skillpool/health/check.py +85 -0
- skillpool/health/degradation.py +90 -0
- skillpool/health/models.py +43 -0
- skillpool/hooks/__init__.py +4 -0
- skillpool/hooks/security_scanner.py +288 -0
- skillpool/lifecycle.py +150 -0
- skillpool/materializer/__init__.py +124 -0
- skillpool/materializer/budget_cropper.py +178 -0
- skillpool/materializer/csdf_loader.py +114 -0
- skillpool/materializer/lazy_loader.py +265 -0
- skillpool/materializer/lifecycle_filter.py +93 -0
- skillpool/materializer/mapper.py +178 -0
- skillpool/materializer/models.py +66 -0
- skillpool/mcp_server.py +2005 -0
- skillpool/monitor/__init__.py +576 -0
- skillpool/monitor/bug_collector.py +392 -0
- skillpool/monitor/defect_classifier.py +218 -0
- skillpool/monitor/self_healing.py +530 -0
- skillpool/monitor/telemetry_bridge.py +197 -0
- skillpool/paradigm/__init__.py +312 -0
- skillpool/paradigm/override.py +285 -0
- skillpool/profile.py +94 -0
- skillpool/quality.py +254 -0
- skillpool/registry/__init__.py +509 -0
- skillpool/registry/models.py +98 -0
- skillpool/resolver/__init__.py +320 -0
- skillpool/resolver/cache.py +103 -0
- skillpool/resolver/circuit_breaker.py +103 -0
- skillpool/resolver/conflict_detector.py +111 -0
- skillpool/resolver/health_filter.py +38 -0
- skillpool/resolver/models.py +154 -0
- skillpool/resolver/rate_limiter.py +48 -0
- skillpool/resolver/skill_graph.py +183 -0
- skillpool/review/__init__.py +242 -0
- skillpool/review/async_queue.py +96 -0
- skillpool/review/checkpoint_runner.py +345 -0
- skillpool/review/models.py +164 -0
- skillpool/review/suspect_marker.py +39 -0
- skillpool/review/veto_evaluator.py +94 -0
- skillpool/router/__init__.py +481 -0
- skillpool/schemas.py +119 -0
- skillpool/synergy/__init__.py +240 -0
- skillpool/synergy/detector.py +5 -0
- skillpool/telemetry.py +126 -0
- skillpool/utils/__init__.py +21 -0
- skillpool/utils/changelog.py +218 -0
- skillpool/utils/logger.py +273 -0
- skillpool/utils/runtime_audit.py +163 -0
- skillpool/utils/time_utils.py +13 -0
- skillpool-4.3.0.dist-info/METADATA +21 -0
- skillpool-4.3.0.dist-info/RECORD +90 -0
- skillpool-4.3.0.dist-info/WHEEL +5 -0
- skillpool-4.3.0.dist-info/entry_points.txt +3 -0
- skillpool-4.3.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,240 @@
|
|
|
1
|
+
"""SynergyDetector — Skill combination gain detection and synergy edge management.
|
|
2
|
+
|
|
3
|
+
Discovers and manages skill combination synergies:
|
|
4
|
+
1. Loads expert-annotated synergies from CSDF definitions
|
|
5
|
+
2. Creates/updates DagEdge(type=enhances) edges in the skill graph
|
|
6
|
+
3. Discovers new combinations from historical execution data (future: Thompson Sampling)
|
|
7
|
+
|
|
8
|
+
Part of SkillPool — independent infrastructure, shared by all agents.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import logging
|
|
14
|
+
from pathlib import Path
|
|
15
|
+
|
|
16
|
+
from pydantic import BaseModel, Field
|
|
17
|
+
|
|
18
|
+
from skillpool.config import get_data_dir
|
|
19
|
+
from skillpool.materializer.models import SynergyEntry
|
|
20
|
+
from skillpool.resolver.models import DagEdge, DagEdgeType
|
|
21
|
+
|
|
22
|
+
logger = logging.getLogger(__name__)
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class SynergyEdge(BaseModel):
|
|
26
|
+
"""A synergy relationship between two skills with gain data."""
|
|
27
|
+
|
|
28
|
+
source: str = Field(description="Primary skill ID")
|
|
29
|
+
target: str = Field(description="Enhancing skill ID")
|
|
30
|
+
gain: str = Field(default="", description="Expected gain (e.g. '+15%')")
|
|
31
|
+
reason: str = Field(default="", description="Why this combination helps")
|
|
32
|
+
weight: float = Field(default=0.5, ge=0.0, le=1.0, description="Edge weight")
|
|
33
|
+
evidence: str = Field(default="expert", description="Evidence source: expert/observed/sampled")
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
class SynergyDetectionResult(BaseModel):
|
|
37
|
+
"""Result of synergy detection run."""
|
|
38
|
+
|
|
39
|
+
edges_created: int = 0
|
|
40
|
+
edges_updated: int = 0
|
|
41
|
+
edges_total: int = 0
|
|
42
|
+
new_discoveries: list[SynergyEdge] = Field(default_factory=list)
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
class SynergyDetector:
|
|
46
|
+
"""Detects and manages skill combination synergies.
|
|
47
|
+
|
|
48
|
+
Usage:
|
|
49
|
+
detector = SynergyDetector(skills_dir=Path("~/.skillpool/skills"))
|
|
50
|
+
result = detector.sync_expert_synergies()
|
|
51
|
+
print(f"Created {result.edges_created} synergy edges")
|
|
52
|
+
"""
|
|
53
|
+
|
|
54
|
+
def __init__(self, skills_dir: Path | None = None):
|
|
55
|
+
self.skills_dir = skills_dir or get_data_dir() / "skills"
|
|
56
|
+
self._synergy_edges: list[SynergyEdge] = []
|
|
57
|
+
|
|
58
|
+
def load_expert_synergies(self) -> list[SynergyEdge]:
|
|
59
|
+
"""Load expert-annotated synergies from CSDF YAML files.
|
|
60
|
+
|
|
61
|
+
Reads the `synergies` field from each skill's CSDF definition
|
|
62
|
+
and converts them to SynergyEdge objects.
|
|
63
|
+
|
|
64
|
+
Data source priority: Registry (if available) → local filesystem.
|
|
65
|
+
All Agents sharing the same MCP server get consistent data.
|
|
66
|
+
"""
|
|
67
|
+
edges: list[SynergyEdge] = []
|
|
68
|
+
|
|
69
|
+
# Primary: try Registry (shared state, consistent across Agents)
|
|
70
|
+
registry_edges = self._load_from_registry()
|
|
71
|
+
if registry_edges is not None:
|
|
72
|
+
self._synergy_edges = registry_edges
|
|
73
|
+
return registry_edges
|
|
74
|
+
|
|
75
|
+
# Fallback: local filesystem (same data, different access path)
|
|
76
|
+
if not self.skills_dir.exists():
|
|
77
|
+
return edges
|
|
78
|
+
|
|
79
|
+
for skill_dir in self.skills_dir.iterdir():
|
|
80
|
+
if not skill_dir.is_dir():
|
|
81
|
+
continue
|
|
82
|
+
|
|
83
|
+
# Look for CSDF YAML files
|
|
84
|
+
for yaml_file in skill_dir.glob("*.yaml"):
|
|
85
|
+
try:
|
|
86
|
+
import yaml
|
|
87
|
+
|
|
88
|
+
data = yaml.safe_load(yaml_file.read_text())
|
|
89
|
+
if not data or not isinstance(data, dict):
|
|
90
|
+
continue
|
|
91
|
+
|
|
92
|
+
skill_id = data.get("id", skill_dir.name)
|
|
93
|
+
synergies = data.get("synergies", [])
|
|
94
|
+
|
|
95
|
+
for syn in synergies:
|
|
96
|
+
if isinstance(syn, dict) and "skill_id" in syn:
|
|
97
|
+
entry = SynergyEntry(**syn)
|
|
98
|
+
# Parse gain percentage to weight
|
|
99
|
+
weight = self._parse_gain_to_weight(entry.gain)
|
|
100
|
+
edge = SynergyEdge(
|
|
101
|
+
source=skill_id,
|
|
102
|
+
target=entry.skill_id,
|
|
103
|
+
gain=entry.gain,
|
|
104
|
+
reason=entry.reason,
|
|
105
|
+
weight=weight,
|
|
106
|
+
evidence="expert",
|
|
107
|
+
)
|
|
108
|
+
edges.append(edge)
|
|
109
|
+
except Exception as e:
|
|
110
|
+
logger.warning("Failed to load synergy entry for %s: %s", skill_dir.name, e)
|
|
111
|
+
continue
|
|
112
|
+
|
|
113
|
+
self._synergy_edges = edges
|
|
114
|
+
return edges
|
|
115
|
+
|
|
116
|
+
def to_dag_edges(self) -> list[DagEdge]:
|
|
117
|
+
"""Convert synergy edges to DagEdge(type=enhances) for the skill graph."""
|
|
118
|
+
return [
|
|
119
|
+
DagEdge(
|
|
120
|
+
source=e.source,
|
|
121
|
+
target=e.target,
|
|
122
|
+
weight=e.weight,
|
|
123
|
+
type=DagEdgeType.ENHANCES,
|
|
124
|
+
)
|
|
125
|
+
for e in self._synergy_edges
|
|
126
|
+
]
|
|
127
|
+
|
|
128
|
+
def sync_expert_synergies(self) -> SynergyDetectionResult:
|
|
129
|
+
"""Full sync: load expert synergies and return detection result.
|
|
130
|
+
|
|
131
|
+
This is the main entry point for cold-start synergy setup.
|
|
132
|
+
"""
|
|
133
|
+
edges = self.load_expert_synergies()
|
|
134
|
+
_dag_edges = self.to_dag_edges()
|
|
135
|
+
|
|
136
|
+
return SynergyDetectionResult(
|
|
137
|
+
edges_created=len(edges),
|
|
138
|
+
edges_updated=0,
|
|
139
|
+
edges_total=len(edges),
|
|
140
|
+
new_discoveries=[],
|
|
141
|
+
)
|
|
142
|
+
|
|
143
|
+
def get_synergies_for(self, skill_id: str) -> list[SynergyEdge]:
|
|
144
|
+
"""Get all synergy edges where the given skill is the source."""
|
|
145
|
+
return [e for e in self._synergy_edges if e.source == skill_id]
|
|
146
|
+
|
|
147
|
+
def get_enhancers_of(self, skill_id: str) -> list[SynergyEdge]:
|
|
148
|
+
"""Get all skills that enhance the given skill (where target == skill_id)."""
|
|
149
|
+
return [e for e in self._synergy_edges if e.target == skill_id]
|
|
150
|
+
|
|
151
|
+
def load_combination_synergies(self) -> list[SynergyEdge]:
|
|
152
|
+
"""Load synergy edges from PROMOTED and VALIDATING combinations.
|
|
153
|
+
|
|
154
|
+
Only includes PROMOTED (full weight) and VALIDATING (reduced weight).
|
|
155
|
+
DEPRECATED combinations get 0.5x weight. RETIRED are excluded.
|
|
156
|
+
"""
|
|
157
|
+
try:
|
|
158
|
+
from skillpool.combiner import CombinationLifecycleManager
|
|
159
|
+
from skillpool.combiner.models import CombinationLifecycleState
|
|
160
|
+
|
|
161
|
+
mgr = CombinationLifecycleManager()
|
|
162
|
+
all_combos = list(mgr._combinations.values())
|
|
163
|
+
|
|
164
|
+
combo_edges: list[SynergyEdge] = []
|
|
165
|
+
for combo in all_combos:
|
|
166
|
+
# Skip RETIRED combinations entirely
|
|
167
|
+
if combo.state == CombinationLifecycleState.RETIRED:
|
|
168
|
+
continue
|
|
169
|
+
|
|
170
|
+
weight = combo.current_weight()
|
|
171
|
+
|
|
172
|
+
# Adjust weight by lifecycle state
|
|
173
|
+
if combo.state == CombinationLifecycleState.PROMOTED:
|
|
174
|
+
pass # Full weight
|
|
175
|
+
elif combo.state == CombinationLifecycleState.VALIDATING:
|
|
176
|
+
weight *= 0.7 # Reduced confidence
|
|
177
|
+
elif combo.state == CombinationLifecycleState.DEPRECATED:
|
|
178
|
+
weight *= 0.5 # Significantly reduced
|
|
179
|
+
elif combo.state == CombinationLifecycleState.DISCOVERED:
|
|
180
|
+
weight *= 0.3 # Low weight, untested
|
|
181
|
+
elif combo.state == CombinationLifecycleState.REJECTED:
|
|
182
|
+
continue # Skip rejected
|
|
183
|
+
|
|
184
|
+
if weight < 0.05:
|
|
185
|
+
continue
|
|
186
|
+
|
|
187
|
+
for enhancer in combo.enhancers:
|
|
188
|
+
edge = SynergyEdge(
|
|
189
|
+
source=combo.primary,
|
|
190
|
+
target=enhancer,
|
|
191
|
+
gain=f"+{combo.gain_avg:.1f}%",
|
|
192
|
+
reason=f"{CombinationLifecycleState(combo.state).name} combination "
|
|
193
|
+
f"(source={combo.source}, executions={combo.execution_count})",
|
|
194
|
+
weight=round(weight, 3),
|
|
195
|
+
evidence="observed" if combo.state == CombinationLifecycleState.PROMOTED else "exploratory",
|
|
196
|
+
)
|
|
197
|
+
combo_edges.append(edge)
|
|
198
|
+
|
|
199
|
+
# Merge with existing synergy edges (dedup by source+target)
|
|
200
|
+
existing = {(e.source, e.target) for e in self._synergy_edges}
|
|
201
|
+
new_edges = [e for e in combo_edges if (e.source, e.target) not in existing]
|
|
202
|
+
self._synergy_edges.extend(new_edges)
|
|
203
|
+
return new_edges
|
|
204
|
+
|
|
205
|
+
except (ImportError, Exception):
|
|
206
|
+
return []
|
|
207
|
+
|
|
208
|
+
@staticmethod
|
|
209
|
+
def _parse_gain_to_weight(gain: str) -> float:
|
|
210
|
+
"""Parse gain string like '+15%' to a weight between 0 and 1.
|
|
211
|
+
|
|
212
|
+
Maps: +5% → 0.3, +10% → 0.5, +15% → 0.65, +20% → 0.8, +25%+ → 0.9
|
|
213
|
+
"""
|
|
214
|
+
try:
|
|
215
|
+
pct = float(gain.strip().replace("+", "").replace("%", ""))
|
|
216
|
+
# Sigmoid-like mapping: higher gains → higher weights
|
|
217
|
+
# Clamped to [0.1, 0.95]
|
|
218
|
+
weight = min(0.95, max(0.1, pct / 30.0))
|
|
219
|
+
return round(weight, 2)
|
|
220
|
+
except (ValueError, AttributeError):
|
|
221
|
+
return 0.5 # Default weight when gain is not parseable
|
|
222
|
+
|
|
223
|
+
def _load_from_registry(self) -> list[SynergyEdge] | None:
|
|
224
|
+
"""Try loading synergies from the Registry (shared state).
|
|
225
|
+
|
|
226
|
+
Returns None if Registry not available or has no synergy data,
|
|
227
|
+
triggering filesystem fallback. Returns list if data found.
|
|
228
|
+
"""
|
|
229
|
+
try:
|
|
230
|
+
from skillpool.registry import Registry
|
|
231
|
+
|
|
232
|
+
_registry = Registry()
|
|
233
|
+
# Registry stores skills with metadata; check for synergy annotations
|
|
234
|
+
# Currently, Registry doesn't store CSDF synergies directly,
|
|
235
|
+
# so this returns None to trigger filesystem read.
|
|
236
|
+
# Future: when Registry supports synergy metadata, this will
|
|
237
|
+
# be the primary path for remote Agents.
|
|
238
|
+
return None
|
|
239
|
+
except (ImportError, Exception):
|
|
240
|
+
return None
|
skillpool/telemetry.py
ADDED
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
"""
|
|
2
|
+
TelemetryBridge — 反向反馈通道,将运行时信号传回 SkillPool。
|
|
3
|
+
|
|
4
|
+
3 个通道:
|
|
5
|
+
1. hook — Claude Code / Codex hook 事件(PreToolUse, PostToolUse, Stop 等)
|
|
6
|
+
2. mcp — MCP tool call(由 mcp_server.py 暴露 telemetry_report 工具)
|
|
7
|
+
3. log_file — 文件轮询(兼容无 hook/MCP 的环境)
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import json
|
|
13
|
+
import logging
|
|
14
|
+
from datetime import datetime, timezone
|
|
15
|
+
from enum import StrEnum
|
|
16
|
+
from pathlib import Path
|
|
17
|
+
from typing import Optional, Callable
|
|
18
|
+
|
|
19
|
+
from skillpool.config import get_data_dir
|
|
20
|
+
from pydantic import BaseModel, Field
|
|
21
|
+
|
|
22
|
+
logger = logging.getLogger(__name__)
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class TelemetryChannel(StrEnum):
|
|
26
|
+
HOOK = "hook"
|
|
27
|
+
MCP = "mcp"
|
|
28
|
+
LOG_FILE = "log_file"
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
class TelemetryEvent(BaseModel):
|
|
32
|
+
"""单条遥测事件。"""
|
|
33
|
+
|
|
34
|
+
event_type: str
|
|
35
|
+
skill_id: str
|
|
36
|
+
channel: TelemetryChannel = TelemetryChannel.LOG_FILE
|
|
37
|
+
payload: dict = {}
|
|
38
|
+
timestamp: str = Field(default_factory=lambda: datetime.now(timezone.utc).isoformat())
|
|
39
|
+
trace_id: str = ""
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
class TelemetryBridge:
|
|
43
|
+
"""反向反馈通道 — 从运行时向 SkillPool 传递遥测信号。
|
|
44
|
+
|
|
45
|
+
Usage:
|
|
46
|
+
bridge = TelemetryBridge(log_dir=Path("~/.skillpool/telemetry"))
|
|
47
|
+
bridge.emit("skill_used", skill_id="S05a", channel="hook")
|
|
48
|
+
bridge.emit("skill_error", skill_id="S10", channel="mcp", payload={"error": "timeout"})
|
|
49
|
+
"""
|
|
50
|
+
|
|
51
|
+
def __init__(self, log_dir: Optional[Path] = None):
|
|
52
|
+
self.log_dir = log_dir or get_data_dir() / "telemetry"
|
|
53
|
+
self.log_dir.mkdir(parents=True, exist_ok=True)
|
|
54
|
+
self._hooks: list[Callable] = []
|
|
55
|
+
|
|
56
|
+
def emit(
|
|
57
|
+
self,
|
|
58
|
+
event_type: str,
|
|
59
|
+
skill_id: str,
|
|
60
|
+
channel: TelemetryChannel | str = TelemetryChannel.LOG_FILE,
|
|
61
|
+
payload: Optional[dict] = None,
|
|
62
|
+
trace_id: str = "",
|
|
63
|
+
) -> TelemetryEvent:
|
|
64
|
+
"""发射一条遥测事件。同时写入 log_file 并调用已注册的 hook。"""
|
|
65
|
+
ch = TelemetryChannel(channel) if isinstance(channel, str) else channel
|
|
66
|
+
event = TelemetryEvent(
|
|
67
|
+
event_type=event_type,
|
|
68
|
+
skill_id=skill_id,
|
|
69
|
+
channel=ch,
|
|
70
|
+
payload=payload or {},
|
|
71
|
+
trace_id=trace_id,
|
|
72
|
+
)
|
|
73
|
+
|
|
74
|
+
# 始终写入 log_file
|
|
75
|
+
self._write_to_log(event)
|
|
76
|
+
|
|
77
|
+
# 调用注册的 hook
|
|
78
|
+
for hook_fn in self._hooks:
|
|
79
|
+
try:
|
|
80
|
+
hook_fn(event)
|
|
81
|
+
except Exception as e:
|
|
82
|
+
logger.warning("Telemetry hook callback failed: %s", e)
|
|
83
|
+
|
|
84
|
+
return event
|
|
85
|
+
|
|
86
|
+
def register_hook(self, fn: Callable) -> None:
|
|
87
|
+
"""注册一个 hook 回调,每次 emit 时调用。"""
|
|
88
|
+
self._hooks.append(fn)
|
|
89
|
+
|
|
90
|
+
def read_events(
|
|
91
|
+
self,
|
|
92
|
+
skill_id: Optional[str] = None,
|
|
93
|
+
event_type: Optional[str] = None,
|
|
94
|
+
since: Optional[float] = None,
|
|
95
|
+
) -> list[TelemetryEvent]:
|
|
96
|
+
"""从 log_file 读取历史事件。"""
|
|
97
|
+
events = []
|
|
98
|
+
log_file = self.log_dir / f"telemetry-{datetime.now().strftime('%Y%m%d')}.jsonl"
|
|
99
|
+
if not log_file.exists():
|
|
100
|
+
return events
|
|
101
|
+
|
|
102
|
+
for line in log_file.read_text().strip().split("\n"):
|
|
103
|
+
if not line:
|
|
104
|
+
continue
|
|
105
|
+
try:
|
|
106
|
+
data = json.loads(line)
|
|
107
|
+
event = TelemetryEvent(**data)
|
|
108
|
+
if skill_id and event.skill_id != skill_id:
|
|
109
|
+
continue
|
|
110
|
+
if event_type and event.event_type != event_type:
|
|
111
|
+
continue
|
|
112
|
+
if since:
|
|
113
|
+
evt_ts = datetime.fromisoformat(event.timestamp).timestamp()
|
|
114
|
+
if evt_ts < since:
|
|
115
|
+
continue
|
|
116
|
+
events.append(event)
|
|
117
|
+
except (json.JSONDecodeError, Exception):
|
|
118
|
+
continue
|
|
119
|
+
|
|
120
|
+
return events
|
|
121
|
+
|
|
122
|
+
def _write_to_log(self, event: TelemetryEvent) -> None:
|
|
123
|
+
"""追加写入当日 JSONL 文件。"""
|
|
124
|
+
log_file = self.log_dir / f"telemetry-{datetime.now().strftime('%Y%m%d')}.jsonl"
|
|
125
|
+
with open(log_file, "a") as f:
|
|
126
|
+
f.write(event.model_dump_json() + "\n")
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
"""SkillPool utility modules."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
__all__ = [
|
|
6
|
+
"ConsoleRenderer",
|
|
7
|
+
"ContextVarsBinding",
|
|
8
|
+
"JSONRenderer",
|
|
9
|
+
"RuntimeAuditHook",
|
|
10
|
+
"SkillPoolLogger",
|
|
11
|
+
"utc_now",
|
|
12
|
+
]
|
|
13
|
+
|
|
14
|
+
from skillpool.utils.time_utils import utc_now
|
|
15
|
+
from skillpool.utils.runtime_audit import RuntimeAuditHook
|
|
16
|
+
from skillpool.utils.logger import (
|
|
17
|
+
ConsoleRenderer,
|
|
18
|
+
ContextVarsBinding,
|
|
19
|
+
JSONRenderer,
|
|
20
|
+
SkillPoolLogger,
|
|
21
|
+
)
|
|
@@ -0,0 +1,218 @@
|
|
|
1
|
+
"""Changelog utilities — auto-append entries to CHANGELOG.md.
|
|
2
|
+
|
|
3
|
+
Reads the project CHANGELOG.md, finds the current version section,
|
|
4
|
+
and appends structured entries under the appropriate category subsection.
|
|
5
|
+
|
|
6
|
+
Categories: Added, Fixed, Changed, Deprecated, Removed, Security
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
__all__ = ["append_changelog_entry"]
|
|
12
|
+
|
|
13
|
+
import re
|
|
14
|
+
from datetime import date
|
|
15
|
+
from pathlib import Path
|
|
16
|
+
|
|
17
|
+
# Default changelog path
|
|
18
|
+
_CHANGELOG_PATH = Path(__file__).resolve().parent.parent.parent.parent / "CHANGELOG.md"
|
|
19
|
+
|
|
20
|
+
# Valid categories (Keep a Changelog standard)
|
|
21
|
+
VALID_CATEGORIES = frozenset(
|
|
22
|
+
{
|
|
23
|
+
"Added",
|
|
24
|
+
"Fixed",
|
|
25
|
+
"Changed",
|
|
26
|
+
"Deprecated",
|
|
27
|
+
"Removed",
|
|
28
|
+
"Security",
|
|
29
|
+
}
|
|
30
|
+
)
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def _detect_current_version(content: str) -> str | None:
|
|
34
|
+
"""Detect the current (most recent) version from CHANGELOG content.
|
|
35
|
+
|
|
36
|
+
Looks for the first '## [X.Y.Z]' header.
|
|
37
|
+
|
|
38
|
+
Args:
|
|
39
|
+
content: Full CHANGELOG.md content.
|
|
40
|
+
|
|
41
|
+
Returns:
|
|
42
|
+
Version string (e.g., '4.2.0') or None if not found.
|
|
43
|
+
"""
|
|
44
|
+
match = re.search(r"^## \[(\d+\.\d+\.\d+)\]", content, re.MULTILINE)
|
|
45
|
+
return match.group(1) if match else None
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def _find_version_section_end(content: str, version: str) -> int | None:
|
|
49
|
+
"""Find the line index where the next version section starts.
|
|
50
|
+
|
|
51
|
+
Args:
|
|
52
|
+
content: Full CHANGELOG.md content split into lines.
|
|
53
|
+
version: Version string to find.
|
|
54
|
+
|
|
55
|
+
Returns:
|
|
56
|
+
Line index of the next version header, or len(lines) if last section.
|
|
57
|
+
"""
|
|
58
|
+
lines = content.split("\n")
|
|
59
|
+
found_current = False
|
|
60
|
+
for i, line in enumerate(lines):
|
|
61
|
+
if re.match(rf"^## \[{re.escape(version)}\]", line):
|
|
62
|
+
found_current = True
|
|
63
|
+
continue
|
|
64
|
+
if found_current and re.match(r"^## \[", line):
|
|
65
|
+
return i
|
|
66
|
+
return len(lines) if found_current else None
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def _find_or_create_category(
|
|
70
|
+
lines: list[str],
|
|
71
|
+
section_start: int,
|
|
72
|
+
section_end: int,
|
|
73
|
+
category: str,
|
|
74
|
+
) -> int:
|
|
75
|
+
"""Find the line index where a category subsection starts, or determine
|
|
76
|
+
where to insert it.
|
|
77
|
+
|
|
78
|
+
Args:
|
|
79
|
+
lines: CHANGELOG.md split into lines.
|
|
80
|
+
section_start: Line index of the version header.
|
|
81
|
+
section_end: Line index of the next version header (or len(lines)).
|
|
82
|
+
category: Category name (e.g., 'Fixed').
|
|
83
|
+
|
|
84
|
+
Returns:
|
|
85
|
+
Line index where entries for this category should be appended.
|
|
86
|
+
If the category subsection exists, returns the line after its last entry.
|
|
87
|
+
If not, returns the line where the subsection header should be inserted.
|
|
88
|
+
"""
|
|
89
|
+
category_header = f"### {category}"
|
|
90
|
+
|
|
91
|
+
# Search for existing category subsection within the version section
|
|
92
|
+
category_start = None
|
|
93
|
+
next_category_start = None
|
|
94
|
+
|
|
95
|
+
for i in range(section_start, section_end):
|
|
96
|
+
if lines[i].strip() == category_header:
|
|
97
|
+
category_start = i
|
|
98
|
+
break
|
|
99
|
+
|
|
100
|
+
if category_start is not None:
|
|
101
|
+
# Found the category — find where it ends (next ### or ## or end of section)
|
|
102
|
+
for i in range(category_start + 1, section_end):
|
|
103
|
+
if re.match(r"^### ", lines[i]) or re.match(r"^## ", lines[i]):
|
|
104
|
+
next_category_start = i
|
|
105
|
+
break
|
|
106
|
+
|
|
107
|
+
if next_category_start is not None:
|
|
108
|
+
# Find the last non-empty line before the next subsection
|
|
109
|
+
insert_at = next_category_start
|
|
110
|
+
for i in range(next_category_start - 1, category_start, -1):
|
|
111
|
+
if lines[i].strip():
|
|
112
|
+
insert_at = i + 1
|
|
113
|
+
break
|
|
114
|
+
return insert_at
|
|
115
|
+
else:
|
|
116
|
+
# Category is the last subsection — append at end of section
|
|
117
|
+
insert_at = section_end
|
|
118
|
+
for i in range(section_end - 1, category_start, -1):
|
|
119
|
+
if lines[i].strip():
|
|
120
|
+
insert_at = i + 1
|
|
121
|
+
break
|
|
122
|
+
return insert_at
|
|
123
|
+
else:
|
|
124
|
+
# Category doesn't exist — insert after the version header
|
|
125
|
+
# Find the first ### or non-empty line after the version header
|
|
126
|
+
insert_at = section_start + 1
|
|
127
|
+
for i in range(section_start + 1, section_end):
|
|
128
|
+
stripped = lines[i].strip()
|
|
129
|
+
if stripped.startswith("### ") or stripped.startswith("#### "):
|
|
130
|
+
insert_at = i
|
|
131
|
+
break
|
|
132
|
+
if stripped and not stripped.startswith("---"):
|
|
133
|
+
insert_at = i
|
|
134
|
+
break
|
|
135
|
+
|
|
136
|
+
return insert_at
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
def append_changelog_entry(
|
|
140
|
+
category: str,
|
|
141
|
+
description: str,
|
|
142
|
+
details: dict | None = None,
|
|
143
|
+
changelog_path: Path | str | None = None,
|
|
144
|
+
) -> None:
|
|
145
|
+
"""Append a structured entry to CHANGELOG.md under current version section.
|
|
146
|
+
|
|
147
|
+
Categories: Added, Fixed, Changed, Deprecated, Removed, Security
|
|
148
|
+
|
|
149
|
+
Writes a single line entry with timestamp and auto-detection of current version.
|
|
150
|
+
|
|
151
|
+
Args:
|
|
152
|
+
category: Changelog category (Added, Fixed, Changed, etc.).
|
|
153
|
+
description: Short description of the change.
|
|
154
|
+
details: Optional dict with additional context. If provided, rendered
|
|
155
|
+
as key=value pairs in the entry.
|
|
156
|
+
changelog_path: Override for CHANGELOG.md path (default: project root).
|
|
157
|
+
|
|
158
|
+
Raises:
|
|
159
|
+
ValueError: If category is not a valid Keep a Changelog category.
|
|
160
|
+
FileNotFoundError: If CHANGELOG.md does not exist.
|
|
161
|
+
"""
|
|
162
|
+
if category not in VALID_CATEGORIES:
|
|
163
|
+
raise ValueError(f"Invalid category '{category}'. Must be one of: {', '.join(sorted(VALID_CATEGORIES))}")
|
|
164
|
+
|
|
165
|
+
path = Path(changelog_path) if changelog_path else _CHANGELOG_PATH
|
|
166
|
+
|
|
167
|
+
if not path.exists():
|
|
168
|
+
raise FileNotFoundError(f"CHANGELOG.md not found at {path}")
|
|
169
|
+
|
|
170
|
+
content = path.read_text(encoding="utf-8")
|
|
171
|
+
version = _detect_current_version(content)
|
|
172
|
+
|
|
173
|
+
if version is None:
|
|
174
|
+
raise ValueError("No version section found in CHANGELOG.md")
|
|
175
|
+
|
|
176
|
+
lines = content.split("\n")
|
|
177
|
+
|
|
178
|
+
# Find the version section boundaries
|
|
179
|
+
section_start = None
|
|
180
|
+
for i, line in enumerate(lines):
|
|
181
|
+
if re.match(rf"^## \[{re.escape(version)}\]", line):
|
|
182
|
+
section_start = i
|
|
183
|
+
break
|
|
184
|
+
|
|
185
|
+
if section_start is None:
|
|
186
|
+
raise ValueError(f"Version section [{version}] not found in CHANGELOG.md")
|
|
187
|
+
|
|
188
|
+
section_end = _find_version_section_end(content, version)
|
|
189
|
+
if section_end is None:
|
|
190
|
+
section_end = len(lines)
|
|
191
|
+
|
|
192
|
+
# Find where to insert the entry
|
|
193
|
+
insert_at = _find_or_create_category(lines, section_start, section_end, category)
|
|
194
|
+
|
|
195
|
+
# Build the entry line
|
|
196
|
+
today = date.today().isoformat()
|
|
197
|
+
details_str = ""
|
|
198
|
+
if details:
|
|
199
|
+
details_str = " ".join(f"{k}={v}" for k, v in details.items())
|
|
200
|
+
details_str = f" ({details_str})"
|
|
201
|
+
|
|
202
|
+
entry_line = f"- **{description}**:{details_str} ({today})"
|
|
203
|
+
|
|
204
|
+
# Check if the category subsection header needs to be created
|
|
205
|
+
category_header = f"### {category}"
|
|
206
|
+
category_exists = any(lines[i].strip() == category_header for i in range(section_start, section_end))
|
|
207
|
+
|
|
208
|
+
if category_exists:
|
|
209
|
+
# Just insert the entry line
|
|
210
|
+
lines.insert(insert_at, entry_line)
|
|
211
|
+
else:
|
|
212
|
+
# Insert category header + blank line + entry
|
|
213
|
+
lines.insert(insert_at, entry_line)
|
|
214
|
+
lines.insert(insert_at, "")
|
|
215
|
+
lines.insert(insert_at, category_header)
|
|
216
|
+
|
|
217
|
+
# Write back
|
|
218
|
+
path.write_text("\n".join(lines), encoding="utf-8")
|