buildlog 0.1.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.
@@ -0,0 +1,392 @@
1
+ """Core operations for buildlog skill management.
2
+
3
+ This module contains the business logic that can be exposed via
4
+ MCP, CLI, HTTP, or any other interface.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import json
10
+ from dataclasses import dataclass, field
11
+ from datetime import datetime
12
+ from pathlib import Path
13
+ from typing import Literal
14
+
15
+ from buildlog.render import get_renderer
16
+ from buildlog.skills import Skill, SkillSet, generate_skills
17
+
18
+ __all__ = [
19
+ "StatusResult",
20
+ "PromoteResult",
21
+ "RejectResult",
22
+ "DiffResult",
23
+ "status",
24
+ "promote",
25
+ "reject",
26
+ "diff",
27
+ "find_skills_by_ids",
28
+ ]
29
+
30
+
31
+ @dataclass
32
+ class StatusResult:
33
+ """Result of a status operation."""
34
+
35
+ skills: dict[str, list[dict]]
36
+ """Skills grouped by category."""
37
+
38
+ total_entries: int
39
+ """Number of buildlog entries processed."""
40
+
41
+ total_skills: int
42
+ """Total number of skills found."""
43
+
44
+ by_confidence: dict[str, int]
45
+ """Count of skills by confidence level."""
46
+
47
+ promotable_ids: list[str]
48
+ """IDs of high-confidence skills ready for promotion."""
49
+
50
+ error: str | None = None
51
+ """Error message if operation failed."""
52
+
53
+
54
+ @dataclass
55
+ class PromoteResult:
56
+ """Result of a promote operation."""
57
+
58
+ promoted_ids: list[str]
59
+ """IDs of skills that were promoted."""
60
+
61
+ target: str
62
+ """Target format (claude_md or settings_json)."""
63
+
64
+ rules_added: int
65
+ """Number of rules added."""
66
+
67
+ not_found_ids: list[str] = field(default_factory=list)
68
+ """IDs that were not found."""
69
+
70
+ message: str = ""
71
+ """Confirmation message."""
72
+
73
+ error: str | None = None
74
+ """Error message if operation failed."""
75
+
76
+
77
+ @dataclass
78
+ class RejectResult:
79
+ """Result of a reject operation."""
80
+
81
+ rejected_ids: list[str]
82
+ """IDs that were rejected."""
83
+
84
+ total_rejected: int
85
+ """Total number of rejected skills."""
86
+
87
+ error: str | None = None
88
+ """Error message if operation failed."""
89
+
90
+
91
+ @dataclass
92
+ class DiffResult:
93
+ """Result of a diff operation."""
94
+
95
+ pending: dict[str, list[dict]]
96
+ """Skills pending review, grouped by category."""
97
+
98
+ total_pending: int
99
+ """Total number of pending skills."""
100
+
101
+ already_promoted: int
102
+ """Number of previously promoted skills."""
103
+
104
+ already_rejected: int
105
+ """Number of previously rejected skills."""
106
+
107
+ error: str | None = None
108
+ """Error message if operation failed."""
109
+
110
+
111
+ def _get_rejected_path(buildlog_dir: Path) -> Path:
112
+ """Get path to rejected.json file."""
113
+ return buildlog_dir / ".buildlog" / "rejected.json"
114
+
115
+
116
+ def _get_promoted_path(buildlog_dir: Path) -> Path:
117
+ """Get path to promoted.json file."""
118
+ return buildlog_dir / ".buildlog" / "promoted.json"
119
+
120
+
121
+ def _load_json_set(path: Path, key: str) -> set[str]:
122
+ """Load a set of IDs from a JSON file."""
123
+ if not path.exists():
124
+ return set()
125
+ try:
126
+ data = json.loads(path.read_text())
127
+ return set(data.get(key, []))
128
+ except (json.JSONDecodeError, OSError):
129
+ return set()
130
+
131
+
132
+ def find_skills_by_ids(
133
+ skill_set: SkillSet,
134
+ skill_ids: list[str],
135
+ ) -> tuple[list[Skill], list[str]]:
136
+ """Find skills by their IDs.
137
+
138
+ Args:
139
+ skill_set: The SkillSet to search.
140
+ skill_ids: List of skill IDs to find.
141
+
142
+ Returns:
143
+ Tuple of (found_skills, not_found_ids).
144
+ """
145
+ found: list[Skill] = []
146
+ not_found: list[str] = []
147
+
148
+ # Build lookup map
149
+ id_to_skill: dict[str, Skill] = {}
150
+ for category_skills in skill_set.skills.values():
151
+ for skill in category_skills:
152
+ id_to_skill[skill.id] = skill
153
+
154
+ for skill_id in skill_ids:
155
+ if skill_id in id_to_skill:
156
+ found.append(id_to_skill[skill_id])
157
+ else:
158
+ not_found.append(skill_id)
159
+
160
+ return found, not_found
161
+
162
+
163
+ def status(
164
+ buildlog_dir: Path,
165
+ min_confidence: Literal["low", "medium", "high"] = "low",
166
+ ) -> StatusResult:
167
+ """Get current skills extracted from buildlog entries.
168
+
169
+ Args:
170
+ buildlog_dir: Path to buildlog directory.
171
+ min_confidence: Minimum confidence level to include.
172
+
173
+ Returns:
174
+ StatusResult with skills grouped by category and summary statistics.
175
+ """
176
+ if not buildlog_dir.exists():
177
+ return StatusResult(
178
+ skills={},
179
+ total_entries=0,
180
+ total_skills=0,
181
+ by_confidence={"high": 0, "medium": 0, "low": 0},
182
+ promotable_ids=[],
183
+ error=f"No buildlog directory found at {buildlog_dir}",
184
+ )
185
+
186
+ skill_set = generate_skills(buildlog_dir)
187
+
188
+ # Load rejected IDs to filter them out
189
+ rejected_ids = _load_json_set(_get_rejected_path(buildlog_dir), "skill_ids")
190
+
191
+ # Filter by confidence and exclude rejected
192
+ confidence_order = {"low": 0, "medium": 1, "high": 2}
193
+ min_level = confidence_order[min_confidence]
194
+
195
+ filtered: dict[str, list[dict]] = {}
196
+ by_confidence = {"high": 0, "medium": 0, "low": 0}
197
+ promotable: list[str] = []
198
+
199
+ for category, skill_list in skill_set.skills.items():
200
+ category_skills = []
201
+ for skill in skill_list:
202
+ # Skip rejected skills
203
+ if skill.id in rejected_ids:
204
+ continue
205
+
206
+ # Count by confidence (before filtering)
207
+ by_confidence[skill.confidence] += 1
208
+
209
+ # Track promotable (high confidence, not rejected)
210
+ if skill.confidence == "high":
211
+ promotable.append(skill.id)
212
+
213
+ # Apply confidence filter
214
+ if confidence_order[skill.confidence] >= min_level:
215
+ category_skills.append(skill.to_dict())
216
+
217
+ if category_skills:
218
+ filtered[category] = category_skills
219
+
220
+ # Calculate actual total (sum of by_confidence, which excludes rejected)
221
+ actual_total = sum(by_confidence.values())
222
+
223
+ return StatusResult(
224
+ skills=filtered,
225
+ total_entries=skill_set.source_entries,
226
+ total_skills=actual_total,
227
+ by_confidence=by_confidence,
228
+ promotable_ids=promotable,
229
+ )
230
+
231
+
232
+ def promote(
233
+ buildlog_dir: Path,
234
+ skill_ids: list[str],
235
+ target: Literal["claude_md", "settings_json"] = "claude_md",
236
+ target_path: Path | None = None,
237
+ ) -> PromoteResult:
238
+ """Promote skills to agent rules.
239
+
240
+ Args:
241
+ buildlog_dir: Path to buildlog directory.
242
+ skill_ids: List of skill IDs to promote.
243
+ target: Where to write rules ("claude_md" or "settings_json").
244
+ target_path: Optional custom path for the target file.
245
+
246
+ Returns:
247
+ PromoteResult with confirmation.
248
+ """
249
+ if not buildlog_dir.exists():
250
+ return PromoteResult(
251
+ promoted_ids=[],
252
+ target=target,
253
+ rules_added=0,
254
+ error=f"No buildlog directory found at {buildlog_dir}",
255
+ )
256
+
257
+ if not skill_ids:
258
+ return PromoteResult(
259
+ promoted_ids=[],
260
+ target=target,
261
+ rules_added=0,
262
+ error="No skill IDs provided",
263
+ )
264
+
265
+ skill_set = generate_skills(buildlog_dir)
266
+ found_skills, not_found_ids = find_skills_by_ids(skill_set, skill_ids)
267
+
268
+ if not found_skills:
269
+ return PromoteResult(
270
+ promoted_ids=[],
271
+ target=target,
272
+ rules_added=0,
273
+ not_found_ids=not_found_ids,
274
+ error="No valid skill IDs provided",
275
+ )
276
+
277
+ # Set up tracking path in buildlog directory
278
+ tracking_path = _get_promoted_path(buildlog_dir)
279
+
280
+ # Get renderer with custom paths
281
+ if target == "claude_md":
282
+ from buildlog.render.claude_md import ClaudeMdRenderer
283
+ renderer = ClaudeMdRenderer(path=target_path, tracking_path=tracking_path)
284
+ else:
285
+ from buildlog.render.settings_json import SettingsJsonRenderer
286
+ renderer = SettingsJsonRenderer(path=target_path, tracking_path=tracking_path)
287
+
288
+ message = renderer.render(found_skills)
289
+
290
+ return PromoteResult(
291
+ promoted_ids=[s.id for s in found_skills],
292
+ target=target,
293
+ rules_added=len(found_skills),
294
+ not_found_ids=not_found_ids,
295
+ message=message,
296
+ )
297
+
298
+
299
+ def reject(
300
+ buildlog_dir: Path,
301
+ skill_ids: list[str],
302
+ ) -> RejectResult:
303
+ """Mark skills as rejected so they won't be suggested again.
304
+
305
+ Args:
306
+ buildlog_dir: Path to buildlog directory.
307
+ skill_ids: List of skill IDs to reject.
308
+
309
+ Returns:
310
+ RejectResult with confirmation.
311
+ """
312
+ if not skill_ids:
313
+ return RejectResult(
314
+ rejected_ids=[],
315
+ total_rejected=0,
316
+ error="No skill IDs provided",
317
+ )
318
+
319
+ reject_file = _get_rejected_path(buildlog_dir)
320
+ reject_file.parent.mkdir(parents=True, exist_ok=True)
321
+
322
+ # Load existing rejections
323
+ if reject_file.exists():
324
+ try:
325
+ rejected = json.loads(reject_file.read_text())
326
+ except json.JSONDecodeError:
327
+ rejected = {"rejected_at": {}, "skill_ids": []}
328
+ else:
329
+ rejected = {"rejected_at": {}, "skill_ids": []}
330
+
331
+ # Add new rejections
332
+ now = datetime.now().isoformat()
333
+ newly_rejected: list[str] = []
334
+ for skill_id in skill_ids:
335
+ if skill_id not in rejected["skill_ids"]:
336
+ rejected["skill_ids"].append(skill_id)
337
+ rejected["rejected_at"][skill_id] = now
338
+ newly_rejected.append(skill_id)
339
+
340
+ reject_file.write_text(json.dumps(rejected, indent=2))
341
+
342
+ return RejectResult(
343
+ rejected_ids=newly_rejected,
344
+ total_rejected=len(rejected["skill_ids"]),
345
+ )
346
+
347
+
348
+ def diff(
349
+ buildlog_dir: Path,
350
+ ) -> DiffResult:
351
+ """Show skills that haven't been promoted or rejected yet.
352
+
353
+ Args:
354
+ buildlog_dir: Path to buildlog directory.
355
+
356
+ Returns:
357
+ DiffResult with pending skills.
358
+ """
359
+ if not buildlog_dir.exists():
360
+ return DiffResult(
361
+ pending={},
362
+ total_pending=0,
363
+ already_promoted=0,
364
+ already_rejected=0,
365
+ error=f"No buildlog directory found at {buildlog_dir}",
366
+ )
367
+
368
+ skill_set = generate_skills(buildlog_dir)
369
+
370
+ # Load rejected and promoted IDs
371
+ rejected_ids = _load_json_set(_get_rejected_path(buildlog_dir), "skill_ids")
372
+ promoted_ids = _load_json_set(_get_promoted_path(buildlog_dir), "skill_ids")
373
+
374
+ # Find unpromoted, unrejected skills
375
+ pending: dict[str, list[dict]] = {}
376
+ total_pending = 0
377
+
378
+ for category, skill_list in skill_set.skills.items():
379
+ pending_skills = [
380
+ s.to_dict() for s in skill_list
381
+ if s.id not in rejected_ids and s.id not in promoted_ids
382
+ ]
383
+ if pending_skills:
384
+ pending[category] = pending_skills
385
+ total_pending += len(pending_skills)
386
+
387
+ return DiffResult(
388
+ pending=pending,
389
+ total_pending=total_pending,
390
+ already_promoted=len(promoted_ids),
391
+ already_rejected=len(rejected_ids),
392
+ )