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.
- buildlog/cli.py +46 -23
- buildlog/core/operations.py +11 -15
- buildlog/distill.py +3 -3
- buildlog/embeddings.py +108 -16
- buildlog/mcp/tools.py +4 -4
- buildlog/render/__init__.py +34 -11
- buildlog/render/claude_md.py +3 -24
- buildlog/render/settings_json.py +3 -23
- buildlog/render/skill.py +175 -0
- buildlog/render/tracking.py +43 -0
- buildlog/skills.py +118 -37
- buildlog/stats.py +7 -5
- buildlog-0.2.0.dist-info/METADATA +762 -0
- buildlog-0.2.0.dist-info/RECORD +29 -0
- buildlog-0.1.0.dist-info/METADATA +0 -664
- buildlog-0.1.0.dist-info/RECORD +0 -27
- {buildlog-0.1.0.data → buildlog-0.2.0.data}/data/share/buildlog/copier.yml +0 -0
- {buildlog-0.1.0.data → buildlog-0.2.0.data}/data/share/buildlog/post_gen.py +0 -0
- {buildlog-0.1.0.data → buildlog-0.2.0.data}/data/share/buildlog/template/buildlog/.gitkeep +0 -0
- {buildlog-0.1.0.data → buildlog-0.2.0.data}/data/share/buildlog/template/buildlog/2026-01-01-example.md +0 -0
- {buildlog-0.1.0.data → buildlog-0.2.0.data}/data/share/buildlog/template/buildlog/BUILDLOG_SYSTEM.md +0 -0
- {buildlog-0.1.0.data → buildlog-0.2.0.data}/data/share/buildlog/template/buildlog/_TEMPLATE.md +0 -0
- {buildlog-0.1.0.data → buildlog-0.2.0.data}/data/share/buildlog/template/buildlog/assets/.gitkeep +0 -0
- {buildlog-0.1.0.dist-info → buildlog-0.2.0.dist-info}/WHEEL +0 -0
- {buildlog-0.1.0.dist-info → buildlog-0.2.0.dist-info}/entry_points.txt +0 -0
- {buildlog-0.1.0.dist-info → buildlog-0.2.0.dist-info}/licenses/LICENSE +0 -0
buildlog/render/skill.py
ADDED
|
@@ -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
|
|
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
|
|
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",
|
|
183
|
-
"
|
|
184
|
-
"
|
|
185
|
-
"
|
|
186
|
-
"
|
|
187
|
-
"
|
|
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 =
|
|
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(
|
|
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(
|
|
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(
|
|
365
|
-
|
|
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 =
|
|
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)
|
|
401
|
-
|
|
402
|
-
r"\
|
|
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)
|
|
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",
|
|
420
|
-
"
|
|
421
|
-
"
|
|
422
|
-
"
|
|
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",
|
|
460
|
-
"
|
|
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 ",
|
|
489
|
-
"
|
|
490
|
-
"
|
|
491
|
-
"
|
|
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(
|
|
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(
|
|
542
|
-
|
|
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(
|
|
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
|
|
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(
|
|
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(
|
|
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,
|