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.
- buildlog/__init__.py +3 -0
- buildlog/cli.py +437 -0
- buildlog/core/__init__.py +25 -0
- buildlog/core/operations.py +392 -0
- buildlog/distill.py +374 -0
- buildlog/embeddings.py +392 -0
- buildlog/mcp/__init__.py +15 -0
- buildlog/mcp/server.py +29 -0
- buildlog/mcp/tools.py +97 -0
- buildlog/render/__init__.py +41 -0
- buildlog/render/base.py +23 -0
- buildlog/render/claude_md.py +106 -0
- buildlog/render/settings_json.py +96 -0
- buildlog/skills.py +630 -0
- buildlog/stats.py +469 -0
- buildlog-0.1.0.data/data/share/buildlog/copier.yml +35 -0
- buildlog-0.1.0.data/data/share/buildlog/post_gen.py +51 -0
- buildlog-0.1.0.data/data/share/buildlog/template/buildlog/.gitkeep +0 -0
- buildlog-0.1.0.data/data/share/buildlog/template/buildlog/2026-01-01-example.md +269 -0
- buildlog-0.1.0.data/data/share/buildlog/template/buildlog/BUILDLOG_SYSTEM.md +114 -0
- buildlog-0.1.0.data/data/share/buildlog/template/buildlog/_TEMPLATE.md +162 -0
- buildlog-0.1.0.data/data/share/buildlog/template/buildlog/assets/.gitkeep +0 -0
- buildlog-0.1.0.dist-info/METADATA +664 -0
- buildlog-0.1.0.dist-info/RECORD +27 -0
- buildlog-0.1.0.dist-info/WHEEL +4 -0
- buildlog-0.1.0.dist-info/entry_points.txt +3 -0
- buildlog-0.1.0.dist-info/licenses/LICENSE +21 -0
|
@@ -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
|
+
)
|