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,264 @@
|
|
|
1
|
+
"""TokenGovernor — per-agent daily token budget enforcement."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
from dataclasses import dataclass
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
from typing import Any, Callable
|
|
9
|
+
|
|
10
|
+
from skillpool.cost.models import AgentConfig, CostEstimate, ThrottleAction
|
|
11
|
+
from skillpool.utils.time_utils import utc_now
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
@dataclass
|
|
15
|
+
class _Usage:
|
|
16
|
+
"""Internal daily usage tracker for a single agent."""
|
|
17
|
+
|
|
18
|
+
tokens: int = 0
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
# Preset agent configurations from CLAUDE.md Section 9.2
|
|
22
|
+
PRESET_AGENT_CONFIGS: list[AgentConfig] = [
|
|
23
|
+
AgentConfig(agent_id="root_planner", agent_type="planner", daily_limit_tokens=0, is_critical=True),
|
|
24
|
+
AgentConfig(agent_id="sub_planner", agent_type="planner", daily_limit_tokens=0, is_critical=True),
|
|
25
|
+
AgentConfig(agent_id="level1_worker", agent_type="worker", daily_limit_tokens=0, is_critical=True),
|
|
26
|
+
AgentConfig(agent_id="level2_worker", agent_type="worker", daily_limit_tokens=0, is_critical=True),
|
|
27
|
+
AgentConfig(
|
|
28
|
+
agent_id="evolver_v4",
|
|
29
|
+
agent_type="evolver",
|
|
30
|
+
daily_limit_tokens=100_000,
|
|
31
|
+
throttle_at_pct=0.8,
|
|
32
|
+
throttle_to_pct=0.5,
|
|
33
|
+
),
|
|
34
|
+
AgentConfig(
|
|
35
|
+
agent_id="knowledge_refiner",
|
|
36
|
+
agent_type="refiner",
|
|
37
|
+
daily_limit_tokens=500_000,
|
|
38
|
+
throttle_at_pct=0.9,
|
|
39
|
+
throttle_to_pct=0.25,
|
|
40
|
+
),
|
|
41
|
+
AgentConfig(
|
|
42
|
+
agent_id="sandbox_validator",
|
|
43
|
+
agent_type="validator",
|
|
44
|
+
daily_limit_tokens=50_000,
|
|
45
|
+
throttle_at_pct=0.9,
|
|
46
|
+
throttle_to_pct=0.25,
|
|
47
|
+
),
|
|
48
|
+
AgentConfig(
|
|
49
|
+
agent_id="hermes_refiner",
|
|
50
|
+
agent_type="refiner",
|
|
51
|
+
daily_limit_tokens=300_000,
|
|
52
|
+
throttle_at_pct=0.8,
|
|
53
|
+
throttle_to_pct=0.5,
|
|
54
|
+
),
|
|
55
|
+
]
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
class TokenGovernor:
|
|
59
|
+
"""Enforce per-agent daily token budgets with throttle/reject logic.
|
|
60
|
+
|
|
61
|
+
Agents with daily_limit_tokens=0 are unlimited.
|
|
62
|
+
When usage crosses throttle_at_pct of the limit, requests are throttled
|
|
63
|
+
(allowed but reduced). When usage reaches 100%, requests are rejected.
|
|
64
|
+
"""
|
|
65
|
+
|
|
66
|
+
def __init__(self, configs: list[AgentConfig] | None = None) -> None:
|
|
67
|
+
self._configs: dict[str, AgentConfig] = {}
|
|
68
|
+
self._usage: dict[str, _Usage] = {}
|
|
69
|
+
|
|
70
|
+
for cfg in configs or PRESET_AGENT_CONFIGS:
|
|
71
|
+
self._configs[cfg.agent_id] = cfg
|
|
72
|
+
self._usage[cfg.agent_id] = _Usage()
|
|
73
|
+
|
|
74
|
+
def get_config(self, agent_id: str) -> AgentConfig | None:
|
|
75
|
+
"""Return the config for an agent, or None if unknown."""
|
|
76
|
+
return self._configs.get(agent_id)
|
|
77
|
+
|
|
78
|
+
def check(self, agent_id: str, requested_tokens: int) -> tuple[ThrottleAction, int]:
|
|
79
|
+
"""Check whether a token request is allowed.
|
|
80
|
+
|
|
81
|
+
Returns (action, allowed_tokens). For unlimited agents, always allows
|
|
82
|
+
the full request. For budgeted agents:
|
|
83
|
+
- below throttle threshold: ALLOW with full tokens
|
|
84
|
+
- between throttle and 100%: THROTTLE with reduced tokens
|
|
85
|
+
- at or above 100%: REJECT with 0 tokens
|
|
86
|
+
"""
|
|
87
|
+
cfg = self._configs.get(agent_id)
|
|
88
|
+
if cfg is None:
|
|
89
|
+
# Unknown agent: allow by default
|
|
90
|
+
return ThrottleAction.ALLOW, requested_tokens
|
|
91
|
+
|
|
92
|
+
if cfg.daily_limit_tokens == 0:
|
|
93
|
+
return ThrottleAction.ALLOW, requested_tokens
|
|
94
|
+
|
|
95
|
+
usage = self._usage.get(agent_id, _Usage())
|
|
96
|
+
pct_consumed = usage.tokens / cfg.daily_limit_tokens
|
|
97
|
+
|
|
98
|
+
if pct_consumed >= 1.0:
|
|
99
|
+
return ThrottleAction.REJECT, 0
|
|
100
|
+
|
|
101
|
+
if pct_consumed >= cfg.throttle_at_pct:
|
|
102
|
+
# Throttle: reduce to throttle_to_pct of the remaining budget
|
|
103
|
+
remaining = cfg.daily_limit_tokens - usage.tokens
|
|
104
|
+
allowed = int(remaining * cfg.throttle_to_pct)
|
|
105
|
+
allowed = max(0, min(allowed, requested_tokens))
|
|
106
|
+
return ThrottleAction.THROTTLE, allowed
|
|
107
|
+
|
|
108
|
+
return ThrottleAction.ALLOW, requested_tokens
|
|
109
|
+
|
|
110
|
+
def record_usage(self, agent_id: str, tokens_used: int) -> None:
|
|
111
|
+
"""Record actual token usage for an agent."""
|
|
112
|
+
if agent_id not in self._usage:
|
|
113
|
+
self._usage[agent_id] = _Usage()
|
|
114
|
+
self._usage[agent_id].tokens += tokens_used
|
|
115
|
+
|
|
116
|
+
def get_daily_usage(self, agent_id: str) -> int:
|
|
117
|
+
"""Return tokens consumed today by an agent."""
|
|
118
|
+
usage = self._usage.get(agent_id)
|
|
119
|
+
return usage.tokens if usage else 0
|
|
120
|
+
|
|
121
|
+
def is_throttled(self, agent_id: str) -> bool:
|
|
122
|
+
"""Check if an agent is currently in throttled state."""
|
|
123
|
+
cfg = self._configs.get(agent_id)
|
|
124
|
+
if cfg is None or cfg.daily_limit_tokens == 0:
|
|
125
|
+
return False
|
|
126
|
+
usage = self._usage.get(agent_id, _Usage())
|
|
127
|
+
return usage.tokens >= cfg.daily_limit_tokens * cfg.throttle_at_pct
|
|
128
|
+
|
|
129
|
+
def reset_daily(self) -> None:
|
|
130
|
+
"""Reset all daily usage counters (for testing or day rollover)."""
|
|
131
|
+
for usage in self._usage.values():
|
|
132
|
+
usage.tokens = 0
|
|
133
|
+
|
|
134
|
+
# -------------------------------------------------------------------
|
|
135
|
+
# Session Cost Estimation (V4.2)
|
|
136
|
+
# -------------------------------------------------------------------
|
|
137
|
+
|
|
138
|
+
# P50 conservative pricing: $0.003/1K tokens (Claude Sonnet)
|
|
139
|
+
_P50_PRICE_PER_1K: float = 0.003
|
|
140
|
+
# Approximate chars per token for English/code content
|
|
141
|
+
_CHARS_PER_TOKEN: int = 4
|
|
142
|
+
# Review overhead: estimated token counts for review checkpoints
|
|
143
|
+
_L2_REVIEW_TOKENS: int = 8_000
|
|
144
|
+
_L3_REVIEW_TOKENS: int = 15_000
|
|
145
|
+
_REVIEW_CHECKPOINT_TOKENS: int = 3_000
|
|
146
|
+
|
|
147
|
+
def estimate_session_cost(
|
|
148
|
+
self,
|
|
149
|
+
skill_id: str,
|
|
150
|
+
skill_length: int = 0,
|
|
151
|
+
review_level: str = "L1",
|
|
152
|
+
include_review_checkpoint: bool = False,
|
|
153
|
+
skill_get_fn: Callable[[str], Any] | None = None,
|
|
154
|
+
gate_check_result: Any | None = None,
|
|
155
|
+
enforcement_mode: str = "strict",
|
|
156
|
+
emergency_bypass_path: str | None = None,
|
|
157
|
+
changed_files: list[str] | None = None,
|
|
158
|
+
) -> CostEstimate:
|
|
159
|
+
"""Estimate session cost for a skill execution.
|
|
160
|
+
|
|
161
|
+
Uses P50 conservative pricing ($0.003/1K tokens).
|
|
162
|
+
Combines: skill execution cost + L2/L3 review overhead + checkpoint overhead.
|
|
163
|
+
|
|
164
|
+
Args:
|
|
165
|
+
skill_id: Skill identifier (e.g. "dev-4d-sdd").
|
|
166
|
+
skill_length: Character count of skill definition (fallback if skill_get unavailable).
|
|
167
|
+
review_level: Complexity level (L0/L1/L2/L3+L2+).
|
|
168
|
+
include_review_checkpoint: Whether to include review checkpoint overhead.
|
|
169
|
+
skill_get_fn: Optional callable to fetch skill definition via MCP.
|
|
170
|
+
gate_check_result: Optional GateCheckResult from gate validation.
|
|
171
|
+
enforcement_mode: Gate enforcement mode (strict/permissive/disabled).
|
|
172
|
+
emergency_bypass_path: Path to emergency_overrides.json file.
|
|
173
|
+
changed_files: List of changed file paths for gate pattern matching.
|
|
174
|
+
|
|
175
|
+
Returns:
|
|
176
|
+
CostEstimate with full cost breakdown.
|
|
177
|
+
"""
|
|
178
|
+
# Step 1: Resolve skill_length from skill_get or fallback
|
|
179
|
+
resolved_length = skill_length
|
|
180
|
+
if skill_get_fn is not None:
|
|
181
|
+
try:
|
|
182
|
+
skill_data = skill_get_fn(skill_id)
|
|
183
|
+
if skill_data is not None and hasattr(skill_data, "content"):
|
|
184
|
+
resolved_length = len(skill_data.content)
|
|
185
|
+
elif isinstance(skill_data, dict):
|
|
186
|
+
content = skill_data.get("content", "")
|
|
187
|
+
resolved_length = len(content) if content else skill_length
|
|
188
|
+
except Exception:
|
|
189
|
+
pass # Fallback to provided skill_length
|
|
190
|
+
|
|
191
|
+
# Step 2: Estimate token count
|
|
192
|
+
token_count = max(1, resolved_length // self._CHARS_PER_TOKEN)
|
|
193
|
+
|
|
194
|
+
# Step 3: Calculate base cost (P50 pricing)
|
|
195
|
+
base_cost_usd = token_count * self._P50_PRICE_PER_1K / 1000
|
|
196
|
+
|
|
197
|
+
# Step 4: Calculate review overhead based on level
|
|
198
|
+
l2_overhead = 0.0
|
|
199
|
+
l3_overhead = 0.0
|
|
200
|
+
if review_level in ("L2", "L3+L2+"):
|
|
201
|
+
l2_overhead = self._L2_REVIEW_TOKENS * self._P50_PRICE_PER_1K / 1000
|
|
202
|
+
if review_level == "L3+L2+":
|
|
203
|
+
l3_overhead = self._L3_REVIEW_TOKENS * self._P50_PRICE_PER_1K / 1000
|
|
204
|
+
|
|
205
|
+
# Step 5: Calculate review checkpoint overhead
|
|
206
|
+
checkpoint_overhead = 0.0
|
|
207
|
+
if include_review_checkpoint:
|
|
208
|
+
checkpoint_overhead = self._REVIEW_CHECKPOINT_TOKENS * self._P50_PRICE_PER_1K / 1000
|
|
209
|
+
|
|
210
|
+
# Step 6: Gate validation
|
|
211
|
+
gate_passed = True
|
|
212
|
+
gate_block_reason = None
|
|
213
|
+
if gate_check_result is not None:
|
|
214
|
+
if enforcement_mode == "disabled":
|
|
215
|
+
gate_passed = True
|
|
216
|
+
elif enforcement_mode == "permissive":
|
|
217
|
+
gate_passed = gate_check_result.passed
|
|
218
|
+
else: # strict
|
|
219
|
+
gate_passed = gate_check_result.passed
|
|
220
|
+
if not gate_passed:
|
|
221
|
+
gate_block_reason = gate_check_result.validation_message
|
|
222
|
+
|
|
223
|
+
# Step 7: Emergency bypass check
|
|
224
|
+
emergency_bypass_active = False
|
|
225
|
+
if emergency_bypass_path is not None:
|
|
226
|
+
bypass_path = Path(emergency_bypass_path)
|
|
227
|
+
if bypass_path.exists():
|
|
228
|
+
try:
|
|
229
|
+
data = json.loads(bypass_path.read_text())
|
|
230
|
+
expires_at = data.get("expires_at")
|
|
231
|
+
if expires_at:
|
|
232
|
+
from datetime import datetime
|
|
233
|
+
|
|
234
|
+
expiry = datetime.fromisoformat(expires_at)
|
|
235
|
+
if utc_now() < expiry:
|
|
236
|
+
emergency_bypass_active = True
|
|
237
|
+
else:
|
|
238
|
+
# No expiry → bypass active indefinitely
|
|
239
|
+
emergency_bypass_active = True
|
|
240
|
+
except Exception:
|
|
241
|
+
pass # Corrupt file → bypass not active
|
|
242
|
+
|
|
243
|
+
# If bypass active, override gate result
|
|
244
|
+
if emergency_bypass_active:
|
|
245
|
+
gate_passed = True
|
|
246
|
+
gate_block_reason = None
|
|
247
|
+
|
|
248
|
+
# Step 8: Total cost
|
|
249
|
+
total_cost = base_cost_usd + l2_overhead + l3_overhead + checkpoint_overhead
|
|
250
|
+
|
|
251
|
+
return CostEstimate(
|
|
252
|
+
skill_id=skill_id,
|
|
253
|
+
skill_length=resolved_length,
|
|
254
|
+
token_count=token_count,
|
|
255
|
+
base_cost_usd=round(base_cost_usd, 6),
|
|
256
|
+
l2_review_overhead_usd=round(l2_overhead, 6),
|
|
257
|
+
l3_review_overhead_usd=round(l3_overhead, 6),
|
|
258
|
+
review_checkpoint_overhead_usd=round(checkpoint_overhead, 6),
|
|
259
|
+
total_cost_usd=round(total_cost, 6),
|
|
260
|
+
price_per_1k_tokens=self._P50_PRICE_PER_1K,
|
|
261
|
+
gate_passed=gate_passed,
|
|
262
|
+
gate_block_reason=gate_block_reason,
|
|
263
|
+
emergency_bypass_active=emergency_bypass_active,
|
|
264
|
+
)
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
"""TraceCeiling — per-trace cost ceiling with circuit breaker."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class TraceCeiling:
|
|
7
|
+
"""Enforce a cost ceiling per trace_id.
|
|
8
|
+
|
|
9
|
+
When a trace's cumulative cost exceeds the ceiling, all further
|
|
10
|
+
operations under that trace are blocked (circuit broken).
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
def __init__(self, ceiling_usd: float = 5.0) -> None:
|
|
14
|
+
self.ceiling_usd = ceiling_usd
|
|
15
|
+
self._trace_costs: dict[str, float] = {}
|
|
16
|
+
|
|
17
|
+
def record_trace_cost(self, trace_id: str, cost_usd: float) -> None:
|
|
18
|
+
"""Record a cost against a trace."""
|
|
19
|
+
if trace_id not in self._trace_costs:
|
|
20
|
+
self._trace_costs[trace_id] = 0.0
|
|
21
|
+
self._trace_costs[trace_id] += cost_usd
|
|
22
|
+
|
|
23
|
+
def check(self, trace_id: str, additional_cost: float) -> tuple[bool, str]:
|
|
24
|
+
"""Check if an additional cost is allowed for a trace.
|
|
25
|
+
|
|
26
|
+
Returns (allowed, reason).
|
|
27
|
+
"""
|
|
28
|
+
current = self._trace_costs.get(trace_id, 0.0)
|
|
29
|
+
if current + additional_cost > self.ceiling_usd:
|
|
30
|
+
return (
|
|
31
|
+
False,
|
|
32
|
+
f"trace {trace_id} ceiling exceeded: ${current:.4f} + ${additional_cost:.4f} > ${self.ceiling_usd:.2f}",
|
|
33
|
+
)
|
|
34
|
+
return True, "ok"
|
|
35
|
+
|
|
36
|
+
def is_circuit_broken(self, trace_id: str) -> bool:
|
|
37
|
+
"""Check if a trace has hit its cost ceiling."""
|
|
38
|
+
return self._trace_costs.get(trace_id, 0.0) >= self.ceiling_usd
|
skillpool/csdf.py
ADDED
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
"""SkillPool CSDF Parser — Parse SKILL.md frontmatter into structured data."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import hashlib
|
|
6
|
+
import re
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
from typing import Any
|
|
9
|
+
|
|
10
|
+
import yaml
|
|
11
|
+
from pydantic import BaseModel, Field, field_validator
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class CSDFDocument(BaseModel):
|
|
15
|
+
"""Parsed CSDF (Codex Skill Definition Format) document."""
|
|
16
|
+
|
|
17
|
+
name: str
|
|
18
|
+
version: str = "0.1.0"
|
|
19
|
+
description: str = ""
|
|
20
|
+
triggers: list[str] = Field(default_factory=list)
|
|
21
|
+
references: list[str] = Field(default_factory=list)
|
|
22
|
+
dependencies: list[str] = Field(default_factory=list)
|
|
23
|
+
quality: dict[str, Any] = Field(default_factory=dict)
|
|
24
|
+
dimensions: dict[str, float] = Field(default_factory=dict)
|
|
25
|
+
body: str = ""
|
|
26
|
+
content_hash: str = ""
|
|
27
|
+
source_path: str = ""
|
|
28
|
+
|
|
29
|
+
@field_validator("version")
|
|
30
|
+
@classmethod
|
|
31
|
+
def version_format(cls, v: str) -> str:
|
|
32
|
+
"""Accept semver (1.2.3) or short (1.0) version strings."""
|
|
33
|
+
if not re.match(r"^\d+(\.\d+)*$", v):
|
|
34
|
+
raise ValueError(f"Invalid version format: {v!r}")
|
|
35
|
+
return v
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
class CSDFParser:
|
|
39
|
+
"""Stateless parser for CSDF frontmatter documents."""
|
|
40
|
+
|
|
41
|
+
def __init__(self) -> None:
|
|
42
|
+
self._frontmatter_re = re.compile(r"^---\s*\n(.*?)\n---\s*\n?(.*)", re.DOTALL)
|
|
43
|
+
|
|
44
|
+
def parse(self, content: str, source_path: str = "") -> CSDFDocument:
|
|
45
|
+
"""Parse a SKILL.md string into a CSDFDocument.
|
|
46
|
+
|
|
47
|
+
Raises ValueError if no valid YAML frontmatter is found.
|
|
48
|
+
"""
|
|
49
|
+
if isinstance(content, Path):
|
|
50
|
+
content = content.read_text(encoding="utf-8")
|
|
51
|
+
|
|
52
|
+
content = str(content)
|
|
53
|
+
match = self._frontmatter_re.match(content)
|
|
54
|
+
if not match:
|
|
55
|
+
raise ValueError("No valid YAML frontmatter found")
|
|
56
|
+
|
|
57
|
+
fm_text, body = match.group(1), match.group(2)
|
|
58
|
+
try:
|
|
59
|
+
fm: dict[str, Any] = yaml.safe_load(fm_text) or {}
|
|
60
|
+
except yaml.YAMLError as exc:
|
|
61
|
+
raise ValueError(f"Invalid YAML in frontmatter: {exc}") from exc
|
|
62
|
+
|
|
63
|
+
quality = fm.pop("quality", {}) or {}
|
|
64
|
+
dimensions = fm.pop("dimensions", {}) or {}
|
|
65
|
+
|
|
66
|
+
doc = CSDFDocument(
|
|
67
|
+
name=fm.get("name", ""),
|
|
68
|
+
version=fm.get("version", "0.1.0"),
|
|
69
|
+
description=fm.get("description", ""),
|
|
70
|
+
triggers=fm.get("triggers", []),
|
|
71
|
+
references=fm.get("references", []),
|
|
72
|
+
dependencies=fm.get("dependencies", []),
|
|
73
|
+
quality=quality,
|
|
74
|
+
dimensions=dimensions,
|
|
75
|
+
body=body.strip(),
|
|
76
|
+
content_hash=self._hash(content),
|
|
77
|
+
source_path=source_path,
|
|
78
|
+
)
|
|
79
|
+
return doc
|
|
80
|
+
|
|
81
|
+
@staticmethod
|
|
82
|
+
def _hash(content: str) -> str:
|
|
83
|
+
return hashlib.sha256(content.encode("utf-8")).hexdigest()[:16]
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def parse_csdf(source: str | Path) -> CSDFDocument:
|
|
87
|
+
"""Parse a CSDF document from a file path or string content.
|
|
88
|
+
|
|
89
|
+
Unlike CSDFParser.parse(), this function gracefully handles content
|
|
90
|
+
without frontmatter by returning a default CSDFDocument.
|
|
91
|
+
"""
|
|
92
|
+
parser = CSDFParser()
|
|
93
|
+
if isinstance(source, Path):
|
|
94
|
+
content = source.read_text(encoding="utf-8")
|
|
95
|
+
try:
|
|
96
|
+
return parser.parse(content, source_path=str(source))
|
|
97
|
+
except ValueError:
|
|
98
|
+
return CSDFDocument(
|
|
99
|
+
name="",
|
|
100
|
+
body=content.strip(),
|
|
101
|
+
content_hash=parser._hash(content),
|
|
102
|
+
source_path=str(source),
|
|
103
|
+
)
|
|
104
|
+
content = str(source)
|
|
105
|
+
try:
|
|
106
|
+
return parser.parse(content)
|
|
107
|
+
except ValueError:
|
|
108
|
+
return CSDFDocument(
|
|
109
|
+
name="",
|
|
110
|
+
body=content.strip(),
|
|
111
|
+
content_hash=parser._hash(content),
|
|
112
|
+
)
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def validate_csdf(doc: CSDFDocument) -> list[str]:
|
|
116
|
+
"""Validate a CSDFDocument and return a list of issues."""
|
|
117
|
+
issues: list[str] = []
|
|
118
|
+
if not doc.name:
|
|
119
|
+
issues.append("Missing name")
|
|
120
|
+
if not doc.description:
|
|
121
|
+
issues.append("Missing description")
|
|
122
|
+
if not doc.triggers:
|
|
123
|
+
issues.append("Missing triggers")
|
|
124
|
+
if not doc.body:
|
|
125
|
+
issues.append("Missing body content after frontmatter")
|
|
126
|
+
return issues
|