buildlog 0.6.1__py3-none-any.whl → 0.8.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 (40) hide show
  1. buildlog/__init__.py +1 -1
  2. buildlog/cli.py +589 -44
  3. buildlog/confidence.py +27 -0
  4. buildlog/core/__init__.py +12 -0
  5. buildlog/core/bandit.py +699 -0
  6. buildlog/core/operations.py +499 -11
  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 +461 -0
  15. buildlog/mcp/server.py +12 -6
  16. buildlog/mcp/tools.py +166 -13
  17. buildlog/render/__init__.py +19 -2
  18. buildlog/render/claude_md.py +74 -26
  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/tracking.py +20 -1
  23. buildlog/render/windsurf.py +95 -0
  24. buildlog/seeds.py +41 -0
  25. buildlog/skills.py +69 -6
  26. {buildlog-0.6.1.data → buildlog-0.8.0.data}/data/share/buildlog/copier.yml +0 -4
  27. buildlog-0.8.0.data/data/share/buildlog/template/buildlog/_TEMPLATE_QUICK.md +21 -0
  28. buildlog-0.8.0.dist-info/METADATA +151 -0
  29. buildlog-0.8.0.dist-info/RECORD +54 -0
  30. buildlog-0.6.1.dist-info/METADATA +0 -490
  31. buildlog-0.6.1.dist-info/RECORD +0 -41
  32. {buildlog-0.6.1.data → buildlog-0.8.0.data}/data/share/buildlog/post_gen.py +0 -0
  33. {buildlog-0.6.1.data → buildlog-0.8.0.data}/data/share/buildlog/template/buildlog/.gitkeep +0 -0
  34. {buildlog-0.6.1.data → buildlog-0.8.0.data}/data/share/buildlog/template/buildlog/2026-01-01-example.md +0 -0
  35. {buildlog-0.6.1.data → buildlog-0.8.0.data}/data/share/buildlog/template/buildlog/BUILDLOG_SYSTEM.md +0 -0
  36. {buildlog-0.6.1.data → buildlog-0.8.0.data}/data/share/buildlog/template/buildlog/_TEMPLATE.md +0 -0
  37. {buildlog-0.6.1.data → buildlog-0.8.0.data}/data/share/buildlog/template/buildlog/assets/.gitkeep +0 -0
  38. {buildlog-0.6.1.dist-info → buildlog-0.8.0.dist-info}/WHEEL +0 -0
  39. {buildlog-0.6.1.dist-info → buildlog-0.8.0.dist-info}/entry_points.txt +0 -0
  40. {buildlog-0.6.1.dist-info → buildlog-0.8.0.dist-info}/licenses/LICENSE +0 -0
