buildlog 0.7.0__py3-none-any.whl → 0.9.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 (41) hide show
  1. buildlog/__init__.py +1 -1
  2. buildlog/cli.py +659 -48
  3. buildlog/confidence.py +27 -0
  4. buildlog/core/__init__.py +2 -0
  5. buildlog/core/bandit.py +699 -0
  6. buildlog/core/operations.py +284 -24
  7. buildlog/distill.py +80 -1
  8. buildlog/engine/__init__.py +61 -0
  9. buildlog/engine/bandit.py +23 -0
  10. buildlog/engine/confidence.py +28 -0
  11. buildlog/engine/embeddings.py +28 -0
  12. buildlog/engine/experiments.py +619 -0
  13. buildlog/engine/types.py +31 -0
  14. buildlog/llm.py +508 -0
  15. buildlog/mcp/server.py +10 -6
  16. buildlog/mcp/tools.py +61 -13
  17. buildlog/render/__init__.py +19 -2
  18. buildlog/render/claude_md.py +67 -32
  19. buildlog/render/continue_dev.py +102 -0
  20. buildlog/render/copilot.py +100 -0
  21. buildlog/render/cursor.py +105 -0
  22. buildlog/render/windsurf.py +95 -0
  23. buildlog/seed_engine/__init__.py +2 -0
  24. buildlog/seed_engine/llm_extractor.py +121 -0
  25. buildlog/seed_engine/pipeline.py +45 -1
  26. buildlog/skills.py +69 -6
  27. {buildlog-0.7.0.data → buildlog-0.9.0.data}/data/share/buildlog/copier.yml +0 -4
  28. buildlog-0.9.0.data/data/share/buildlog/template/buildlog/_TEMPLATE_QUICK.md +21 -0
  29. buildlog-0.9.0.dist-info/METADATA +248 -0
  30. buildlog-0.9.0.dist-info/RECORD +55 -0
  31. buildlog-0.7.0.dist-info/METADATA +0 -544
  32. buildlog-0.7.0.dist-info/RECORD +0 -41
  33. {buildlog-0.7.0.data → buildlog-0.9.0.data}/data/share/buildlog/post_gen.py +0 -0
  34. {buildlog-0.7.0.data → buildlog-0.9.0.data}/data/share/buildlog/template/buildlog/.gitkeep +0 -0
  35. {buildlog-0.7.0.data → buildlog-0.9.0.data}/data/share/buildlog/template/buildlog/2026-01-01-example.md +0 -0
  36. {buildlog-0.7.0.data → buildlog-0.9.0.data}/data/share/buildlog/template/buildlog/BUILDLOG_SYSTEM.md +0 -0
  37. {buildlog-0.7.0.data → buildlog-0.9.0.data}/data/share/buildlog/template/buildlog/_TEMPLATE.md +0 -0
  38. {buildlog-0.7.0.data → buildlog-0.9.0.data}/data/share/buildlog/template/buildlog/assets/.gitkeep +0 -0
  39. {buildlog-0.7.0.dist-info → buildlog-0.9.0.dist-info}/WHEEL +0 -0
  40. {buildlog-0.7.0.dist-info → buildlog-0.9.0.dist-info}/entry_points.txt +0 -0
  41. {buildlog-0.7.0.dist-info → buildlog-0.9.0.dist-info}/licenses/LICENSE +0 -0
@@ -7,8 +7,12 @@ 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
+ from buildlog.render.continue_dev import ContinueRenderer
11
+ from buildlog.render.copilot import CopilotRenderer
12
+ from buildlog.render.cursor import CursorRenderer
10
13
  from buildlog.render.settings_json import SettingsJsonRenderer
11
14
  from buildlog.render.skill import SkillRenderer
15
+ from buildlog.render.windsurf import WindsurfRenderer
12
16
 
13
17
  if TYPE_CHECKING:
14
18
  from typing import Any
