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
buildlog/skills.py
ADDED
|
@@ -0,0 +1,630 @@
|
|
|
1
|
+
"""Generate agent-consumable skills from distilled patterns."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
__all__ = [
|
|
6
|
+
"Skill",
|
|
7
|
+
"SkillSet",
|
|
8
|
+
"_deduplicate_insights",
|
|
9
|
+
"_calculate_confidence",
|
|
10
|
+
"_extract_tags",
|
|
11
|
+
"_generate_skill_id",
|
|
12
|
+
"_to_imperative",
|
|
13
|
+
"generate_skills",
|
|
14
|
+
"format_skills",
|
|
15
|
+
]
|
|
16
|
+
|
|
17
|
+
import hashlib
|
|
18
|
+
import json
|
|
19
|
+
import logging
|
|
20
|
+
import re
|
|
21
|
+
from dataclasses import dataclass, field
|
|
22
|
+
from datetime import UTC, date, datetime
|
|
23
|
+
from pathlib import Path
|
|
24
|
+
from typing import Final, Literal, TypedDict
|
|
25
|
+
|
|
26
|
+
from buildlog.distill import CATEGORIES, PatternDict, distill_all
|
|
27
|
+
from buildlog.embeddings import EmbeddingBackend, get_backend, get_default_backend
|
|
28
|
+
|
|
29
|
+
logger = logging.getLogger(__name__)
|
|
30
|
+
|
|
31
|
+
# Configuration constants
|
|
32
|
+
MIN_SIMILARITY_THRESHOLD: Final[float] = 0.7
|
|
33
|
+
HIGH_CONFIDENCE_FREQUENCY: Final[int] = 3
|
|
34
|
+
HIGH_CONFIDENCE_RECENCY_DAYS: Final[int] = 30
|
|
35
|
+
MEDIUM_CONFIDENCE_FREQUENCY: Final[int] = 2
|
|
36
|
+
|
|
37
|
+
# Type definitions
|
|
38
|
+
OutputFormat = Literal["yaml", "json", "markdown", "rules", "settings"]
|
|
39
|
+
ConfidenceLevel = Literal["high", "medium", "low"]
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
class SkillDict(TypedDict):
|
|
43
|
+
"""Type for skill dictionary representation."""
|
|
44
|
+
|
|
45
|
+
id: str
|
|
46
|
+
category: str
|
|
47
|
+
rule: str
|
|
48
|
+
frequency: int
|
|
49
|
+
confidence: ConfidenceLevel
|
|
50
|
+
sources: list[str]
|
|
51
|
+
tags: list[str]
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
class SkillSetDict(TypedDict):
|
|
55
|
+
"""Type for full skill set dictionary."""
|
|
56
|
+
|
|
57
|
+
generated_at: str
|
|
58
|
+
source_entries: int
|
|
59
|
+
total_skills: int
|
|
60
|
+
skills: dict[str, list[SkillDict]]
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
@dataclass
|
|
64
|
+
class Skill:
|
|
65
|
+
"""A codified learning from buildlog patterns.
|
|
66
|
+
|
|
67
|
+
Represents a single actionable rule derived from one or more
|
|
68
|
+
similar insights across buildlog entries.
|
|
69
|
+
"""
|
|
70
|
+
|
|
71
|
+
id: str
|
|
72
|
+
category: str
|
|
73
|
+
rule: str
|
|
74
|
+
frequency: int
|
|
75
|
+
confidence: ConfidenceLevel
|
|
76
|
+
sources: list[str] = field(default_factory=list)
|
|
77
|
+
tags: list[str] = field(default_factory=list)
|
|
78
|
+
|
|
79
|
+
def to_dict(self) -> SkillDict:
|
|
80
|
+
"""Convert to dictionary for serialization."""
|
|
81
|
+
return SkillDict(
|
|
82
|
+
id=self.id,
|
|
83
|
+
category=self.category,
|
|
84
|
+
rule=self.rule,
|
|
85
|
+
frequency=self.frequency,
|
|
86
|
+
confidence=self.confidence,
|
|
87
|
+
sources=self.sources,
|
|
88
|
+
tags=self.tags,
|
|
89
|
+
)
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
@dataclass
|
|
93
|
+
class SkillSet:
|
|
94
|
+
"""Collection of skills with metadata."""
|
|
95
|
+
|
|
96
|
+
generated_at: str
|
|
97
|
+
source_entries: int
|
|
98
|
+
skills: dict[str, list[Skill]] = field(default_factory=dict)
|
|
99
|
+
|
|
100
|
+
@property
|
|
101
|
+
def total_skills(self) -> int:
|
|
102
|
+
"""Total number of skills across all categories."""
|
|
103
|
+
return sum(len(skills) for skills in self.skills.values())
|
|
104
|
+
|
|
105
|
+
def to_dict(self) -> SkillSetDict:
|
|
106
|
+
"""Convert to dictionary for serialization."""
|
|
107
|
+
return SkillSetDict(
|
|
108
|
+
generated_at=self.generated_at,
|
|
109
|
+
source_entries=self.source_entries,
|
|
110
|
+
total_skills=self.total_skills,
|
|
111
|
+
skills={
|
|
112
|
+
cat: [s.to_dict() for s in skills]
|
|
113
|
+
for cat, skills in self.skills.items()
|
|
114
|
+
},
|
|
115
|
+
)
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def _generate_skill_id(category: str, rule: str) -> str:
|
|
119
|
+
"""Generate a stable ID for a skill.
|
|
120
|
+
|
|
121
|
+
The ID is deterministic - same category+rule always produces same ID.
|
|
122
|
+
Uses SHA-256 (truncated to 10 chars = 40 bits) for collision resistance.
|
|
123
|
+
At 40 bits, collision probability is ~0.5 after ~1 million unique rules.
|
|
124
|
+
"""
|
|
125
|
+
prefix_map = {
|
|
126
|
+
"architectural": "arch",
|
|
127
|
+
"workflow": "wf",
|
|
128
|
+
"tool_usage": "tool",
|
|
129
|
+
"domain_knowledge": "dk",
|
|
130
|
+
}
|
|
131
|
+
prefix = prefix_map.get(category, "sk")
|
|
132
|
+
# SHA-256 is more robust than MD5; 10 chars provides good collision resistance
|
|
133
|
+
rule_hash = hashlib.sha256(rule.lower().encode()).hexdigest()[:10]
|
|
134
|
+
return f"{prefix}-{rule_hash}"
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
def _calculate_confidence(
|
|
140
|
+
frequency: int,
|
|
141
|
+
most_recent_date: date | None,
|
|
142
|
+
reference_date: date | None = None,
|
|
143
|
+
) -> ConfidenceLevel:
|
|
144
|
+
"""Calculate confidence level based on frequency and recency.
|
|
145
|
+
|
|
146
|
+
Args:
|
|
147
|
+
frequency: How many times this pattern was seen.
|
|
148
|
+
most_recent_date: Date of most recent occurrence.
|
|
149
|
+
reference_date: Date to calculate recency from. Defaults to today.
|
|
150
|
+
Pass explicitly for deterministic testing.
|
|
151
|
+
|
|
152
|
+
Returns:
|
|
153
|
+
Confidence level: high, medium, or low.
|
|
154
|
+
|
|
155
|
+
Confidence is determined by:
|
|
156
|
+
- high: frequency >= 3 AND seen within last 30 days
|
|
157
|
+
- medium: frequency >= 2
|
|
158
|
+
- low: frequency < 2 or no frequency data
|
|
159
|
+
"""
|
|
160
|
+
if reference_date is None:
|
|
161
|
+
reference_date = date.today()
|
|
162
|
+
|
|
163
|
+
recency_days = float("inf")
|
|
164
|
+
if most_recent_date:
|
|
165
|
+
recency_days = (reference_date - most_recent_date).days
|
|
166
|
+
|
|
167
|
+
if frequency >= HIGH_CONFIDENCE_FREQUENCY and recency_days < HIGH_CONFIDENCE_RECENCY_DAYS:
|
|
168
|
+
return "high"
|
|
169
|
+
elif frequency >= MEDIUM_CONFIDENCE_FREQUENCY:
|
|
170
|
+
return "medium"
|
|
171
|
+
else:
|
|
172
|
+
return "low"
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
def _extract_tags(rule: str) -> list[str]:
|
|
176
|
+
"""Extract potential tags from a rule.
|
|
177
|
+
|
|
178
|
+
Looks for technology names, common keywords, etc.
|
|
179
|
+
"""
|
|
180
|
+
# Common tech/concept terms to extract as tags
|
|
181
|
+
known_tags = {
|
|
182
|
+
"api", "http", "json", "yaml", "sql", "database", "cache",
|
|
183
|
+
"redis", "supabase", "postgres", "mongodb", "git", "docker",
|
|
184
|
+
"kubernetes", "aws", "gcp", "azure", "react", "python",
|
|
185
|
+
"typescript", "javascript", "rust", "go", "test", "testing",
|
|
186
|
+
"ci", "cd", "deploy", "error", "retry", "timeout", "auth",
|
|
187
|
+
"jwt", "oauth", "plugin", "middleware", "async", "sync",
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
# Word variants that map to canonical tags
|
|
191
|
+
tag_variants = {
|
|
192
|
+
"caching": "cache",
|
|
193
|
+
"cached": "cache",
|
|
194
|
+
"databases": "database",
|
|
195
|
+
"tests": "test",
|
|
196
|
+
"tested": "test",
|
|
197
|
+
"pytest": "test",
|
|
198
|
+
"unittest": "test",
|
|
199
|
+
"deploying": "deploy",
|
|
200
|
+
"deployed": "deploy",
|
|
201
|
+
"deployment": "deploy",
|
|
202
|
+
"errors": "error",
|
|
203
|
+
"retries": "retry",
|
|
204
|
+
"retrying": "retry",
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
words = set(rule.lower().replace("-", " ").replace("_", " ").split())
|
|
208
|
+
|
|
209
|
+
tags = set()
|
|
210
|
+
for word in words:
|
|
211
|
+
if word in known_tags:
|
|
212
|
+
tags.add(word)
|
|
213
|
+
elif word in tag_variants:
|
|
214
|
+
tags.add(tag_variants[word])
|
|
215
|
+
|
|
216
|
+
return sorted(tags)
|
|
217
|
+
|
|
218
|
+
|
|
219
|
+
def _deduplicate_insights(
|
|
220
|
+
patterns: list[PatternDict],
|
|
221
|
+
threshold: float = MIN_SIMILARITY_THRESHOLD,
|
|
222
|
+
backend: EmbeddingBackend | None = None,
|
|
223
|
+
) -> list[tuple[str, int, list[str], date | None]]:
|
|
224
|
+
"""Deduplicate similar insights into merged rules.
|
|
225
|
+
|
|
226
|
+
Args:
|
|
227
|
+
patterns: List of pattern dictionaries from distill.
|
|
228
|
+
threshold: Minimum similarity ratio to consider duplicates.
|
|
229
|
+
backend: Embedding backend for similarity computation.
|
|
230
|
+
|
|
231
|
+
Returns:
|
|
232
|
+
List of (rule, frequency, sources, most_recent_date) tuples.
|
|
233
|
+
"""
|
|
234
|
+
if not patterns:
|
|
235
|
+
return []
|
|
236
|
+
|
|
237
|
+
if backend is None:
|
|
238
|
+
backend = get_default_backend()
|
|
239
|
+
|
|
240
|
+
# Group similar insights
|
|
241
|
+
groups: list[list[PatternDict]] = []
|
|
242
|
+
|
|
243
|
+
for pattern in patterns:
|
|
244
|
+
insight = pattern["insight"]
|
|
245
|
+
matched = False
|
|
246
|
+
|
|
247
|
+
for group in groups:
|
|
248
|
+
# Compare against first item in group (representative)
|
|
249
|
+
sim = backend.similarity(insight, group[0]["insight"])
|
|
250
|
+
if sim >= threshold:
|
|
251
|
+
group.append(pattern)
|
|
252
|
+
matched = True
|
|
253
|
+
break
|
|
254
|
+
|
|
255
|
+
if not matched:
|
|
256
|
+
groups.append([pattern])
|
|
257
|
+
|
|
258
|
+
# Convert groups to deduplicated rules
|
|
259
|
+
results: list[tuple[str, int, list[str], date | None]] = []
|
|
260
|
+
|
|
261
|
+
for group in groups:
|
|
262
|
+
# Use the shortest insight as the canonical rule (often cleaner)
|
|
263
|
+
canonical = min(group, key=lambda p: len(p["insight"]))
|
|
264
|
+
rule = canonical["insight"]
|
|
265
|
+
frequency = len(group)
|
|
266
|
+
sources = sorted(set(p["source"] for p in group))
|
|
267
|
+
|
|
268
|
+
# Find most recent date
|
|
269
|
+
dates: list[date] = []
|
|
270
|
+
for p in group:
|
|
271
|
+
try:
|
|
272
|
+
dates.append(date.fromisoformat(p["date"]))
|
|
273
|
+
except (ValueError, KeyError):
|
|
274
|
+
pass
|
|
275
|
+
|
|
276
|
+
most_recent = max(dates) if dates else None
|
|
277
|
+
results.append((rule, frequency, sources, most_recent))
|
|
278
|
+
|
|
279
|
+
return results
|
|
280
|
+
|
|
281
|
+
|
|
282
|
+
def generate_skills(
|
|
283
|
+
buildlog_dir: Path,
|
|
284
|
+
min_frequency: int = 1,
|
|
285
|
+
since_date: date | None = None,
|
|
286
|
+
embedding_backend: str | None = None,
|
|
287
|
+
) -> SkillSet:
|
|
288
|
+
"""Generate skills from buildlog patterns.
|
|
289
|
+
|
|
290
|
+
Args:
|
|
291
|
+
buildlog_dir: Path to the buildlog directory.
|
|
292
|
+
min_frequency: Minimum frequency to include a skill.
|
|
293
|
+
since_date: Only include patterns from this date onward.
|
|
294
|
+
embedding_backend: Name of embedding backend for deduplication.
|
|
295
|
+
Options: "token" (default), "sentence-transformers", "openai".
|
|
296
|
+
|
|
297
|
+
Returns:
|
|
298
|
+
SkillSet with generated skills.
|
|
299
|
+
"""
|
|
300
|
+
# Get distilled patterns
|
|
301
|
+
result = distill_all(buildlog_dir, since=since_date)
|
|
302
|
+
|
|
303
|
+
# Get embedding backend
|
|
304
|
+
backend = get_backend(embedding_backend) if embedding_backend else get_default_backend()
|
|
305
|
+
logger.info("Using embedding backend: %s", backend.name)
|
|
306
|
+
|
|
307
|
+
skills_by_category: dict[str, list[Skill]] = {}
|
|
308
|
+
|
|
309
|
+
for category in CATEGORIES:
|
|
310
|
+
patterns = result.patterns.get(category, [])
|
|
311
|
+
deduplicated = _deduplicate_insights(patterns, backend=backend)
|
|
312
|
+
|
|
313
|
+
skills: list[Skill] = []
|
|
314
|
+
for rule, frequency, sources, most_recent in deduplicated:
|
|
315
|
+
if frequency < min_frequency:
|
|
316
|
+
continue
|
|
317
|
+
|
|
318
|
+
skill = Skill(
|
|
319
|
+
id=_generate_skill_id(category, rule),
|
|
320
|
+
category=category,
|
|
321
|
+
rule=rule,
|
|
322
|
+
frequency=frequency,
|
|
323
|
+
confidence=_calculate_confidence(frequency, most_recent),
|
|
324
|
+
sources=sources,
|
|
325
|
+
tags=_extract_tags(rule),
|
|
326
|
+
)
|
|
327
|
+
skills.append(skill)
|
|
328
|
+
|
|
329
|
+
# Sort by frequency (descending), then by rule (for stability)
|
|
330
|
+
skills.sort(key=lambda s: (-s.frequency, s.rule))
|
|
331
|
+
skills_by_category[category] = skills
|
|
332
|
+
|
|
333
|
+
return SkillSet(
|
|
334
|
+
generated_at=datetime.now(UTC).isoformat().replace("+00:00", "Z"),
|
|
335
|
+
source_entries=result.entry_count,
|
|
336
|
+
skills=skills_by_category,
|
|
337
|
+
)
|
|
338
|
+
|
|
339
|
+
|
|
340
|
+
def _format_yaml(skill_set: SkillSet) -> str:
|
|
341
|
+
"""Format skills as YAML."""
|
|
342
|
+
try:
|
|
343
|
+
import yaml
|
|
344
|
+
except ImportError as e:
|
|
345
|
+
raise ImportError(
|
|
346
|
+
"PyYAML is required for YAML output. Install with: pip install pyyaml"
|
|
347
|
+
) from e
|
|
348
|
+
|
|
349
|
+
data = skill_set.to_dict()
|
|
350
|
+
return yaml.dump(data, default_flow_style=False, allow_unicode=True, sort_keys=False)
|
|
351
|
+
|
|
352
|
+
|
|
353
|
+
def _format_json(skill_set: SkillSet) -> str:
|
|
354
|
+
"""Format skills as JSON."""
|
|
355
|
+
return json.dumps(skill_set.to_dict(), indent=2, ensure_ascii=False)
|
|
356
|
+
|
|
357
|
+
|
|
358
|
+
def _format_markdown(skill_set: SkillSet) -> str:
|
|
359
|
+
"""Format skills as Markdown for CLAUDE.md injection."""
|
|
360
|
+
lines: list[str] = []
|
|
361
|
+
|
|
362
|
+
lines.append("## Learned Skills")
|
|
363
|
+
lines.append("")
|
|
364
|
+
lines.append(f"Based on {skill_set.source_entries} buildlog entries, "
|
|
365
|
+
f"{skill_set.total_skills} actionable skills have emerged:")
|
|
366
|
+
lines.append("")
|
|
367
|
+
|
|
368
|
+
category_titles = {
|
|
369
|
+
"architectural": "Architectural",
|
|
370
|
+
"workflow": "Workflow",
|
|
371
|
+
"tool_usage": "Tool Usage",
|
|
372
|
+
"domain_knowledge": "Domain Knowledge",
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
for category, skills in skill_set.skills.items():
|
|
376
|
+
if not skills:
|
|
377
|
+
continue
|
|
378
|
+
|
|
379
|
+
title = category_titles.get(category, category.replace("_", " ").title())
|
|
380
|
+
lines.append(f"### {title} ({len(skills)} skills)")
|
|
381
|
+
lines.append("")
|
|
382
|
+
|
|
383
|
+
for skill in skills:
|
|
384
|
+
confidence_badge = {"high": "🟢", "medium": "🟡", "low": "⚪"}.get(
|
|
385
|
+
skill.confidence, ""
|
|
386
|
+
)
|
|
387
|
+
freq_text = f"seen {skill.frequency}x" if skill.frequency > 1 else "seen once"
|
|
388
|
+
lines.append(f"- {confidence_badge} **{skill.rule}** ({freq_text})")
|
|
389
|
+
|
|
390
|
+
lines.append("")
|
|
391
|
+
|
|
392
|
+
lines.append("---")
|
|
393
|
+
lines.append(f"*Generated: {skill_set.generated_at}*")
|
|
394
|
+
|
|
395
|
+
return "\n".join(lines)
|
|
396
|
+
|
|
397
|
+
|
|
398
|
+
# Pre-compiled patterns for _to_imperative (module-level for efficiency)
|
|
399
|
+
_NEGATIVE_PATTERNS = tuple(
|
|
400
|
+
re.compile(p) for p in (
|
|
401
|
+
r"\bdon't\b", r"\bdo not\b", r"\bnever\b", r"\bavoid\b",
|
|
402
|
+
r"\bstop\b", r"\bshouldn't\b", r"\bshould not\b",
|
|
403
|
+
)
|
|
404
|
+
)
|
|
405
|
+
|
|
406
|
+
# Comparison patterns - intentionally narrow to avoid false positives
|
|
407
|
+
# "over" alone matches "all over", "game over" etc. so we require context
|
|
408
|
+
_COMPARISON_PATTERNS = tuple(
|
|
409
|
+
re.compile(p) for p in (
|
|
410
|
+
r"\binstead of\b",
|
|
411
|
+
r"\brather than\b",
|
|
412
|
+
r"\bbetter than\b",
|
|
413
|
+
r"\b\w+\s+over\s+\w+\b", # "X over Y" pattern
|
|
414
|
+
)
|
|
415
|
+
)
|
|
416
|
+
|
|
417
|
+
# Verbs that need -ing form when following "Avoid" or bare "Prefer"
|
|
418
|
+
_VERB_TO_GERUND: Final[dict[str, str]] = {
|
|
419
|
+
"use": "using", "run": "running", "make": "making", "write": "writing",
|
|
420
|
+
"read": "reading", "put": "putting", "get": "getting", "set": "setting",
|
|
421
|
+
"add": "adding", "create": "creating", "delete": "deleting", "call": "calling",
|
|
422
|
+
"pass": "passing", "send": "sending", "store": "storing", "cache": "caching",
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
|
|
426
|
+
def _to_imperative(rule: str, confidence: ConfidenceLevel) -> str:
|
|
427
|
+
"""Transform a rule into imperative form.
|
|
428
|
+
|
|
429
|
+
High confidence → "Always X" or "Never Y"
|
|
430
|
+
Medium confidence → "Prefer X" or "Avoid Y"
|
|
431
|
+
Low confidence → "Consider: X" (stays as observation)
|
|
432
|
+
|
|
433
|
+
Args:
|
|
434
|
+
rule: The rule text to transform.
|
|
435
|
+
confidence: Must be "high", "medium", or "low".
|
|
436
|
+
|
|
437
|
+
Returns:
|
|
438
|
+
Transformed rule with appropriate confidence prefix.
|
|
439
|
+
|
|
440
|
+
Raises:
|
|
441
|
+
ValueError: If confidence is not a valid ConfidenceLevel.
|
|
442
|
+
"""
|
|
443
|
+
# Validate confidence parameter
|
|
444
|
+
valid_confidence: set[ConfidenceLevel] = {"high", "medium", "low"}
|
|
445
|
+
if confidence not in valid_confidence:
|
|
446
|
+
raise ValueError(
|
|
447
|
+
f"Invalid confidence level: {confidence!r}. "
|
|
448
|
+
f"Must be one of: {valid_confidence}"
|
|
449
|
+
)
|
|
450
|
+
|
|
451
|
+
rule = rule.strip()
|
|
452
|
+
if not rule:
|
|
453
|
+
return ""
|
|
454
|
+
|
|
455
|
+
rule_lower = rule.lower()
|
|
456
|
+
|
|
457
|
+
# Already has a confidence modifier - just capitalize and return
|
|
458
|
+
confidence_modifiers = (
|
|
459
|
+
"always", "never", "prefer", "avoid", "consider", "remember",
|
|
460
|
+
"don't", "do not",
|
|
461
|
+
)
|
|
462
|
+
if any(rule_lower.startswith(word) for word in confidence_modifiers):
|
|
463
|
+
return rule[0].upper() + rule[1:]
|
|
464
|
+
|
|
465
|
+
# Detect patterns using pre-compiled regexes
|
|
466
|
+
is_negative = any(pat.search(rule_lower) for pat in _NEGATIVE_PATTERNS)
|
|
467
|
+
is_comparison = any(pat.search(rule_lower) for pat in _COMPARISON_PATTERNS)
|
|
468
|
+
|
|
469
|
+
# Choose prefix based on confidence and pattern
|
|
470
|
+
if confidence == "high":
|
|
471
|
+
if is_negative:
|
|
472
|
+
prefix = "Never"
|
|
473
|
+
else:
|
|
474
|
+
prefix = "Always"
|
|
475
|
+
elif confidence == "medium":
|
|
476
|
+
if is_negative:
|
|
477
|
+
prefix = "Avoid"
|
|
478
|
+
elif is_comparison:
|
|
479
|
+
prefix = "Prefer"
|
|
480
|
+
else:
|
|
481
|
+
prefix = "Prefer to"
|
|
482
|
+
else: # low - already validated above
|
|
483
|
+
return f"Consider: {rule}"
|
|
484
|
+
|
|
485
|
+
# Clean up the rule for prefixing
|
|
486
|
+
# Remove leading "should" type words (order matters - longer first)
|
|
487
|
+
cleaners = [
|
|
488
|
+
"you shouldn't ", "we shouldn't ", "shouldn't ",
|
|
489
|
+
"you should not ", "we should not ", "should not ",
|
|
490
|
+
"you should ", "we should ", "should ",
|
|
491
|
+
"it's better to ", "it is better to ",
|
|
492
|
+
]
|
|
493
|
+
cleaned = rule
|
|
494
|
+
cleaned_lower = rule_lower
|
|
495
|
+
for cleaner in cleaners:
|
|
496
|
+
if cleaned_lower.startswith(cleaner):
|
|
497
|
+
cleaned = cleaned[len(cleaner):]
|
|
498
|
+
cleaned_lower = cleaned.lower()
|
|
499
|
+
break
|
|
500
|
+
|
|
501
|
+
# If we're adding a negative prefix, remove leading "not " from cleaned
|
|
502
|
+
if prefix in ("Never", "Avoid") and cleaned_lower.startswith("not "):
|
|
503
|
+
cleaned = cleaned[4:]
|
|
504
|
+
cleaned_lower = cleaned.lower()
|
|
505
|
+
|
|
506
|
+
# Avoid double words: "Avoid avoid using..." -> "Avoid using..."
|
|
507
|
+
prefix_lower = prefix.lower()
|
|
508
|
+
if cleaned_lower.startswith(prefix_lower + " ") or cleaned_lower.startswith(prefix_lower + "ing "):
|
|
509
|
+
first_space = cleaned.find(" ")
|
|
510
|
+
if first_space > 0:
|
|
511
|
+
cleaned = cleaned[first_space + 1:]
|
|
512
|
+
cleaned_lower = cleaned.lower()
|
|
513
|
+
|
|
514
|
+
# For "Avoid" and bare "Prefer", convert leading verbs to gerund form
|
|
515
|
+
# "Avoid use eval" -> "Avoid using eval"
|
|
516
|
+
# "Prefer use X over Y" -> "Prefer using X over Y"
|
|
517
|
+
if prefix in ("Avoid", "Prefer"):
|
|
518
|
+
first_word = cleaned_lower.split()[0] if cleaned_lower else ""
|
|
519
|
+
if first_word in _VERB_TO_GERUND:
|
|
520
|
+
gerund = _VERB_TO_GERUND[first_word]
|
|
521
|
+
cleaned = gerund + cleaned[len(first_word):]
|
|
522
|
+
cleaned_lower = cleaned.lower()
|
|
523
|
+
|
|
524
|
+
# Lowercase first char if we're adding a prefix (but not for gerunds which are already lower)
|
|
525
|
+
if cleaned and cleaned[0].isupper():
|
|
526
|
+
cleaned = cleaned[0].lower() + cleaned[1:]
|
|
527
|
+
|
|
528
|
+
return f"{prefix} {cleaned}"
|
|
529
|
+
|
|
530
|
+
|
|
531
|
+
def _format_rules(skill_set: SkillSet) -> str:
|
|
532
|
+
"""Format skills as CLAUDE.md-ready rules.
|
|
533
|
+
|
|
534
|
+
Transforms skills into imperative rules grouped by confidence level.
|
|
535
|
+
High-confidence rules become "Always/Never" imperatives.
|
|
536
|
+
"""
|
|
537
|
+
lines: list[str] = []
|
|
538
|
+
|
|
539
|
+
lines.append("# Project Rules")
|
|
540
|
+
lines.append("")
|
|
541
|
+
lines.append(f"*Auto-generated from {skill_set.source_entries} buildlog entries. "
|
|
542
|
+
f"{skill_set.total_skills} rules extracted.*")
|
|
543
|
+
lines.append("")
|
|
544
|
+
|
|
545
|
+
# Collect all skills, sort by confidence then frequency
|
|
546
|
+
all_skills: list[Skill] = []
|
|
547
|
+
for skills in skill_set.skills.values():
|
|
548
|
+
all_skills.extend(skills)
|
|
549
|
+
|
|
550
|
+
high_conf = [s for s in all_skills if s.confidence == "high"]
|
|
551
|
+
med_conf = [s for s in all_skills if s.confidence == "medium"]
|
|
552
|
+
low_conf = [s for s in all_skills if s.confidence == "low"]
|
|
553
|
+
|
|
554
|
+
if high_conf:
|
|
555
|
+
lines.append("## Core Rules")
|
|
556
|
+
lines.append("")
|
|
557
|
+
for skill in sorted(high_conf, key=lambda s: -s.frequency):
|
|
558
|
+
lines.append(f"- {_to_imperative(skill.rule, 'high')}")
|
|
559
|
+
lines.append("")
|
|
560
|
+
|
|
561
|
+
if med_conf:
|
|
562
|
+
lines.append("## Established Patterns")
|
|
563
|
+
lines.append("")
|
|
564
|
+
for skill in sorted(med_conf, key=lambda s: -s.frequency):
|
|
565
|
+
lines.append(f"- {_to_imperative(skill.rule, 'medium')}")
|
|
566
|
+
lines.append("")
|
|
567
|
+
|
|
568
|
+
if low_conf:
|
|
569
|
+
lines.append("## Considerations")
|
|
570
|
+
lines.append("")
|
|
571
|
+
for skill in sorted(low_conf, key=lambda s: -s.frequency):
|
|
572
|
+
lines.append(f"- {_to_imperative(skill.rule, 'low')}")
|
|
573
|
+
lines.append("")
|
|
574
|
+
|
|
575
|
+
lines.append("---")
|
|
576
|
+
lines.append(f"*Generated: {skill_set.generated_at}*")
|
|
577
|
+
|
|
578
|
+
return "\n".join(lines)
|
|
579
|
+
|
|
580
|
+
|
|
581
|
+
def _format_settings(skill_set: SkillSet) -> str:
|
|
582
|
+
"""Format skills as .claude/settings.json compatible rules array.
|
|
583
|
+
|
|
584
|
+
Only includes high and medium confidence rules by default,
|
|
585
|
+
as these are established enough to influence agent behavior.
|
|
586
|
+
"""
|
|
587
|
+
rules: list[str] = []
|
|
588
|
+
|
|
589
|
+
for skills in skill_set.skills.values():
|
|
590
|
+
for skill in skills:
|
|
591
|
+
# Only include high/medium confidence as agent rules
|
|
592
|
+
if skill.confidence in ("high", "medium"):
|
|
593
|
+
rules.append(skill.rule)
|
|
594
|
+
|
|
595
|
+
# Sort by frequency (embedded in skill order)
|
|
596
|
+
output = {
|
|
597
|
+
"_comment": f"Auto-generated from {skill_set.source_entries} buildlog entries",
|
|
598
|
+
"_generated": skill_set.generated_at,
|
|
599
|
+
"rules": rules,
|
|
600
|
+
}
|
|
601
|
+
|
|
602
|
+
return json.dumps(output, indent=2, ensure_ascii=False)
|
|
603
|
+
|
|
604
|
+
|
|
605
|
+
def format_skills(skill_set: SkillSet, fmt: OutputFormat = "yaml") -> str:
|
|
606
|
+
"""Format skills in the specified format.
|
|
607
|
+
|
|
608
|
+
Args:
|
|
609
|
+
skill_set: The SkillSet to format.
|
|
610
|
+
fmt: Output format - yaml, json, or markdown.
|
|
611
|
+
|
|
612
|
+
Returns:
|
|
613
|
+
Formatted string.
|
|
614
|
+
|
|
615
|
+
Raises:
|
|
616
|
+
ValueError: If format is not recognized.
|
|
617
|
+
"""
|
|
618
|
+
formatters = {
|
|
619
|
+
"yaml": _format_yaml,
|
|
620
|
+
"json": _format_json,
|
|
621
|
+
"markdown": _format_markdown,
|
|
622
|
+
"rules": _format_rules,
|
|
623
|
+
"settings": _format_settings,
|
|
624
|
+
}
|
|
625
|
+
|
|
626
|
+
formatter = formatters.get(fmt)
|
|
627
|
+
if formatter is None:
|
|
628
|
+
raise ValueError(f"Unknown format: {fmt}. Must be one of: {list(formatters.keys())}")
|
|
629
|
+
|
|
630
|
+
return formatter(skill_set)
|