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 CHANGED
@@ -9,7 +9,7 @@ from pathlib import Path
9
9
  import click
10
10
 
11
11
  from buildlog.distill import CATEGORIES, distill_all, format_output
12
- from buildlog.skills import generate_skills, format_skills
12
+ from buildlog.skills import format_skills, generate_skills
13
13
  from buildlog.stats import calculate_stats, format_dashboard, format_json
14
14
 
15
15
 
@@ -27,6 +27,7 @@ def get_template_dir() -> Path | None:
27
27
 
28
28
  # 2. Check installed location (site-packages/../share/buildlog)
29
29
  import sysconfig
30
+
30
31
  data_dir = Path(sysconfig.get_path("data")) / "share" / "buildlog"
31
32
  if (data_dir / "copier.yml").exists():
32
33
  return data_dir
@@ -67,13 +68,16 @@ def init(no_claude_md: bool):
67
68
  try:
68
69
  subprocess.run(
69
70
  [
70
- sys.executable, "-m", "copier", "copy",
71
+ sys.executable,
72
+ "-m",
73
+ "copier",
74
+ "copy",
71
75
  "--trust",
72
76
  *(["--data", "update_claude_md=false"] if no_claude_md else []),
73
77
  str(template_dir),
74
- "."
78
+ ".",
75
79
  ],
76
- check=True
80
+ check=True,
77
81
  )
78
82
  except subprocess.CalledProcessError:
79
83
  click.echo("Failed to initialize buildlog.", err=True)
@@ -84,13 +88,16 @@ def init(no_claude_md: bool):
84
88
  try:
85
89
  subprocess.run(
86
90
  [
87
- sys.executable, "-m", "copier", "copy",
91
+ sys.executable,
92
+ "-m",
93
+ "copier",
94
+ "copy",
88
95
  "--trust",
89
96
  *(["--data", "update_claude_md=false"] if no_claude_md else []),
90
97
  "gh:Peleke/buildlog-template",
91
- "."
98
+ ".",
92
99
  ],
93
- check=True
100
+ check=True,
94
101
  )
95
102
  except subprocess.CalledProcessError:
96
103
  click.echo("Failed to initialize buildlog.", err=True)
@@ -102,7 +109,9 @@ def init(no_claude_md: bool):
102
109
 
103
110
  @main.command()
104
111
  @click.argument("slug")
105
- @click.option("--date", "-d", "entry_date", default=None, help="Date for entry (YYYY-MM-DD)")
112
+ @click.option(
113
+ "--date", "-d", "entry_date", default=None, help="Date for entry (YYYY-MM-DD)"
114
+ )
106
115
  def new(slug: str, entry_date: str | None):