@@ -18,8 +22,13 @@ __all__ = [
18
22
  "ClaudeMdRenderer",
19
23
  "SettingsJsonRenderer",
20
24
  "SkillRenderer",
25
+ "CursorRenderer",
26
+ "CopilotRenderer",
27
+ "WindsurfRenderer",
28
+ "ContinueRenderer",
21
29
  "get_renderer",
22
30
  "RENDERERS",
31
+ "RENDER_TARGETS",
23
32
  ]
24
33
 
25
34
  # Registry of available renderers
@@ -28,18 +37,26 @@ RENDERERS: dict[str, type[RenderTarget]] = {
28
37
  "claude_md": ClaudeMdRenderer,
29
38
  "settings_json": SettingsJsonRenderer,
30
39
  "skill": SkillRenderer,
40
+ "cursor": CursorRenderer,
41
+ "copilot": CopilotRenderer,
42
+ "windsurf": WindsurfRenderer,
43
+ "continue_dev": ContinueRenderer,
31
44
  }
32
45
 
46
+ # Valid target names (useful for CLI choices and type hints)
47
+ RENDER_TARGETS = list(RENDERERS.keys())
48
+
33
49
 
34
50
  def get_renderer(
35
- target: Literal["claude_md", "settings_json", "skill"],
51
+ target: str,
36
52
  path: Path | None = None,
37
53
  **kwargs: Any,
38
54
  ) -> RenderTarget:
39
55
  """Get renderer for target.
40
56
 
41
57
  Args:
42
- target: Target format - "claude_md", "settings_json", or "skill".
58
+ target: Target format - one of: claude_md, settings_json, skill,
59
+ cursor, copilot, windsurf, continue_dev.
43
60
  path: Optional custom path for the target file.
44
61
  **kwargs: Additional arguments passed to the renderer constructor.
45
62
  Common kwargs (accepted by all renderers):
@@ -12,9 +12,18 @@ from buildlog.skills import _to_imperative
12
12
  if TYPE_CHECKING:
13
13
  from buildlog.skills import Skill
14
14
 
15
+ # Markers to identify the buildlog-managed section in CLAUDE.md
16
+ _SECTION_START = "<!-- buildlog:rules:start -->"
17
+ _SECTION_END = "<!-- buildlog:rules:end -->"
18
+
15
19
 
16
20
  class ClaudeMdRenderer:
17
- """Appends promoted skills to CLAUDE.md."""
21
+ """Manages a dedicated rules section in CLAUDE.md.
22
+
23
+ Uses HTML comment markers to identify the buildlog-managed section.
24
+ On each promote, the section is replaced (not appended) with ALL
25
+ currently promoted rules, preventing duplicates.
26
+ """
18
27
 
19
28
  def __init__(self, path: Path | None = None, tracking_path: Path | None = None):
20
29
  """Initialize renderer.
@@ -31,12 +40,14 @@ class ClaudeMdRenderer:
31
40
  self.tracking_path = tracking_path
32
41
 
33
42
  def render(self, skills: list[Skill]) -> str:
34
- """Append skills to CLAUDE.md.
43
+ """Write skills to CLAUDE.md, replacing the buildlog-managed section.
35
44
 
36
- Filters out skills that have already been promoted to prevent duplicates.
45
+ On first run, appends a marked section. On subsequent runs, finds
46
+ and replaces the marked section with updated rules. This prevents
47
+ the duplicate accumulation that append-only causes.
37
48
 
38
49
  Args:
39
- skills: List of skills to append.
50
+ skills: List of skills to write.
40
51
 
41
52
  Returns:
42
53
  Confirmation message.
@@ -44,25 +55,52 @@ class ClaudeMdRenderer:
44
55
  if not skills:
45
56
  return "No skills to promote"
46
57
 
