buildlog 0.1.0__py3-none-any.whl → 0.2.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,175 @@
1
+ """Render skills to Anthropic Agent Skills format.
2
+
3
+ Creates .claude/skills/buildlog-learned/SKILL.md that can be loaded
4
+ on-demand by Claude Code and other Anthropic tools.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ from datetime import datetime
10
+ from pathlib import Path
11
+ from typing import TYPE_CHECKING
12
+
13
+ from buildlog.render.tracking import track_promoted
14
+
15
+ if TYPE_CHECKING:
16
+ from buildlog.skills import Skill
17
+
18
+
19
+ class SkillRenderer:
20
+ """Creates .claude/skills/buildlog-learned/SKILL.md
21
+
22
+ This renderer produces Anthropic Agent Skills format, which allows
23
+ for on-demand loading of project-specific patterns by Claude.
24
+ """
25
+
26
+ def __init__(
27
+ self,
28
+ path: Path | None = None,
29
+ tracking_path: Path | None = None,
30
+ skill_name: str = "buildlog-learned",
31
+ ):
32
+ """Initialize renderer.
33
+
34
+ Args:
35
+ path: Path to SKILL.md file. Defaults to .claude/skills/{skill_name}/SKILL.md.
36
+ tracking_path: Path to promoted.json tracking file.
37
+ Defaults to .buildlog/promoted.json.
38
+ skill_name: Name of the skill directory. Defaults to "buildlog-learned".
39
+ Must not contain path separators or parent references.
40
+
41
+ Raises:
42
+ ValueError: If skill_name contains path traversal characters.
43
+ """
44
+ # Security: Validate skill_name to prevent path traversal
45
+ if "/" in skill_name or "\\" in skill_name or ".." in skill_name:
46
+ raise ValueError(
47
+ f"Invalid skill_name: {skill_name!r}. "
48
+ "Must not contain path separators or '..'."
49
+ )
50
+ self.skill_name = skill_name
51
+ self.path = path or Path(f".claude/skills/{skill_name}/SKILL.md")
52
+ self.tracking_path = tracking_path or Path(".buildlog/promoted.json")
53
+
54
+ def render(self, skills: list[Skill]) -> str:
55
+ """Render skills to SKILL.md format.
56
+
57
+ Args:
58
+ skills: List of skills to render.
59
+
60
+ Returns:
61
+ Confirmation message describing what was written.
62
+ """
63
+ if not skills:
64
+ return "No skills to promote"
65
+
66
+ # Group by confidence, then category
67
+ by_confidence: dict[str, dict[str, list[Skill]]] = {
68
+ "high": {},
69
+ "medium": {},
70
+ "low": {},
71
+ }
72
+ for skill in skills:
73
+ conf = skill.confidence
74
+ cat = skill.category
75
+ by_confidence[conf].setdefault(cat, []).append(skill)
76
+
77
+ # Build SKILL.md content
78
+ categories = sorted(set(s.category for s in skills))
79
+ category_display = ", ".join(self._category_title(c) for c in categories)
80
+
81
+ lines = [
82
+ "---",
83
+ f"name: {self.skill_name}",
84
+ f"description: Project-specific patterns learned from development history. "
85
+ f"Use when writing code, making architectural decisions, reviewing PRs, "
86
+ f"or ensuring consistency. Contains {len(skills)} rules across "
87
+ f"{category_display}.",
88
+ "---",
89
+ "",
90
+ "# Learned Patterns",
91
+ "",
92
+ f"*{len(skills)} rules extracted from buildlog entries on "
93
+ f"{datetime.now().strftime('%Y-%m-%d')}*",
94
+ "",
95
+ ]
96
+
97
+ # High confidence = Must Follow
98
+ if by_confidence["high"]:
99
+ lines.extend(
100
+ self._render_confidence_section(
101
+ "Must Follow (High Confidence)",
102
+ "These patterns have been reinforced multiple times.",
103
+ by_confidence["high"],
104
+ )
105
+ )
106
+
107
+ # Medium confidence = Should Consider
108
+ if by_confidence["medium"]:
109
+ lines.extend(
110
+ self._render_confidence_section(
111
+ "Should Consider (Medium Confidence)",
112
+ "These patterns appear frequently but may have exceptions.",
113
+ by_confidence["medium"],
114
+ )
115
+ )
116
+
117
+ # Low confidence = Worth Knowing
118
+ if by_confidence["low"]:
119
+ lines.extend(
120
+ self._render_confidence_section(
121
+ "Worth Knowing (Low Confidence)",
122
+ "Emerging patterns worth being aware of.",
123
+ by_confidence["low"],
124
+ )
125
+ )
126
+
127
+ content = "\n".join(lines)
128
+
129
+ # Write file
130
+ self.path.parent.mkdir(parents=True, exist_ok=True)
131
+ self.path.write_text(content)
132
+
133
+ # Track promoted using shared utility
134
+ track_promoted(skills, self.tracking_path)
135
+
136
+ return f"Created skill at {self.path}"
137
+
138
+ def _category_title(self, category: str) -> str:
139
+ """Convert category slug to display title."""
140
+ titles = {
141
+ "architectural": "Architectural",
142
+ "workflow": "Workflow",
143
+ "tool_usage": "Tool Usage",
144
+ "domain_knowledge": "Domain Knowledge",
145
+ }
146
+ return titles.get(category, category.replace("_", " ").title())
147
+
148
+ def _render_confidence_section(
149
+ self,
150
+ title: str,
151
+ description: str,
152
+ by_category: dict[str, list[Skill]],
153
+ ) -> list[str]:
154
+ """Render a confidence-level section.
155
+
156
+ Args:
157
+ title: Section title (e.g., "Must Follow (High Confidence)").
158
+ description: Description of what this confidence level means.
159
+ by_category: Skills grouped by category.
160
+
161
+ Returns:
162
+ List of markdown lines for this section.
163
+ """
164
+ lines = [f"## {title}", "", description, ""]
165
+
166
+ for category, cat_skills in sorted(by_category.items()):
167
+ cat_title = self._category_title(category)
168
+ lines.append(f"### {cat_title}")
169
+ lines.append("")
170
+ for skill in cat_skills:
171
+ # Don't add confidence prefix - section already indicates confidence
172
+ lines.append(f"- {skill.rule}")
173
+ lines.append("")
174
+
175
+ return lines
@@ -0,0 +1,43 @@
1
+ """Shared tracking utilities for render adapters."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ from datetime import datetime
7
+ from pathlib import Path
8
+ from typing import TYPE_CHECKING
9
+
10
+ if TYPE_CHECKING:
11
+ from buildlog.skills import Skill
12
+
13
+ __all__ = ["track_promoted"]
14
+
15
+
16
+ def track_promoted(skills: list[Skill], tracking_path: Path) -> None:
17
+ """Track which skills have been promoted.
18
+
19
+ Writes skill IDs and promotion timestamps to a JSON file.
20
+ Handles corrupt JSON gracefully by starting fresh.
21
+
22
+ Args:
23
+ skills: Skills that were promoted.
24
+ tracking_path: Path to the tracking JSON file.
25
+ """
26
+ tracking_path.parent.mkdir(parents=True, exist_ok=True)
27
+
28
+ # Load existing tracking data (handle corrupt JSON)
29
+ tracking: dict = {"skill_ids": [], "promoted_at": {}}
30
+ if tracking_path.exists():
31
+ try:
32
+ tracking = json.loads(tracking_path.read_text())
33
+ except json.JSONDecodeError:
34
+ pass # Start fresh if corrupted
35
+
36
+ # Add new skill IDs
37
+ now = datetime.now().isoformat()
38
+ for skill in skills:
39
+ if skill.id not in tracking["skill_ids"]:
40
+ tracking["skill_ids"].append(skill.id)
41
+ tracking["promoted_at"][skill.id] = now
42
+
43
+ tracking_path.write_text(json.dumps(tracking, indent=2))
buildlog/skills.py CHANGED
@@ -19,7 +19,7 @@ import json
19
19
  import logging
