buildlog 0.1.0__py3-none-any.whl → 0.3.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.
Files changed (27) hide show
  1. buildlog/cli.py +46 -23
  2. buildlog/confidence.py +311 -0
  3. buildlog/core/operations.py +11 -15
  4. buildlog/distill.py +3 -3
  5. buildlog/embeddings.py +108 -16
  6. buildlog/mcp/tools.py +4 -4
  7. buildlog/render/__init__.py +34 -11
  8. buildlog/render/claude_md.py +3 -24
  9. buildlog/render/settings_json.py +3 -23
  10. buildlog/render/skill.py +175 -0
  11. buildlog/render/tracking.py +43 -0
  12. buildlog/skills.py +229 -47
  13. buildlog/stats.py +7 -5
  14. {buildlog-0.1.0.data → buildlog-0.3.0.data}/data/share/buildlog/post_gen.py +11 -7
  15. buildlog-0.3.0.dist-info/METADATA +763 -0
  16. buildlog-0.3.0.dist-info/RECORD +30 -0
  17. buildlog-0.1.0.dist-info/METADATA +0 -664
  18. buildlog-0.1.0.dist-info/RECORD +0 -27
  19. {buildlog-0.1.0.data → buildlog-0.3.0.data}/data/share/buildlog/copier.yml +0 -0
  20. {buildlog-0.1.0.data → buildlog-0.3.0.data}/data/share/buildlog/template/buildlog/.gitkeep +0 -0
  21. {buildlog-0.1.0.data → buildlog-0.3.0.data}/data/share/buildlog/template/buildlog/2026-01-01-example.md +0 -0
  22. {buildlog-0.1.0.data → buildlog-0.3.0.data}/data/share/buildlog/template/buildlog/BUILDLOG_SYSTEM.md +0 -0
  23. {buildlog-0.1.0.data → buildlog-0.3.0.data}/data/share/buildlog/template/buildlog/_TEMPLATE.md +0 -0
  24. {buildlog-0.1.0.data → buildlog-0.3.0.data}/data/share/buildlog/template/buildlog/assets/.gitkeep +0 -0
  25. {buildlog-0.1.0.dist-info → buildlog-0.3.0.dist-info}/WHEEL +0 -0
  26. {buildlog-0.1.0.dist-info → buildlog-0.3.0.dist-info}/entry_points.txt +0 -0
  27. {buildlog-0.1.0.dist-info → buildlog-0.3.0.dist-info}/licenses/LICENSE +0 -0
buildlog/embeddings.py CHANGED
@@ -46,20 +46,112 @@ Embedding = list[float]
46
46
  BackendName = Literal["token", "sentence-transformers", "openai"]
47
47
 
48
48
  # Stop words to filter in token-based approach
49
- STOP_WORDS: Final[frozenset[str]] = frozenset({
50
- "a", "an", "the", "is", "are", "was", "were", "be", "been", "being",
51
- "have", "has", "had", "do", "does", "did", "will", "would", "could",
52
- "should", "may", "might", "must", "shall", "can", "need", "dare",
53
- "ought", "used", "to", "of", "in", "for", "on", "with", "at", "by",
54
- "from", "as", "into", "through", "during", "before", "after", "above",
55
- "below", "between", "under", "again", "further", "then", "once",
56
- "here", "there", "when", "where", "why", "how", "all", "each", "few",
57
- "more", "most", "other", "some", "such", "no", "nor", "not", "only",
58
- "own", "same", "so", "than", "too", "very", "just", "also", "now",
59
- "always", "never", "often", "still", "already", "ever",
60
- "it", "its", "this", "that", "these", "those", "i", "you", "he",
61
- "she", "we", "they", "what", "which", "who", "whom", "whose",
62
- })
49
+ STOP_WORDS: Final[frozenset[str]] = frozenset(
50
+ {
51
+ "a",
52
+ "an",
53
+ "the",
54
+ "is",
55
+ "are",
56
+ "was",
57
+ "were",
58
+ "be",
59
+ "been",
60
+ "being",
61
+ "have",
62
+ "has",
63
+ "had",
64
+ "do",
65
+ "does",
66
+ "did",
67
+ "will",
68
+ "would",
69
+ "could",
70
+ "should",
71
+ "may",
72
+ "might",
73
+ "must",
74
+ "shall",
75
+ "can",
76
+ "need",
77
+ "dare",
78
+ "ought",
79
+ "used",
80
+ "to",
81
+ "of",
82
+ "in",
83
+ "for",
84
+ "on",
85
+ "with",
86
+ "at",
87
+ "by",
88
+ "from",
89
+ "as",
90
+ "into",
91
+ "through",
92
+ "during",
93
+ "before",
94
+ "after",
95
+ "above",
96
+ "below",
97
+ "between",
98
+ "under",
99
+ "again",
100
+ "further",
101
+ "then",
102
+ "once",
103
+ "here",
104
+ "there",
105
+ "when",
106
+ "where",
107
+ "why",
108
+ "how",
109
+ "all",
110
+ "each",
111
+ "few",
112
+ "more",
113
+ "most",
114
+ "other",
115
+ "some",
116
+ "such",
117
+ "no",
118
+ "nor",
119
+ "not",
120
+ "only",
121
+ "own",
122
+ "same",
123
+ "so",
124
+ "than",
125
+ "too",
126
+ "very",
127
+ "just",
128
+ "also",
129
+ "now",
130
+ "always",
131
+ "never",
132
+ "often",
133
+ "still",
134
+ "already",
135
+ "ever",
136
+ "it",
137
+ "its",
138
+ "this",
139
+ "that",
140
+ "these",
141
+ "those",
142
+ "i",
143
+ "you",
144
+ "he",
145
+ "she",
146
+ "we",
147
+ "they",
148
+ "what",
149
+ "which",
150
+ "who",
151
+ "whom",
152
+ "whose",
153
+ }
154
+ )
63
155
 