47
- # Filter out already-promoted skills
58
+ # Filter out already-promoted skills for tracking purposes,
59
+ # but we still rebuild the full section from ALL promoted skills
48
60
  already_promoted = get_promoted_ids(self.tracking_path)
49
61
  new_skills = [s for s in skills if s.id not in already_promoted]
50
62
 
51
63
  if not new_skills:
52
64
  return f"All {len(skills)} skills already promoted"
53
65
 
54
- # Group by category
55
- by_category: dict[str, list[Skill]] = {}
56
- for skill in new_skills:
57
- by_category.setdefault(skill.category, []).append(skill)
66
+ # Track the new skills first so the section includes them
67
+ track_promoted(new_skills, self.tracking_path)
58
68
 
59
- # Build section
60
- lines = [
61
- "",
62
- f"## Learned Rules (auto-generated {datetime.now().strftime('%Y-%m-%d')})",
63
- "",
64
- ]
69
+ # Build the section content from ALL skills being promoted now
70
+ # (not just new ones — we replace the entire section)
71
+ all_skills = skills # All skills passed in this call
72
+ section = self._build_section(all_skills)
73
+
74
+ # Read existing file
75
+ if self.path.exists():
76
+ existing = self.path.read_text()
77
+ else:
78
+ existing = ""
79
+
80
+ # Replace or append the buildlog section
81
+ if _SECTION_START in existing and _SECTION_END in existing:
82
+ # Replace existing section
83
+ start_idx = existing.index(_SECTION_START)
84
+ end_idx = existing.index(_SECTION_END) + len(_SECTION_END)
85
+ updated = existing[:start_idx] + section + existing[end_idx:]
86
+ elif _SECTION_START in existing:
87
+ # Malformed: start marker but no end. Replace from start to EOF.
88
+ start_idx = existing.index(_SECTION_START)
89
+ updated = existing[:start_idx] + section
90
+ else:
91
+ # No existing section — append
92
+ updated = existing.rstrip() + "\n\n" + section + "\n"
93
+
94
+ self.path.write_text(updated)
95
+
96
+ skipped = len(skills) - len(new_skills)
97
+ msg = f"Wrote {len(new_skills)} new rules to {self.path} ({len(all_skills)} total in section)"
98
+ if skipped > 0:
99
+ msg += f" ({skipped} already tracked)"
100
+ return msg
65
101
 
102
+ def _build_section(self, skills: list[Skill]) -> str:
103
+ """Build the marked section content."""
66
104
  category_titles = {
67
105
  "architectural": "Architectural",
68
106
  "workflow": "Workflow",
@@ -70,6 +108,18 @@ class ClaudeMdRenderer:
70
108
  "domain_knowledge": "Domain Knowledge",
71
109
  }
72
110
 
111
+ # Group by category
112
+ by_category: dict[str, list[Skill]] = {}
113
+ for skill in skills:
114
+ by_category.setdefault(skill.category, []).append(skill)
115
+
116
+ lines = [
117
+ _SECTION_START,
118
+ "",
119
+ f"## Learned Rules (buildlog, updated {datetime.now().strftime('%Y-%m-%d')})",
120
+ "",
121
+ ]
122
+
73
123
  for category, cat_skills in by_category.items():
74
124
  title = category_titles.get(category, category.replace("_", " ").title())
75
125
  lines.append(f"### {title}")
@@ -79,20 +129,5 @@ class ClaudeMdRenderer:
79
129
  lines.append(f"- {rule}")
80
130
  lines.append("")
81
131
 