107
116
  """Create a new buildlog entry.
108
117
 
@@ -121,7 +130,9 @@ def new(slug: str, entry_date: str | None):
121
130
  raise SystemExit(1)
122
131
 
123
132
  if not template_file.exists():
124
- click.echo("No _TEMPLATE.md found in buildlog/. Run 'buildlog init' first.", err=True)
133
+ click.echo(
134
+ "No _TEMPLATE.md found in buildlog/. Run 'buildlog init' first.", err=True
135
+ )
125
136
  raise SystemExit(1)
126
137
 
127
138
  # Determine date
@@ -170,8 +181,7 @@ def list():
170
181
  raise SystemExit(1)
171
182
 
172
183
  entries = sorted(
173
- buildlog_dir.glob("20??-??-??-*.md"),
174
- reverse=True # Most recent first
184
+ buildlog_dir.glob("20??-??-??-*.md"), reverse=True # Most recent first
175
185
  )
176
186
 
177
187
  if not entries:
@@ -183,7 +193,9 @@ def list():
183
193
  # Extract title from first line if possible
184
194
  try:
185
195
  first_line = entry.read_text().split("\n")[0]
186
- title = first_line.replace("# Build Journal: ", "").replace("# ", "").strip()
196
+ title = (
197
+ first_line.replace("# Build Journal: ", "").replace("# ", "").strip()
198
+ )
187
199
  if title == "[TITLE]":
188
200
  title = "(untitled)"
189
201
  except Exception:
@@ -202,21 +214,23 @@ def update():
202
214
  click.echo("Updating from local template...")
203
215
  try:
204
216
  subprocess.run(
205
- [sys.executable, "-m", "copier", "update", "--trust"],
206
- check=True
217
+ [sys.executable, "-m", "copier", "update", "--trust"], check=True
207
218
  )
208
219
  except subprocess.CalledProcessError:
209
- click.echo("Failed to update. Try running 'copier update' directly.", err=True)
220
+ click.echo(
221
+ "Failed to update. Try running 'copier update' directly.", err=True
222
+ )
210
223
  raise SystemExit(1)
211
224
  else:
212
225
  click.echo("Updating from GitHub...")
213
226
  try:
214
227
  subprocess.run(
215
- [sys.executable, "-m", "copier", "update", "--trust"],
216
- check=True
228
+ [sys.executable, "-m", "copier", "update", "--trust"], check=True
217
229
  )
218
230
  except subprocess.CalledProcessError:
219
- click.echo("Failed to update. Try running 'copier update' directly.", err=True)
231
+ click.echo(
232
+ "Failed to update. Try running 'copier update' directly.", err=True
233
+ )
220
234
  raise SystemExit(1)
221
235
 
222
236
  click.echo("\n✓ buildlog updated!")
@@ -273,7 +287,7 @@ def distill(output: str | None, fmt: str, since: datetime | None, category: str
273
287
 
274
288
  # Format output
275
289
  try:
276
- formatted = format_output(result, fmt)
290
+ formatted = format_output(result, fmt) # type: ignore[arg-type]
277
291
  except ImportError as e:
278
292
  click.echo(str(e), err=True)
279
293
  raise SystemExit(1)
@@ -283,7 +297,9 @@ def distill(output: str | None, fmt: str, since: datetime | None, category: str
283
297
  output_path = Path(output)
284
298
  try:
285
299
  output_path.write_text(formatted, encoding="utf-8")
286
- click.echo(f"Wrote {result.statistics.get('total_patterns', 0)} patterns to {output_path}")
300
+ click.echo(
301
+ f"Wrote {result.statistics.get('total_patterns', 0)} patterns to {output_path}"
302
+ )
287
303
  except Exception as e:
288
304
  click.echo(f"Failed to write output: {e}", err=True)
289
305
  raise SystemExit(1)
@@ -293,8 +309,15 @@ def distill(output: str | None, fmt: str, since: datetime | None, category: str
293
309
 
294
310
  @main.command()
295
311
  @click.option("--json", "output_json", is_flag=True, help="Output as JSON")
296
- @click.option("--detailed", is_flag=True, help="Show detailed breakdown including top sources")
297
- @click.option("--since", "since_date", default=None, help="Only include entries since date (YYYY-MM-DD)")
312
+ @click.option(
313
+ "--detailed", is_flag=True, help="Show detailed breakdown including top sources"
314
+ )
315
+ @click.option(
316
+ "--since",
317
+ "since_date",
318
+ default=None,
319
+ help="Only include entries since date (YYYY-MM-DD)",
320
+ )
298
321
  def stats(output_json: bool, detailed: bool, since_date: str | None):
299
322
  """Show buildlog statistics and analytics.
300
323
 