64
156
  # Common synonyms for normalization
65
157
  SYNONYMS: Final[dict[str, str]] = {
@@ -249,7 +341,7 @@ class SentenceTransformerBackend(EmbeddingBackend):
249
341
  "Install with: pip install buildlog[embeddings]"
250
342
  ) from e
251
343
 
252
- self._model = SentenceTransformer(self._model_name)
344
+ self._model = SentenceTransformer(self._model_name) # type: ignore[assignment]
253
345
 
254
346
  return self._model
255
347
 
@@ -298,7 +390,7 @@ class OpenAIBackend(EmbeddingBackend):
298
390
  "Install with: pip install openai"
299
391
  ) from e
300
392
 
301
- self._client = openai.OpenAI()
393
+ self._client = openai.OpenAI() # type: ignore[assignment]
302
394
 
303
395
  return self._client
304
396
 
buildlog/mcp/tools.py CHANGED
@@ -39,17 +39,17 @@ def buildlog_status(
39
39
 
40
40
  def buildlog_promote(
41
41
  skill_ids: list[str],
42
- target: Literal["claude_md", "settings_json"] = "claude_md",
42
+ target: Literal["claude_md", "settings_json", "skill"] = "claude_md",
43
43
  buildlog_dir: str = "buildlog",
44
44
  ) -> dict:
45
45
  """Promote skills to your agent's rules.
46
46
 
47
- Writes selected skills to CLAUDE.md or .claude/settings.json
48
- so your AI agent will follow these patterns.
47
+ Writes selected skills to CLAUDE.md, .claude/settings.json, or
48
+ .claude/skills/buildlog-learned/SKILL.md (Anthropic Agent Skills format).
49
49
 
50
50
  Args:
51
51
  skill_ids: List of skill IDs to promote (e.g., ["arch-b0fcb62a1e"])
52
- target: Where to write rules ("claude_md" or "settings_json")
52
+ target: Where to write rules ("claude_md", "settings_json", or "skill")
53
53
  buildlog_dir: Path to buildlog directory
54
54
 
55
55
  Returns:
@@ -3,39 +3,62 @@
3
3
  from __future__ import annotations
4
4
 
5
5
  from pathlib import Path
6
- from typing import Literal
6
+ from typing import TYPE_CHECKING, Literal
7
7
 
8
8
  from buildlog.render.base import RenderTarget
9
9
  from buildlog.render.claude_md import ClaudeMdRenderer
10
10
  from buildlog.render.settings_json import SettingsJsonRenderer
11
+ from buildlog.render.skill import SkillRenderer
12
+
13
+ if TYPE_CHECKING:
14
+ from typing import Any
11
15
 
12
16
  __all__ = [
13
17
  "RenderTarget",
14
18
  "ClaudeMdRenderer",
15
19
  "SettingsJsonRenderer",
20
+ "SkillRenderer",
16
21
  "get_renderer",
22
+ "RENDERERS",
17
23
  ]
18
24
 
25
+ # Registry of available renderers
26
+ # Using RenderTarget Protocol allows easy extension without modifying types
27
+ RENDERERS: dict[str, type[RenderTarget]] = {
28
+ "claude_md": ClaudeMdRenderer,
29
+ "settings_json": SettingsJsonRenderer,
30
+ "skill": SkillRenderer,
31
+ }
32
+
19
33
 
20
34
  def get_renderer(
21
- target: Literal["claude_md", "settings_json"],
35
+ target: Literal["claude_md", "settings_json", "skill"],
22
36
  path: Path | None = None,
23
- ) -> ClaudeMdRenderer | SettingsJsonRenderer:
37
+ **kwargs: Any,
38
+ ) -> RenderTarget:
24
39
  """Get renderer for target.
25
40
 
26
41
  Args:
27
- target: Target format - "claude_md" or "settings_json".
42
+ target: Target format - "claude_md", "settings_json", or "skill".
28
43
  path: Optional custom path for the target file.
44
+ **kwargs: Additional arguments passed to the renderer constructor.
45
+ Common kwargs (accepted by all renderers):
46
+ - tracking_path: Path to promoted.json for tracking promoted IDs.
47
+ Skill-specific kwargs:
48
+ - skill_name: Name of the skill directory (default: "buildlog-learned").
49
+ Must not contain path separators.
29
50
 
30
51
  Returns:
31
- Renderer instance.
52
+ Renderer instance implementing RenderTarget protocol.
32
53
 
33
54
  Raises:
34
55
  ValueError: If target is not recognized.
35
56
  """
36
- if target == "claude_md":
37
- return ClaudeMdRenderer(path=path)
38
- elif target == "settings_json":
39
- return SettingsJsonRenderer(path=path)
40
- else:
41
- raise ValueError(f"Unknown render target: {target}. Must be 'claude_md' or 'settings_json'")
57
+ if target not in RENDERERS:
58
+ available = ", ".join(f"'{k}'" for k in RENDERERS.keys())
59
+ raise ValueError(
60
+ f"Unknown render target: '{target}'. Must be one of: {available}"
61
+ )
62
+
63
+ renderer_cls = RENDERERS[target]
64
+ return renderer_cls(path=path, **kwargs) # type: ignore[call-arg]
@@ -2,11 +2,11 @@
2
2
 
3
3
  from __future__ import annotations
4
4
 
5
- import json
6
5
  from datetime import datetime
7
6
  from pathlib import Path
8
7
  from typing import TYPE_CHECKING
9
8
 
9
+ from buildlog.render.tracking import track_promoted
10
10
  from buildlog.skills import _to_imperative
11
11
 
12
12
  if TYPE_CHECKING:
@@ -79,28 +79,7 @@ class ClaudeMdRenderer:
79
79
  else:
80
80
  self.path.write_text(content)
81
81
 
82
- # Track promoted skill IDs
83
- self._track_promoted(skills)
82
+ # Track promoted skill IDs using shared utility
83
+ track_promoted(skills, self.tracking_path)
84
84
 
85
85
  return f"Appended {len(skills)} rules to {self.path}"
86
-
87
- def _track_promoted(self, skills: list[Skill]) -> None:
88
- """Track which skills have been promoted."""
89
- self.tracking_path.parent.mkdir(parents=True, exist_ok=True)
90
-
91
- # Load existing tracking data (handle corrupt JSON)
92
- tracking = {"skill_ids": [], "promoted_at": {}}
93
- if self.tracking_path.exists():
94
- try:
95
- tracking = json.loads(self.tracking_path.read_text())
96
- except json.JSONDecodeError:
97
- pass # Start fresh if corrupted
98
-
99
- # Add new skill IDs
100
- now = datetime.now().isoformat()
101
- for skill in skills:
102
- if skill.id not in tracking["skill_ids"]:
103
- tracking["skill_ids"].append(skill.id)
104
- tracking["promoted_at"][skill.id] = now
105
-
106
- self.tracking_path.write_text(json.dumps(tracking, indent=2))
@@ -7,6 +7,7 @@ from datetime import datetime
7
7
  from pathlib import Path
8
8
  from typing import TYPE_CHECKING
9
9
 
10
+ from buildlog.render.tracking import track_promoted
10
11
  from buildlog.skills import _to_imperative
11
12
 
12
13
  if TYPE_CHECKING:
@@ -69,28 +70,7 @@ class SettingsJsonRenderer:
69
70
  # Write back
70
71
  self.path.write_text(json.dumps(settings, indent=2))
71
72
 
72
- # Track promoted skill IDs
73
- self._track_promoted(skills)
73
+ # Track promoted skill IDs using shared utility
74
+ track_promoted(skills, self.tracking_path)
74
75
 
75
76
  return f"Added {added} rules to {self.path} ({len(skills) - added} duplicates skipped)"
76
-
77
- def _track_promoted(self, skills: list[Skill]) -> None:
78
- """Track which skills have been promoted."""
79
- self.tracking_path.parent.mkdir(parents=True, exist_ok=True)
80
-
81
- # Load existing tracking data (handle corrupt JSON)
82
- tracking = {"skill_ids": [], "promoted_at": {}}
83
- if self.tracking_path.exists():
84
- try:
85
- tracking = json.loads(self.tracking_path.read_text())
86
- except json.JSONDecodeError:
87
- pass # Start fresh if corrupted
88
-
89
- # Add new skill IDs
90
- now = datetime.now().isoformat()
91
- for skill in skills:
92
- if skill.id not in tracking["skill_ids"]:
93
- tracking["skill_ids"].append(skill.id)
94
- tracking["promoted_at"][skill.id] = now
95
-
96
- self.tracking_path.write_text(json.dumps(tracking, indent=2))
@@ -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))