buildlog/mcp/tools.py CHANGED
@@ -12,6 +12,7 @@ from typing import Literal
12
12
  from buildlog.core import (
13
13
  diff,
14
14
  end_session,
15
+ get_bandit_status,
15
16
  get_experiment_report,
16
17
  get_rewards,
17
18
  get_session_metrics,
@@ -52,17 +53,17 @@ def buildlog_status(
52
53
 
53
54
  def buildlog_promote(
54
55
  skill_ids: list[str],
55
- target: Literal["claude_md", "settings_json", "skill"] = "claude_md",
56
+ target: str = "claude_md",
56
57
  buildlog_dir: str = "buildlog",
57
58
  ) -> dict:
58
59
  """Promote skills to your agent's rules.
59
60
 
60
- Writes selected skills to CLAUDE.md, .claude/settings.json, or
61
- .claude/skills/buildlog-learned/SKILL.md (Anthropic Agent Skills format).
61
+ Writes selected skills to agent-specific rule files.
62
62
 
63
63
  Args:
64
64
  skill_ids: List of skill IDs to promote (e.g., ["arch-b0fcb62a1e"])
65
- target: Where to write rules ("claude_md", "settings_json", or "skill")
65
+ target: Where to write rules. One of: claude_md, settings_json,
66
+ skill, cursor, copilot, windsurf, continue_dev.
66
67
  buildlog_dir: Path to buildlog directory
67
68
 
68
69
  Returns:
@@ -262,36 +263,47 @@ def buildlog_rewards(
262
263
  # -----------------------------------------------------------------------------
263
264
 
264
265
 
265
- def buildlog_start_session(
266
+ def buildlog_experiment_start(
266
267
  error_class: str | None = None,
267
268
  notes: str | None = None,
269
+ select_k: int = 3,
268
270
  buildlog_dir: str = "buildlog",
269
271
  ) -> dict:
270
- """Start a new experiment session.
272
+ """Start a new experiment session with Thompson Sampling rule selection.
271
273
 
272
- Begins tracking for a learning experiment. Captures the current
273
- set of active rules to measure learning over time.
274
+ Begins tracking for a learning experiment. Uses Thompson Sampling
275
+ to select which rules will be "active" for this session based on
276
+ the error class context.
277
+
278
+ The selected rules will receive feedback:
279
+ - Negative feedback (reward=0) when log_mistake() is called
280
+ - Explicit feedback when log_reward() is called
281
+
282
+ This teaches the bandit which rules are effective for which contexts.
274
283
 
275
284
  Args:
276
- error_class: Error class being targeted (e.g., "missing_test")
285
+ error_class: Error class being targeted (e.g., "missing_test").
286
+ This is the CONTEXT for contextual bandits.
277
287
  notes: Notes about this session
288
+ select_k: Number of rules to select via Thompson Sampling
278
289
  buildlog_dir: Path to buildlog directory
279
290
 
280
291
  Returns:
281
- Dict with session_id, error_class, rules_count, message
292
+ Dict with session_id, error_class, rules_count, selected_rules, message
282
293
 
283
294
  Example:
284
- buildlog_start_session(error_class="missing_test")
295
+ buildlog_start_session(error_class="type-errors", select_k=5)
285
296
  """
286
297
  result = start_session(
287
298
  Path(buildlog_dir),
288
299
  error_class=error_class,
289
300
  notes=notes,
301
+ select_k=select_k,
290
302
  )
291
303
  return asdict(result)
292
304
 
293
305
 
294
- def buildlog_end_session(
306
+ def buildlog_experiment_end(
295
307
  entry_file: str | None = None,
296
308
  notes: str | None = None,
297
309
  buildlog_dir: str = "buildlog",
@@ -358,7 +370,7 @@ def buildlog_log_mistake(
358
370
  return asdict(result)
359
371
 
360
372
 
361
- def buildlog_session_metrics(
373
+ def buildlog_experiment_metrics(
362
374
  session_id: str | None = None,
363
375
  buildlog_dir: str = "buildlog",
364
376
  ) -> dict:
@@ -405,3 +417,144 @@ def buildlog_experiment_report(
405
417
  buildlog_experiment_report()
406
418
  """
407
419
  return get_experiment_report(Path(buildlog_dir))
420
+
421
+
422
+ def buildlog_bandit_status(
423
+ buildlog_dir: str = "buildlog",
424
+ context: str | None = None,
425
+ top_k: int = 10,
426
+ ) -> dict:
427
+ """Get Thompson Sampling bandit status and rule rankings.
428
+
429
+ Shows the bandit's learned beliefs about which rules are effective
430
+ for each error class context. Higher mean = bandit believes rule
431
+ is more effective.
432
+
433
+ The bandit uses Beta distributions to model uncertainty:
434
+ - High variance (wide CI) = uncertain, will explore more
435
+ - Low variance (narrow CI) = confident, will exploit
436
+
437
+ Args:
438
+ buildlog_dir: Path to buildlog directory
439
+ context: Specific error class to filter by (optional)
440
+ top_k: Number of top rules to show per context
441
+
442
+ Returns:
443
+ Dict with:
444
+ - summary: Total contexts, arms, observations
445
+ - top_rules: Best rules per context by expected value
446
+ - all_rules: Full stats if filtering by context
447
+
448
+ Example:
449
+ # See all bandit state
450
+ buildlog_bandit_status()
451
+
452
+ # See state for specific error class
453
+ buildlog_bandit_status(context="type-errors")
454
+ """
455
+ return get_bandit_status(Path(buildlog_dir), context, top_k)
456
+
457
+
458
+ # -----------------------------------------------------------------------------
459
+ # Gauntlet Loop MCP Tools
460
+ # -----------------------------------------------------------------------------
461
+
462
+
463
+ def buildlog_gauntlet_issues(
464
+ issues: list[dict],
465
+ iteration: int = 1,
466
+ source: str | None = None,
467
+ buildlog_dir: str = "buildlog",
468
+ ) -> dict:
469
+ """Process gauntlet review issues and determine next action.
470
+
471
+ Call this after running a gauntlet review. It categorizes issues by
472
+ severity, persists learnings, and returns the appropriate next action.
473
+
474
+ Args:
475
+ issues: List of issues from the gauntlet review, each with:
476
+ {
477
+ "severity": "critical|major|minor|nitpick",
478
+ "category": "security|testing|architectural|...",
479
+ "description": "What's wrong",
480
+ "rule_learned": "Generalizable rule",
481
+ "location": "file:line (optional)"
482
+ }
483
+ iteration: Current iteration number (for tracking loops)
484
+ source: Optional source identifier for learnings
485
+ buildlog_dir: Path to buildlog directory
486
+
487
+ Returns:
488
+ Dict with:
489
+ - action: What to do next:
490
+ - "fix_criticals": Criticals remain, auto-fix and loop
491
+ - "checkpoint_majors": No criticals, majors remain (ask user)
492
+ - "checkpoint_minors": Only minors remain (ask user)
493
+ - "clean": No issues remain
494
+ - criticals: List of critical issues
495
+ - majors: List of major issues
496
+ - minors: List of minor/nitpick issues
497
+ - iteration: Current iteration number
498
+ - learnings_persisted: Number of learnings saved
499
+ - message: Human-readable summary
500
+
501
+ Example:
502
+ # After running gauntlet review
503
+ result = buildlog_gauntlet_issues(
504
+ issues=[
505
+ {"severity": "critical", "category": "security", ...},
506
+ {"severity": "major", "category": "testing", ...},
507
+ ],
508
+ iteration=1
509
+ )
510
+ # result["action"] tells you what to do next
511
+ """
512
+ from buildlog.core import gauntlet_process_issues
513
+
514
+ result = gauntlet_process_issues(
515
+ Path(buildlog_dir),
516
+ issues=issues,
517
+ iteration=iteration,
518
+ source=source,
519
+ )
520
+ return asdict(result)
521
+
522
+
523
+ def buildlog_gauntlet_accept_risk(
524
+ remaining_issues: list[dict],
525
+ create_github_issues: bool = False,
526
+ repo: str | None = None,
527
+ ) -> dict:
528
+ """Accept risk for remaining issues, optionally creating GitHub issues.
529
+
530
+ Call this when the user decides to accept remaining issues as risk
531
+ (e.g., only minors remain and they want to move on).
532
+
533
+ Args:
534
+ remaining_issues: Issues being accepted as risk
535
+ create_github_issues: Whether to create GitHub issues for tracking
536
+ repo: Repository for GitHub issues (uses current repo if None)
537
+
538
+ Returns:
539
+ Dict with:
540
+ - accepted_issues: Number of issues accepted
541
+ - github_issues_created: Number of GitHub issues created
542
+ - github_issue_urls: URLs of created issues
543
+ - message: Human-readable summary
544
+ - error: Error message if GitHub issue creation failed
545
+
546
+ Example:
547
+ # User accepts risk with minors, wants GitHub issues
548
+ result = buildlog_gauntlet_accept_risk(
549
+ remaining_issues=[...],
550
+ create_github_issues=True
551
+ )
552
+ """
553
+ from buildlog.core import gauntlet_accept_risk
554
+
555
+ result = gauntlet_accept_risk(
556
+ remaining_issues=remaining_issues,
557
+ create_github_issues=create_github_issues,
558
+ repo=repo,
559
+ )
560
+ return asdict(result)
@@ -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):
@@ -6,15 +6,24 @@ from datetime import datetime
6
6
  from pathlib import Path
7
7
  from typing import TYPE_CHECKING
8
8
 
9
- from buildlog.render.tracking import track_promoted
9
+ from buildlog.render.tracking import get_promoted_ids, track_promoted
10
10
  from buildlog.skills import _to_imperative
11
11
 
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,10 +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.
44
+
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.
35
48
 
36
49
  Args:
37
- skills: List of skills to append.
50
+ skills: List of skills to write.
38
51
 
39
52
  Returns:
40
53
  Confirmation message.
@@ -42,25 +55,71 @@ class ClaudeMdRenderer:
42
55
  if not skills:
43
56
  return "No skills to promote"
44
57
 
58
+ # Filter out already-promoted skills for tracking purposes,
59
+ # but we still rebuild the full section from ALL promoted skills
60
+ already_promoted = get_promoted_ids(self.tracking_path)
61
+ new_skills = [s for s in skills if s.id not in already_promoted]
62
+
63
+ if not new_skills:
64
+ return f"All {len(skills)} skills already promoted"
65
+
66
+ # Track the new skills first so the section includes them
67
+ track_promoted(new_skills, self.tracking_path)
68
+
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
101
+
102
+ def _build_section(self, skills: list[Skill]) -> str:
103
+ """Build the marked section content."""
104
+ category_titles = {
105
+ "architectural": "Architectural",
106
+ "workflow": "Workflow",
107
+ "tool_usage": "Tool Usage",
108
+ "domain_knowledge": "Domain Knowledge",
109
+ }
110
+
45
111
  # Group by category
46
112
  by_category: dict[str, list[Skill]] = {}
47
113
  for skill in skills:
48
114
  by_category.setdefault(skill.category, []).append(skill)
49
115
 
50
- # Build section
51
116
  lines = [
117
+ _SECTION_START,
52
118
  "",
53
- f"## Learned Rules (auto-generated {datetime.now().strftime('%Y-%m-%d')})",
119
+ f"## Learned Rules (buildlog, updated {datetime.now().strftime('%Y-%m-%d')})",
54
120
  "",
55
121
  ]
56
122
 
57
- category_titles = {
58
- "architectural": "Architectural",
59
- "workflow": "Workflow",
60
- "tool_usage": "Tool Usage",
61
- "domain_knowledge": "Domain Knowledge",
62
- }
63
-
64
123
  for category, cat_skills in by_category.items():
65
124
  title = category_titles.get(category, category.replace("_", " ").title())
66
125
  lines.append(f"### {title}")
@@ -70,16 +129,5 @@ class ClaudeMdRenderer:
70
129
  lines.append(f"- {rule}")
71
130
  lines.append("")
72
131
 
73
- content = "\n".join(lines)
74
-
75
- # Append to file
76
- if self.path.exists():
77
- existing = self.path.read_text()
78
- self.path.write_text(existing + content)
79
- else:
80
- self.path.write_text(content)
81
-
82
- # Track promoted skill IDs using shared utility
83
- track_promoted(skills, self.tracking_path)
84
-
85
- return f"Appended {len(skills)} rules to {self.path}"
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