skillpool 4.3.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (90) hide show
  1. skillpool/__init__.py +74 -0
  2. skillpool/__main__.py +6 -0
  3. skillpool/adapters/__init__.py +8 -0
  4. skillpool/adapters/base.py +41 -0
  5. skillpool/adapters/claude_adapter.py +36 -0
  6. skillpool/adapters/codex_adapter.py +92 -0
  7. skillpool/adapters/hermes_adapter.py +38 -0
  8. skillpool/audit/__init__.py +651 -0
  9. skillpool/bridge/__init__.py +16 -0
  10. skillpool/bridge/freeze_detector.py +134 -0
  11. skillpool/bridge/maintenance.py +119 -0
  12. skillpool/bridge/wal_manager.py +136 -0
  13. skillpool/clawmem_client.py +176 -0
  14. skillpool/cli.py +700 -0
  15. skillpool/combiner/__init__.py +31 -0
  16. skillpool/combiner/lifecycle.py +453 -0
  17. skillpool/combiner/models.py +99 -0
  18. skillpool/config.py +34 -0
  19. skillpool/cost/__init__.py +111 -0
  20. skillpool/cost/audit_hash.py +51 -0
  21. skillpool/cost/budget_tracker.py +66 -0
  22. skillpool/cost/dashboard.py +189 -0
  23. skillpool/cost/models.py +129 -0
  24. skillpool/cost/token_governor.py +264 -0
  25. skillpool/cost/trace_ceiling.py +38 -0
  26. skillpool/csdf.py +126 -0
  27. skillpool/evolver/__init__.py +978 -0
  28. skillpool/gain/__init__.py +285 -0
  29. skillpool/gate.py +282 -0
  30. skillpool/gate_policy/__init__.py +31 -0
  31. skillpool/gate_policy/incremental.py +157 -0
  32. skillpool/gate_policy/parser.py +258 -0
  33. skillpool/gate_policy/state_machine.py +432 -0
  34. skillpool/graph/__init__.py +14 -0
  35. skillpool/graph/ppr.py +279 -0
  36. skillpool/health/__init__.py +73 -0
  37. skillpool/health/check.py +85 -0
  38. skillpool/health/degradation.py +90 -0
  39. skillpool/health/models.py +43 -0
  40. skillpool/hooks/__init__.py +4 -0
  41. skillpool/hooks/security_scanner.py +288 -0
  42. skillpool/lifecycle.py +150 -0
  43. skillpool/materializer/__init__.py +124 -0
  44. skillpool/materializer/budget_cropper.py +178 -0
  45. skillpool/materializer/csdf_loader.py +114 -0
  46. skillpool/materializer/lazy_loader.py +265 -0
  47. skillpool/materializer/lifecycle_filter.py +93 -0
  48. skillpool/materializer/mapper.py +178 -0
  49. skillpool/materializer/models.py +66 -0
  50. skillpool/mcp_server.py +2005 -0
  51. skillpool/monitor/__init__.py +576 -0
  52. skillpool/monitor/bug_collector.py +392 -0
  53. skillpool/monitor/defect_classifier.py +218 -0
  54. skillpool/monitor/self_healing.py +530 -0
  55. skillpool/monitor/telemetry_bridge.py +197 -0
  56. skillpool/paradigm/__init__.py +312 -0
  57. skillpool/paradigm/override.py +285 -0
  58. skillpool/profile.py +94 -0
  59. skillpool/quality.py +254 -0
  60. skillpool/registry/__init__.py +509 -0
  61. skillpool/registry/models.py +98 -0
  62. skillpool/resolver/__init__.py +320 -0
  63. skillpool/resolver/cache.py +103 -0
  64. skillpool/resolver/circuit_breaker.py +103 -0
  65. skillpool/resolver/conflict_detector.py +111 -0
  66. skillpool/resolver/health_filter.py +38 -0
  67. skillpool/resolver/models.py +154 -0
  68. skillpool/resolver/rate_limiter.py +48 -0
  69. skillpool/resolver/skill_graph.py +183 -0
  70. skillpool/review/__init__.py +242 -0
  71. skillpool/review/async_queue.py +96 -0
  72. skillpool/review/checkpoint_runner.py +345 -0
  73. skillpool/review/models.py +164 -0
  74. skillpool/review/suspect_marker.py +39 -0
  75. skillpool/review/veto_evaluator.py +94 -0
  76. skillpool/router/__init__.py +481 -0
  77. skillpool/schemas.py +119 -0
  78. skillpool/synergy/__init__.py +240 -0
  79. skillpool/synergy/detector.py +5 -0
  80. skillpool/telemetry.py +126 -0
  81. skillpool/utils/__init__.py +21 -0
  82. skillpool/utils/changelog.py +218 -0
  83. skillpool/utils/logger.py +273 -0
  84. skillpool/utils/runtime_audit.py +163 -0
  85. skillpool/utils/time_utils.py +13 -0
  86. skillpool-4.3.0.dist-info/METADATA +21 -0
  87. skillpool-4.3.0.dist-info/RECORD +90 -0
  88. skillpool-4.3.0.dist-info/WHEEL +5 -0
  89. skillpool-4.3.0.dist-info/entry_points.txt +3 -0
  90. skillpool-4.3.0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,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
@@ -0,0 +1,5 @@
1
+ """SynergyDetector — Skill combination gain detection."""
2
+
3
+ from skillpool.synergy import SynergyDetector, SynergyEdge, SynergyDetectionResult
4
+
5
+ __all__ = ["SynergyDetector", "SynergyEdge", "SynergyDetectionResult"]
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")