82
- content = "\n".join(lines)
83
-
84
- # Append to file
85
- if self.path.exists():
86
- existing = self.path.read_text()
87
- self.path.write_text(existing + content)
88
- else:
89
- self.path.write_text(content)
90
-
91
- # Track promoted skill IDs using shared utility
92
- track_promoted(new_skills, self.tracking_path)
93
-
94
- skipped = len(skills) - len(new_skills)
95
- msg = f"Appended {len(new_skills)} rules to {self.path}"
96
- if skipped > 0:
97
- msg += f" ({skipped} already promoted, skipped)"
98
- return msg
132
+ lines.append(_SECTION_END)
133
+ return "\n".join(lines)
@@ -0,0 +1,102 @@
1
+ """Render skills to Continue.dev rules format.
2
+
3
+ Creates .continue/rules/buildlog-rules.md with YAML frontmatter and
4
+ Markdown body containing learned rules.
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 get_promoted_ids, track_promoted
14
+ from buildlog.skills import _to_imperative
15
+
16
+ if TYPE_CHECKING:
17
+ from buildlog.skills import Skill
18
+
19
+
20
+ class ContinueRenderer:
21
+ """Creates .continue/rules/buildlog-rules.md for Continue.dev."""
22
+
23
+ def __init__(self, path: Path | None = None, tracking_path: Path | None = None):
24
+ """Initialize renderer.
25
+
26
+ Args:
27
+ path: Path to rules file.
28
+ Defaults to .continue/rules/buildlog-rules.md.
29
+ tracking_path: Path to promoted.json tracking file.
30
+ Defaults to .buildlog/promoted.json.
31
+ """
32
+ self.path = path or Path(".continue/rules/buildlog-rules.md")
33
+ self.tracking_path = tracking_path or Path(".buildlog/promoted.json")
34
+
35
+ def render(self, skills: list[Skill]) -> str:
36
+ """Render skills to Continue.dev rules file.
37
+
38
+ Overwrites the file with all promoted skills.
39
+
40
+ Args:
41
+ skills: List of skills to render.
42
+
43
+ Returns:
44
+ Confirmation message.
45
+ """
46
+ if not skills:
47
+ return "No skills to promote"
48
+
49
+ # Filter out already-promoted skills
50
+ already_promoted = get_promoted_ids(self.tracking_path)
51
+ new_skills = [s for s in skills if s.id not in already_promoted]
52
+
53
+ if not new_skills:
54
+ return f"All {len(skills)} skills already promoted"
55
+
56
+ # Group by category
57
+ by_category: dict[str, list[Skill]] = {}
58
+ for skill in new_skills:
59
+ by_category.setdefault(skill.category, []).append(skill)
60
+
61
+ category_titles = {
62
+ "architectural": "Architectural",
63
+ "workflow": "Workflow",
64
+ "tool_usage": "Tool Usage",
65
+ "domain_knowledge": "Domain Knowledge",
66
+ }
67
+
68
+ # Build content with YAML frontmatter
69
+ lines = [
70
+ "---",
71
+ "name: buildlog-learned-rules",
72
+ "---",
73
+ "",
74
+ "# Learned Rules",
75
+ "",
76
+ f"*Auto-generated by buildlog on {datetime.now().strftime('%Y-%m-%d')}*",
77
+ "",
78
+ ]
79
+
80
+ for category, cat_skills in by_category.items():
81
+ title = category_titles.get(category, category.replace("_", " ").title())
82
+ lines.append(f"## {title}")
83
+ lines.append("")
84
+ for skill in cat_skills:
85
+ rule = _to_imperative(skill.rule, skill.confidence)
86
+ lines.append(f"- {rule}")
87
+ lines.append("")
88
+
89
+ content = "\n".join(lines)
90
+
91
+ # Write file
92
+ self.path.parent.mkdir(parents=True, exist_ok=True)
93
+ self.path.write_text(content)
94
+
95
+ # Track promoted skill IDs
96
+ track_promoted(new_skills, self.tracking_path)
97
+
98
+ skipped = len(skills) - len(new_skills)
99
+ msg = f"Wrote {len(new_skills)} rules to {self.path}"
100
+ if skipped > 0:
101
+ msg += f" ({skipped} already promoted, skipped)"
102
+ return msg
@@ -0,0 +1,100 @@
1
+ """Render skills to GitHub Copilot instructions format.
2
+
3
+ Appends a learned rules section to .github/copilot-instructions.md.
4
+ """
5
+
6
+ from __future__ import annotations
7
+
8
+ from datetime import datetime
9
+ from pathlib import Path
10
+ from typing import TYPE_CHECKING
11
+
12
+ from buildlog.render.tracking import get_promoted_ids, track_promoted
13
+ from buildlog.skills import _to_imperative
14
+
15
+ if TYPE_CHECKING:
16
+ from buildlog.skills import Skill
17
+
18
+
19
+ class CopilotRenderer:
20
+ """Appends promoted skills to .github/copilot-instructions.md."""
21
+
22
+ def __init__(self, path: Path | None = None, tracking_path: Path | None = None):
23
+ """Initialize renderer.
24
+
25
+ Args:
26
+ path: Path to copilot instructions file.
27
+ Defaults to .github/copilot-instructions.md.
28
+ tracking_path: Path to promoted.json tracking file.
29
+ Defaults to .buildlog/promoted.json.
30
+ """
31
+ self.path = path or Path(".github/copilot-instructions.md")
32
+ self.tracking_path = tracking_path or Path(".buildlog/promoted.json")
33
+
34
+ def render(self, skills: list[Skill]) -> str:
35
+ """Append skills to copilot-instructions.md.
36
+
37
+ Filters out already-promoted skills and appends a new section.
38
+
39
+ Args:
40
+ skills: List of skills to render.
41
+
42
+ Returns:
43
+ Confirmation message.
44
+ """
45
+ if not skills:
46
+ return "No skills to promote"
47
+
48
+ # Filter out already-promoted skills
49
+ already_promoted = get_promoted_ids(self.tracking_path)
50
+ new_skills = [s for s in skills if s.id not in already_promoted]
51
+
52
+ if not new_skills:
53
+ return f"All {len(skills)} skills already promoted"
54
+
55
+ # Group by category
56
+ by_category: dict[str, list[Skill]] = {}
57
+ for skill in new_skills:
58
+ by_category.setdefault(skill.category, []).append(skill)
59
+
60
+ category_titles = {
61
+ "architectural": "Architectural",
62
+ "workflow": "Workflow",
63
+ "tool_usage": "Tool Usage",
64
+ "domain_knowledge": "Domain Knowledge",
65
+ }
66
+
67
+ # Build section
68
+ lines = [
69
+ "",
70
+ f"## Learned Rules (buildlog {datetime.now().strftime('%Y-%m-%d')})",
71
+ "",
72
+ ]
73
+
74
+ for category, cat_skills in by_category.items():
75
+ title = category_titles.get(category, category.replace("_", " ").title())
76
+ lines.append(f"### {title}")
77
+ lines.append("")
78
+ for skill in cat_skills:
79
+ rule = _to_imperative(skill.rule, skill.confidence)
80
+ lines.append(f"- {rule}")
81
+ lines.append("")
82
+
83
+ content = "\n".join(lines)
84
+
85
+ # Append to file (create if needed)
86
+ self.path.parent.mkdir(parents=True, exist_ok=True)
87
+ if self.path.exists():
88
+ existing = self.path.read_text()
89
+ self.path.write_text(existing + content)
90
+ else:
91
+ self.path.write_text(content)
92
+
93
+ # Track promoted skill IDs
94
+ track_promoted(new_skills, self.tracking_path)
95
+
96
+ skipped = len(skills) - len(new_skills)
97
+ msg = f"Appended {len(new_skills)} rules to {self.path}"
98
+ if skipped > 0:
99
+ msg += f" ({skipped} already promoted, skipped)"
100
+ return msg
@@ -0,0 +1,105 @@
1
+ """Render skills to Cursor rules format.
2
+
3
+ Creates .cursor/rules/buildlog-rules.mdc with YAML frontmatter and
4
+ Markdown body containing learned rules.
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 get_promoted_ids, track_promoted
14
+
15
+ if TYPE_CHECKING:
16
+ from buildlog.skills import Skill
17
+
18
+
19
+ class CursorRenderer:
20
+ """Creates .cursor/rules/buildlog-rules.mdc for Cursor IDE."""
21
+
22
+ def __init__(self, path: Path | None = None, tracking_path: Path | None = None):
23
+ """Initialize renderer.
24
+
25
+ Args:
26
+ path: Path to .mdc rules file. Defaults to .cursor/rules/buildlog-rules.mdc.
27
+ tracking_path: Path to promoted.json tracking file.
28
+ Defaults to .buildlog/promoted.json.
29
+ """
30
+ self.path = path or Path(".cursor/rules/buildlog-rules.mdc")
31
+ self.tracking_path = tracking_path or Path(".buildlog/promoted.json")
32
+
33
+ def render(self, skills: list[Skill]) -> str:
34
+ """Render skills to Cursor .mdc rules file.
35
+
36
+ Overwrites the file with all promoted skills (not append-only) since
37
+ Cursor reads the entire file on each invocation.
38
+
39
+ Args:
40
+ skills: List of skills to render.
41
+
42
+ Returns:
43
+ Confirmation message.
44
+ """
45
+ if not skills:
46
+ return "No skills to promote"
47
+
48
+ # Filter out already-promoted skills
49
+ already_promoted = get_promoted_ids(self.tracking_path)
50
+ new_skills = [s for s in skills if s.id not in already_promoted]
51
+
52
+ if not new_skills:
53
+ return f"All {len(skills)} skills already promoted"
54
+
55
+ # Group by category
56
+ by_category: dict[str, list[Skill]] = {}
57
+ for skill in new_skills:
58
+ by_category.setdefault(skill.category, []).append(skill)
59
+
60
+ category_titles = {
61
+ "architectural": "Architectural",
62
+ "workflow": "Workflow",
63
+ "tool_usage": "Tool Usage",
64
+ "domain_knowledge": "Domain Knowledge",
65
+ }
66
+
67
+ # Build MDC content with YAML frontmatter
68
+ lines = [
69
+ "---",
70
+ "description: Buildlog-learned rules for code quality",
71
+ "alwaysApply: true",
72
+ "---",
73
+ "",
74
+ "# Learned Rules",
75
+ "",
76
+ f"*Auto-generated by buildlog on {datetime.now().strftime('%Y-%m-%d')}*",
77
+ "",
78
+ ]
79
+
80
+ for category, cat_skills in by_category.items():
81
+ title = category_titles.get(category, category.replace("_", " ").title())
82
+ lines.append(f"## {title}")
83
+ lines.append("")
84
+ for skill in cat_skills:
85
+ conf = skill.confidence
86
+ freq = skill.frequency
87
+ lines.append(
88
+ f"- **{skill.rule}** (confidence: {conf}, frequency: {freq})"
89
+ )
90
+ lines.append("")
91
+
92
+ content = "\n".join(lines)
93
+
94
+ # Write file
95
+ self.path.parent.mkdir(parents=True, exist_ok=True)
96
+ self.path.write_text(content)
97
+
98
+ # Track promoted skill IDs
99
+ track_promoted(new_skills, self.tracking_path)
100
+
101
+ skipped = len(skills) - len(new_skills)
102
+ msg = f"Wrote {len(new_skills)} rules to {self.path}"
103
+ if skipped > 0:
104
+ msg += f" ({skipped} already promoted, skipped)"
105
+ return msg
@@ -0,0 +1,95 @@
1
+ """Render skills to Windsurf rules format.
2
+
3
+ Creates .windsurf/rules/buildlog-rules.md with learned rules in
4
+ plain Markdown format.
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 get_promoted_ids, track_promoted
14
+ from buildlog.skills import _to_imperative
15
+
16
+ if TYPE_CHECKING:
17
+ from buildlog.skills import Skill
18
+
19
+
20
+ class WindsurfRenderer:
21
+ """Creates .windsurf/rules/buildlog-rules.md for Windsurf IDE."""
22
+
23
+ def __init__(self, path: Path | None = None, tracking_path: Path | None = None):
24
+ """Initialize renderer.
25
+
26
+ Args:
27
+ path: Path to rules file. Defaults to .windsurf/rules/buildlog-rules.md.
28
+ tracking_path: Path to promoted.json tracking file.
29
+ Defaults to .buildlog/promoted.json.
30
+ """
31
+ self.path = path or Path(".windsurf/rules/buildlog-rules.md")
32
+ self.tracking_path = tracking_path or Path(".buildlog/promoted.json")
33
+
34
+ def render(self, skills: list[Skill]) -> str:
35
+ """Render skills to Windsurf rules file.
36
+
37
+ Overwrites the file with all promoted skills.
38
+
39
+ Args:
40
+ skills: List of skills to render.
41
+
42
+ Returns:
43
+ Confirmation message.
44
+ """
45
+ if not skills:
46
+ return "No skills to promote"
47
+
48
+ # Filter out already-promoted skills
49
+ already_promoted = get_promoted_ids(self.tracking_path)
50
+ new_skills = [s for s in skills if s.id not in already_promoted]
51
+
52
+ if not new_skills:
53
+ return f"All {len(skills)} skills already promoted"
54
+
55
+ # Group by category
56
+ by_category: dict[str, list[Skill]] = {}
57
+ for skill in new_skills:
58
+ by_category.setdefault(skill.category, []).append(skill)
59
+
60
+ category_titles = {
61
+ "architectural": "Architectural",
62
+ "workflow": "Workflow",
63
+ "tool_usage": "Tool Usage",
64
+ "domain_knowledge": "Domain Knowledge",
65
+ }
66
+
67
+ # Build Markdown content
68
+ lines = [
69
+ f"## Learned Rules (buildlog {datetime.now().strftime('%Y-%m-%d')})",
70
+ "",
71
+ ]
72
+
73
+ for category, cat_skills in by_category.items():
74
+ title = category_titles.get(category, category.replace("_", " ").title())
75
+ lines.append(f"### {title}")
76
+ lines.append("")
77
+ for skill in cat_skills:
78
+ rule = _to_imperative(skill.rule, skill.confidence)
79
+ lines.append(f"- {rule}")
80
+ lines.append("")
81
+
82
+ content = "\n".join(lines)
83
+
84
+ # Write file
85
+ self.path.parent.mkdir(parents=True, exist_ok=True)
86
+ self.path.write_text(content)
87
+
88
+ # Track promoted skill IDs
89
+ track_promoted(new_skills, self.tracking_path)
90
+
91
+ skipped = len(skills) - len(new_skills)
92
+ msg = f"Wrote {len(new_skills)} rules to {self.path}"
93
+ if skipped > 0:
94
+ msg += f" ({skipped} already promoted, skipped)"
95
+ return msg
@@ -33,6 +33,7 @@ from buildlog.seed_engine.categorizers import (
33
33
  )
34
34
  from buildlog.seed_engine.extractors import ManualExtractor, RuleExtractor
35
35
  from buildlog.seed_engine.generators import SeedGenerator
36
+ from buildlog.seed_engine.llm_extractor import LLMExtractor
36
37
  from buildlog.seed_engine.models import (
37
38
  CandidateRule,
38
39
  CategorizedRule,
@@ -59,6 +60,7 @@ __all__ = [
59
60
  # Extractors
60
61
  "RuleExtractor",
61
62
  "ManualExtractor",
63
+ "LLMExtractor",
62
64
  # Categorizers
63
65
  "Categorizer",
64
66
  "TagBasedCategorizer",