@@ -409,7 +432,7 @@ def skills(
409
432
 
410
433
  # Format output
411
434
  try:
412
- formatted = format_skills(skill_set, fmt)
435
+ formatted = format_skills(skill_set, fmt) # type: ignore[arg-type]
413
436
  except ImportError as e:
414
437
  click.echo(str(e), err=True)
415
438
  raise SystemExit(1)
@@ -59,7 +59,7 @@ class PromoteResult:
59
59
  """IDs of skills that were promoted."""
60
60
 
61
61
  target: str
62
- """Target format (claude_md or settings_json)."""
62
+ """Target format (claude_md, settings_json, or skill)."""
63
63
 
64
64
  rules_added: int
65
65
  """Number of rules added."""
@@ -192,7 +192,7 @@ def status(
192
192
  confidence_order = {"low": 0, "medium": 1, "high": 2}
193
193
  min_level = confidence_order[min_confidence]
194
194
 
195
- filtered: dict[str, list[dict]] = {}
195
+ filtered: dict[str, list[dict]] = {} # type: ignore[type-arg]
196
196
  by_confidence = {"high": 0, "medium": 0, "low": 0}
197
197
  promotable: list[str] = []
198
198
 
@@ -215,7 +215,7 @@ def status(
215
215
  category_skills.append(skill.to_dict())
216
216
 
217
217
  if category_skills:
218
- filtered[category] = category_skills
218
+ filtered[category] = category_skills # type: ignore[assignment]
219
219
 
220
220
  # Calculate actual total (sum of by_confidence, which excludes rejected)
221
221
  actual_total = sum(by_confidence.values())
@@ -232,7 +232,7 @@ def status(
232
232
  def promote(
233
233
  buildlog_dir: Path,
234
234
  skill_ids: list[str],
235
- target: Literal["claude_md", "settings_json"] = "claude_md",
235
+ target: Literal["claude_md", "settings_json", "skill"] = "claude_md",
236
236
  target_path: Path | None = None,
237
237
  ) -> PromoteResult:
238
238
  """Promote skills to agent rules.
@@ -240,7 +240,7 @@ def promote(
240
240
  Args:
241
241
  buildlog_dir: Path to buildlog directory.
242
242
  skill_ids: List of skill IDs to promote.
243
- target: Where to write rules ("claude_md" or "settings_json").
243
+ target: Where to write rules ("claude_md", "settings_json", or "skill").
244
244
  target_path: Optional custom path for the target file.
245
245
 
246
246
  Returns:
@@ -277,13 +277,8 @@ def promote(
277
277
  # Set up tracking path in buildlog directory
278
278
  tracking_path = _get_promoted_path(buildlog_dir)
279
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)
280
+ # Get renderer using the registry pattern
281
+ renderer = get_renderer(target, path=target_path, tracking_path=tracking_path)
287
282
 
288
283
  message = renderer.render(found_skills)
289
284
 
@@ -372,16 +367,17 @@ def diff(
372
367
  promoted_ids = _load_json_set(_get_promoted_path(buildlog_dir), "skill_ids")
373
368
 
374
369
  # Find unpromoted, unrejected skills
375
- pending: dict[str, list[dict]] = {}
370
+ pending: dict[str, list[dict]] = {} # type: ignore[type-arg]
376
371
  total_pending = 0
377
372
 
378
373
  for category, skill_list in skill_set.skills.items():
379
374
  pending_skills = [
380
- s.to_dict() for s in skill_list
375
+ s.to_dict()
376
+ for s in skill_list
381
377
  if s.id not in rejected_ids and s.id not in promoted_ids
382
378
  ]
383
379
  if pending_skills:
384
- pending[category] = pending_skills
380
+ pending[category] = pending_skills # type: ignore[assignment]
385
381
  total_pending += len(pending_skills)
386
382
 
387
383
  return DiffResult(
buildlog/distill.py CHANGED
@@ -17,7 +17,7 @@ import logging
17
17
  import re
18
18
  from collections.abc import Iterator
19
19
  from dataclasses import dataclass, field
20
- from datetime import UTC, date, datetime
20
+ from datetime import date, datetime, timezone
21
21
  from pathlib import Path
22
22
  from typing import Final, Literal, TypedDict
23
23
 
@@ -81,7 +81,7 @@ class DistillResult:
81
81
  extracted_at: str
82
82
  entry_count: int
83
83
  patterns: dict[str, list[PatternDict]] = field(default_factory=dict)
84
- statistics: StatisticsDict = field(default_factory=dict)
84
+ statistics: StatisticsDict = field(default_factory=dict) # type: ignore[assignment]
85
85
 
86
86
  def to_dict(self) -> DistillResultDict:
87
87
  """Convert to dictionary for JSON/YAML serialization."""
@@ -333,7 +333,7 @@ def distill_all(
333
333
  statistics = _compute_statistics(patterns, by_month)
334
334
 
335
335
  return DistillResult(
336
- extracted_at=datetime.now(UTC).isoformat().replace("+00:00", "Z"),
336
+ extracted_at=datetime.now(timezone.utc).isoformat().replace("+00:00", "Z"),
337
337
  entry_count=entry_count,
338
338
  patterns=patterns,
339
339
  statistics=statistics,
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))