20
20
  import re
21
21
  from dataclasses import dataclass, field
22
- from datetime import UTC, date, datetime
22
+ from datetime import date, datetime, timezone
23
23
  from pathlib import Path
24
24
  from typing import Final, Literal, TypedDict
25
25
 
@@ -134,8 +134,6 @@ def _generate_skill_id(category: str, rule: str) -> str:
134
134
  return f"{prefix}-{rule_hash}"
135
135
 
136
136
 
137
-
138
-
139
137
  def _calculate_confidence(
140
138
  frequency: int,
141
139
  most_recent_date: date | None,
@@ -164,7 +162,10 @@ def _calculate_confidence(
164
162
  if most_recent_date:
165
163
  recency_days = (reference_date - most_recent_date).days
166
164
 
167
- if frequency >= HIGH_CONFIDENCE_FREQUENCY and recency_days < HIGH_CONFIDENCE_RECENCY_DAYS:
165
+ if (
166
+ frequency >= HIGH_CONFIDENCE_FREQUENCY
167
+ and recency_days < HIGH_CONFIDENCE_RECENCY_DAYS
168
+ ):
168
169
  return "high"
169
170
  elif frequency >= MEDIUM_CONFIDENCE_FREQUENCY:
170
171
  return "medium"
@@ -179,12 +180,44 @@ def _extract_tags(rule: str) -> list[str]:
179
180
  """
180
181
  # Common tech/concept terms to extract as tags
181
182
  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",
183
+ "api",
184
+ "http",
185
+ "json",
186
+ "yaml",
187
+ "sql",
188
+ "database",
189
+ "cache",
190
+ "redis",
191
+ "supabase",
192
+ "postgres",
193
+ "mongodb",
194
+ "git",
195
+ "docker",
196
+ "kubernetes",
197
+ "aws",
198
+ "gcp",
199
+ "azure",
200
+ "react",
201
+ "python",
202
+ "typescript",
203
+ "javascript",
204
+ "rust",
205
+ "go",
206
+ "test",
207
+ "testing",
208
+ "ci",
209
+ "cd",
210
+ "deploy",
211
+ "error",
212
+ "retry",
213
+ "timeout",
214
+ "auth",
215
+ "jwt",
216
+ "oauth",
217
+ "plugin",
218
+ "middleware",
219
+ "async",
220
+ "sync",
188
221
  }
189
222
 
190
223
  # Word variants that map to canonical tags
@@ -301,7 +334,11 @@ def generate_skills(
301
334
  result = distill_all(buildlog_dir, since=since_date)
302
335
 
303
336
  # Get embedding backend
304
- backend = get_backend(embedding_backend) if embedding_backend else get_default_backend()
337
+ backend = (
338
+ get_backend(embedding_backend) # type: ignore[arg-type]
339
+ if embedding_backend
340
+ else get_default_backend()
341
+ )
305
342
  logger.info("Using embedding backend: %s", backend.name)
306
343
 
307
344
  skills_by_category: dict[str, list[Skill]] = {}
@@ -331,7 +368,7 @@ def generate_skills(
331
368
  skills_by_category[category] = skills
332
369
 
333
370
  return SkillSet(
334
- generated_at=datetime.now(UTC).isoformat().replace("+00:00", "Z"),
371
+ generated_at=datetime.now(timezone.utc).isoformat().replace("+00:00", "Z"),
335
372
  source_entries=result.entry_count,
336
373
  skills=skills_by_category,
337
374
  )
@@ -347,7 +384,9 @@ def _format_yaml(skill_set: SkillSet) -> str:
347
384
  ) from e
348
385
 
349
386
  data = skill_set.to_dict()
350
- return yaml.dump(data, default_flow_style=False, allow_unicode=True, sort_keys=False)
387
+ return yaml.dump(
388
+ data, default_flow_style=False, allow_unicode=True, sort_keys=False
389
+ )
351
390
 
352
391
 
353
392
  def _format_json(skill_set: SkillSet) -> str:
@@ -361,8 +400,10 @@ def _format_markdown(skill_set: SkillSet) -> str:
361
400
 
362
401
  lines.append("## Learned Skills")
363
402
  lines.append("")
364
- lines.append(f"Based on {skill_set.source_entries} buildlog entries, "
365
- f"{skill_set.total_skills} actionable skills have emerged:")
403
+ lines.append(
404
+ f"Based on {skill_set.source_entries} buildlog entries, "
405
+ f"{skill_set.total_skills} actionable skills have emerged:"
406
+ )
366
407
  lines.append("")
367
408
 
368
409
  category_titles = {
@@ -384,7 +425,9 @@ def _format_markdown(skill_set: SkillSet) -> str:
384
425
  confidence_badge = {"high": "🟢", "medium": "🟡", "low": "⚪"}.get(
385
426
  skill.confidence, ""
386
427
  )
387
- freq_text = f"seen {skill.frequency}x" if skill.frequency > 1 else "seen once"
428
+ freq_text = (
429
+ f"seen {skill.frequency}x" if skill.frequency > 1 else "seen once"
430
+ )
388
431
  lines.append(f"- {confidence_badge} **{skill.rule}** ({freq_text})")
389
432
 
390
433
  lines.append("")
@@ -397,16 +440,23 @@ def _format_markdown(skill_set: SkillSet) -> str:
397
440
 
398
441
  # Pre-compiled patterns for _to_imperative (module-level for efficiency)
399
442
  _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",
443
+ re.compile(p)
444
+ for p in (
445
+ r"\bdon't\b",
446
+ r"\bdo not\b",
447
+ r"\bnever\b",
448
+ r"\bavoid\b",
449
+ r"\bstop\b",
450
+ r"\bshouldn't\b",
451
+ r"\bshould not\b",
403
452
  )
404
453
  )
405
454
 
406
455
  # Comparison patterns - intentionally narrow to avoid false positives
407
456
  # "over" alone matches "all over", "game over" etc. so we require context
408
457
  _COMPARISON_PATTERNS = tuple(
409
- re.compile(p) for p in (
458
+ re.compile(p)
459
+ for p in (
410
460
  r"\binstead of\b",
411
461
  r"\brather than\b",
412
462
  r"\bbetter than\b",
@@ -416,10 +466,22 @@ _COMPARISON_PATTERNS = tuple(
416
466
 
417
467
  # Verbs that need -ing form when following "Avoid" or bare "Prefer"
418
468
  _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",
469
+ "use": "using",
470
+ "run": "running",
471
+ "make": "making",
472
+ "write": "writing",
473
+ "read": "reading",
474
+ "put": "putting",
475
+ "get": "getting",
476
+ "set": "setting",
477
+ "add": "adding",
478
+ "create": "creating",
479
+ "delete": "deleting",
480
+ "call": "calling",
481
+ "pass": "passing",
482
+ "send": "sending",
483
+ "store": "storing",
484
+ "cache": "caching",
423
485
  }
424
486
 
425
487
 
@@ -456,8 +518,14 @@ def _to_imperative(rule: str, confidence: ConfidenceLevel) -> str:
456
518
 
457
519
  # Already has a confidence modifier - just capitalize and return
458
520
  confidence_modifiers = (
459
- "always", "never", "prefer", "avoid", "consider", "remember",
460
- "don't", "do not",
521
+ "always",
522
+ "never",
523
+ "prefer",
524
+ "avoid",
525
+ "consider",
526
+ "remember",
527
+ "don't",
528
+ "do not",
461
529
  )
462
530
  if any(rule_lower.startswith(word) for word in confidence_modifiers):
463
531
  return rule[0].upper() + rule[1:]
@@ -485,16 +553,23 @@ def _to_imperative(rule: str, confidence: ConfidenceLevel) -> str:
485
553
  # Clean up the rule for prefixing
486
554
  # Remove leading "should" type words (order matters - longer first)
487
555
  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 ",
556
+ "you shouldn't ",
557
+ "we shouldn't ",
558
+ "shouldn't ",
559
+ "you should not ",
560
+ "we should not ",
561
+ "should not ",
562
+ "you should ",
563
+ "we should ",
564
+ "should ",
565
+ "it's better to ",
566
+ "it is better to ",
492
567
  ]
493
568
  cleaned = rule
494
569
  cleaned_lower = rule_lower
495
570
  for cleaner in cleaners:
496
571
  if cleaned_lower.startswith(cleaner):
497
- cleaned = cleaned[len(cleaner):]
572
+ cleaned = cleaned[len(cleaner) :]
498
573
  cleaned_lower = cleaned.lower()
499
574
  break
500
575
 
@@ -505,10 +580,12 @@ def _to_imperative(rule: str, confidence: ConfidenceLevel) -> str:
505
580
 
506
581
  # Avoid double words: "Avoid avoid using..." -> "Avoid using..."
507
582
  prefix_lower = prefix.lower()
508
- if cleaned_lower.startswith(prefix_lower + " ") or cleaned_lower.startswith(prefix_lower + "ing "):
583
+ if cleaned_lower.startswith(prefix_lower + " ") or cleaned_lower.startswith(
584
+ prefix_lower + "ing "
585
+ ):
509
586
  first_space = cleaned.find(" ")
510
587
  if first_space > 0:
511
- cleaned = cleaned[first_space + 1:]
588
+ cleaned = cleaned[first_space + 1 :]
512
589
  cleaned_lower = cleaned.lower()
513
590
 
514
591
  # For "Avoid" and bare "Prefer", convert leading verbs to gerund form
@@ -518,7 +595,7 @@ def _to_imperative(rule: str, confidence: ConfidenceLevel) -> str:
518
595
  first_word = cleaned_lower.split()[0] if cleaned_lower else ""
519
596
  if first_word in _VERB_TO_GERUND:
520
597
  gerund = _VERB_TO_GERUND[first_word]
521
- cleaned = gerund + cleaned[len(first_word):]
598
+ cleaned = gerund + cleaned[len(first_word) :]
522
599
  cleaned_lower = cleaned.lower()
523
600
 
524
601
  # Lowercase first char if we're adding a prefix (but not for gerunds which are already lower)
@@ -538,8 +615,10 @@ def _format_rules(skill_set: SkillSet) -> str:
538
615
 
539
616
  lines.append("# Project Rules")
540
617
  lines.append("")
541
- lines.append(f"*Auto-generated from {skill_set.source_entries} buildlog entries. "
542
- f"{skill_set.total_skills} rules extracted.*")
618
+ lines.append(
619
+ f"*Auto-generated from {skill_set.source_entries} buildlog entries. "
620
+ f"{skill_set.total_skills} rules extracted.*"
621
+ )
543
622
  lines.append("")
544
623
 
545
624
  # Collect all skills, sort by confidence then frequency
@@ -625,6 +704,8 @@ def format_skills(skill_set: SkillSet, fmt: OutputFormat = "yaml") -> str:
625
704
 
626
705
  formatter = formatters.get(fmt)
627
706
  if formatter is None:
628
- raise ValueError(f"Unknown format: {fmt}. Must be one of: {list(formatters.keys())}")
707
+ raise ValueError(
708
+ f"Unknown format: {fmt}. Must be one of: {list(formatters.keys())}"
709
+ )
629
710
 
630
711
  return formatter(skill_set)
buildlog/stats.py CHANGED
@@ -12,7 +12,7 @@ __all__ = [
12
12
  import json
13
13
  import logging
14
14
  from dataclasses import dataclass, field
15
- from datetime import UTC, date, datetime, timedelta
15
+ from datetime import date, datetime, timedelta, timezone
16
16
  from itertools import takewhile
17
17
  from pathlib import Path
18
18
  from typing import Final, NamedTuple, TypedDict
@@ -315,7 +315,9 @@ def calculate_stats(
315
315
  # Parse all entries using functional map/filter pattern
316
316
  parsed_or_none = [
317
317
  _parse_entry(entry_path, date_str)
318
- for entry_path, date_str in iter_buildlog_entries(buildlog_dir, since=since_date)
318
+ for entry_path, date_str in iter_buildlog_entries(
319
+ buildlog_dir, since=since_date
320
+ )
319
321
  ]
320
322
  entries = [e for e in parsed_or_none if e is not None]
321
323
 
@@ -326,8 +328,8 @@ def calculate_stats(
326
328
 
327
329
  entry_dates = [e.entry_date for e in entries if e.entry_date]
328
330
 
329
- this_week = sum(1 for d in entry_dates if d and d >= week_ago)
330
- this_month = sum(1 for d in entry_dates if d and d >= month_start)
331
+ this_week = sum(1 for d in entry_dates if d and d >= week_ago) # type: ignore[misc]
332
+ this_month = sum(1 for d in entry_dates if d and d >= month_start) # type: ignore[misc]
331
333
 
332
334
  with_improvements = sum(1 for e in entries if e.has_improvements)
333
335
  coverage_percent = int((with_improvements / len(entries) * 100) if entries else 0)
@@ -351,7 +353,7 @@ def calculate_stats(
351
353
  warnings.insert(0, "No buildlog entries found")
352
354
 
353
355
  return BuildlogStats(
354
- generated_at=datetime.now(UTC).isoformat().replace("+00:00", "Z"),
356
+ generated_at=datetime.now(timezone.utc).isoformat().replace("+00:00", "Z"),
355
357
  entries=EntryStats(
356
358
  total=len(entries),
357
359
  this_week=this_week,