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
buildlog/cli.py CHANGED
@@ -8,7 +8,8 @@ from pathlib import Path
8
8
 
9
9
  import click
10
10
 
11
- from buildlog.core import get_rewards, log_reward
11
+ from buildlog.core import diff as core_diff
12
+ from buildlog.core import get_rewards, log_reward, promote, reject, status
12
13
  from buildlog.distill import CATEGORIES, distill_all, format_output
13
14
  from buildlog.skills import format_skills, generate_skills
14
15
  from buildlog.stats import calculate_stats, format_dashboard, format_json
@@ -49,11 +50,18 @@ def main():
49
50
 
50
51
  @main.command()
51
52
  @click.option("--no-claude-md", is_flag=True, help="Don't update CLAUDE.md")
52
- def init(no_claude_md: bool):
53
+ @click.option(
54
+ "--defaults",
55
+ is_flag=True,
56
+ help="Use default values for all prompts (non-interactive)",
57
+ )
58
+ def init(no_claude_md: bool, defaults: bool):
53
59
  """Initialize buildlog in the current directory.
54
60
 
55
61
  Sets up the buildlog/ directory with templates and optionally
56
62
  adds instructions to CLAUDE.md.
63
+
64
+ Use --defaults for non-interactive environments (CI, scripts, agents).
57
65
  """
58
66
  buildlog_dir = Path("buildlog")
59
67
 
@@ -66,46 +74,186 @@ def init(no_claude_md: bool):
66
74
  if template_dir:
67
75
  # Use local template
68
76
  click.echo("Initializing buildlog from local template...")
69
- try:
70
- subprocess.run(
71
- [
72
- sys.executable,
73
- "-m",
74
- "copier",
75
- "copy",
76
- "--trust",
77
- *(["--data", "update_claude_md=false"] if no_claude_md else []),
78
- str(template_dir),
79
- ".",
80
- ],
81
- check=True,
82
- )
83
- except subprocess.CalledProcessError:
84
- click.echo("Failed to initialize buildlog.", err=True)
85
- raise SystemExit(1)
77
+ subprocess.run(
78
+ [
79
+ sys.executable,
80
+ "-m",
81
+ "copier",
82
+ "copy",
83
+ "--trust",
84
+ *(["--defaults"] if defaults else []),
85
+ *(["--data", "update_claude_md=false"] if no_claude_md else []),
86
+ str(template_dir),
87
+ ".",
88
+ ],
89
+ )
86
90
  else:
87
91
  # Fall back to GitHub
88
92
  click.echo("Initializing buildlog from GitHub...")
89
- try:
90
- subprocess.run(
91
- [
92
- sys.executable,
93
- "-m",
94
- "copier",
95
- "copy",
96
- "--trust",
97
- *(["--data", "update_claude_md=false"] if no_claude_md else []),
98
- "gh:Peleke/buildlog-template",
99
- ".",
100
- ],
101
- check=True,
102
- )
103
- except subprocess.CalledProcessError:
104
- click.echo("Failed to initialize buildlog.", err=True)
105
- raise SystemExit(1)
93
+ subprocess.run(
94
+ [
95
+ sys.executable,
96
+ "-m",
97
+ "copier",
98
+ "copy",
99
+ "--trust",
100
+ *(["--defaults"] if defaults else []),
101
+ *(["--data", "update_claude_md=false"] if no_claude_md else []),
102
+ "gh:Peleke/buildlog-template",
103
+ ".",
104
+ ],
105
+ )
106
+
107
+ # Verify the buildlog directory was actually created
108
+ if not buildlog_dir.exists():
109
+ click.echo("Failed to initialize buildlog.", err=True)
110
+ raise SystemExit(1)
111
+
112
+ # Update CLAUDE.md if it exists and user didn't opt out
113
+ if not no_claude_md:
114
+ claude_md = Path("CLAUDE.md")
115
+ if claude_md.exists():
116
+ content = claude_md.read_text()
117
+ if "## Build Journal" not in content:
118
+ section = (
119
+ "\n## Build Journal\n\n"
120
+ "After completing significant work (features, debugging sessions, "
121
+ "deployments,\n"
122
+ "2+ hour focused sessions), write a build journal entry.\n\n"
123
+ "**Location:** `buildlog/YYYY-MM-DD-{slug}.md`\n"
124
+ "**Template:** `buildlog/_TEMPLATE.md`\n"
125
+ )
126
+ with open(claude_md, "a") as f:
127
+ f.write(section)
128
+ click.echo("Added Build Journal section to CLAUDE.md")
106
129
 
107
130
  click.echo("\n✓ buildlog initialized!")
108
- click.echo("\nNext: buildlog new my-feature")
131
+ click.echo()
132
+ click.echo("How it works:")
133
+ click.echo(" 1. Write entries buildlog new my-feature (or --quick)")
134
+ click.echo(" 2. Extract rules buildlog skills")
135
+ click.echo(" 3. Promote to agent buildlog promote <id> --target cursor")
136
+ click.echo(" 4. Measure learning buildlog overview")
137
+ click.echo()
138
+ click.echo(
139
+ "Targets: claude_md, cursor, copilot, windsurf, continue_dev, settings_json, skill"
140
+ )
141
+ click.echo()
142
+ click.echo("Start now: buildlog new my-first-task --quick")
143
+
144
+
145
+ @main.command()
146
+ @click.option("--json", "output_json", is_flag=True, help="Output as JSON")
147
+ def overview(output_json: bool):
148
+ """Show the full state of your buildlog at a glance.
149
+
150
+ Entries, skills, promoted rules, experiments — everything in one view.
151
+
152
+ Examples:
153
+
154
+ buildlog overview
155
+ buildlog overview --json
156
+ """
157
+ import json as json_module
158
+
159
+ buildlog_dir = Path("buildlog")
160
+
161
+ if not buildlog_dir.exists():
162
+ click.echo("No buildlog/ directory found. Run 'buildlog init' first.", err=True)
163
+ raise SystemExit(1)
164
+
165
+ # Count entries
166
+ entries = sorted(buildlog_dir.glob("20??-??-??-*.md"))
167
+
168
+ # Try to get skills
169
+ try:
170
+ skill_set = generate_skills(buildlog_dir)
171
+ total_skills = skill_set.total_skills
172
+ by_confidence = {"high": 0, "medium": 0, "low": 0}
173
+ for cat_skills in skill_set.skills.values():
174
+ for s in cat_skills:
175
+ by_confidence[s.confidence] += 1
176
+ except Exception:
177
+ total_skills = 0
178
+ by_confidence = {"high": 0, "medium": 0, "low": 0}
179
+
180
+ # Promoted/rejected counts
181
+ promoted_path = buildlog_dir / ".buildlog" / "promoted.json"
182
+ rejected_path = buildlog_dir / ".buildlog" / "rejected.json"
183
+ promoted_count = 0
184
+ rejected_count = 0
185
+ if promoted_path.exists():
186
+ try:
187
+ data = json_module.loads(promoted_path.read_text())
188
+ promoted_count = len(data.get("skill_ids", []))
189
+ except (json_module.JSONDecodeError, OSError):
190
+ pass
191
+ if rejected_path.exists():
192
+ try:
193
+ data = json_module.loads(rejected_path.read_text())
194
+ rejected_count = len(data.get("skill_ids", []))
195
+ except (json_module.JSONDecodeError, OSError):
196
+ pass
197
+
198
+ # Active session?
199
+ active_session_path = buildlog_dir / ".buildlog" / "active_session.json"
200
+ active_session = None
201
+ if active_session_path.exists():
202
+ try:
203
+ active_session = json_module.loads(active_session_path.read_text())
204
+ except (json_module.JSONDecodeError, OSError):
205
+ pass
206
+
207
+ # Render targets with files
208
+ from buildlog.render import RENDERERS
209
+
210
+ result = {
211
+ "entries": len(entries),
212
+ "skills": {
213
+ "total": total_skills,
214
+ "by_confidence": by_confidence,
215
+ "promoted": promoted_count,
216
+ "rejected": rejected_count,
217
+ "pending": total_skills - promoted_count - rejected_count,
218
+ },
219
+ "active_session": active_session.get("id") if active_session else None,
220
+ "render_targets": list(RENDERERS.keys()),
221
+ }
222
+
223
+ if output_json:
224
+ click.echo(json_module.dumps(result, indent=2))
225
+ else:
226
+ click.echo("buildlog overview")
227
+ click.echo("=" * 40)
228
+ click.echo(f" Entries: {len(entries)}")
229
+ click.echo(f" Skills: {total_skills}")
230
+ if total_skills > 0:
231
+ conf_parts = [f"{k}={v}" for k, v in by_confidence.items() if v > 0]
232
+ click.echo(f" confidence: {', '.join(conf_parts)}")
233
+ click.echo(f" Promoted: {promoted_count}")
234
+ click.echo(f" Rejected: {rejected_count}")
235
+ pending = total_skills - promoted_count - rejected_count
236
+ if pending > 0:
237
+ click.echo(f" Pending: {pending}")
238
+ if active_session:
239
+ click.echo(f" Session: {active_session.get('id', '?')} (active)")
240
+ click.echo()
241
+
242
+ if len(entries) == 0:
243
+ click.echo("Get started:")
244
+ click.echo(" buildlog new my-first-task # Full template")
245
+ click.echo(" buildlog new my-first-task --quick # Short template")
246
+ elif total_skills == 0:
247
+ click.echo("Next steps:")
248
+ click.echo(
249
+ " buildlog skills # Extract rules from entries"
250
+ )
251
+ elif promoted_count == 0:
252
+ click.echo("Next steps:")
253
+ click.echo(" buildlog status # See extracted skills")
254
+ click.echo(" buildlog promote <id> --target cursor # Push to your agent")
255
+ else:
256
+ click.echo("Targets: " + ", ".join(RENDERERS.keys()))
109
257
 
110
258
 
111
259
  @main.command()
@@ -113,7 +261,12 @@ def init(no_claude_md: bool):
113
261
  @click.option(
114
262
  "--date", "-d", "entry_date", default=None, help="Date for entry (YYYY-MM-DD)"
115
263
  )
116
- def new(slug: str, entry_date: str | None):
264
+ @click.option(
265
+ "--quick",
266
+ is_flag=True,
267
+ help="Use the short template (good for small tasks)",
268
+ )
269
+ def new(slug: str, entry_date: str | None, quick: bool):
117
270
  """Create a new buildlog entry.
118
271
 
119
272
  SLUG is a short identifier for the entry (e.g., 'auth-api', 'bugfix-login').
@@ -121,10 +274,16 @@ def new(slug: str, entry_date: str | None):
121
274
  Examples:
122
275
 
123
276
  buildlog new auth-api
277
+ buildlog new bugfix-login --quick
124
278
  buildlog new runpod-deploy --date 2026-01-15
125
279
  """
126
280
  buildlog_dir = Path("buildlog")
127
- template_file = buildlog_dir / "_TEMPLATE.md"
281
+ template_name = "_TEMPLATE_QUICK.md" if quick else "_TEMPLATE.md"
282
+ template_file = buildlog_dir / template_name
283
+
284
+ # Fall back to full template if quick template doesn't exist
285
+ if quick and not template_file.exists():
286
+ template_file = buildlog_dir / "_TEMPLATE.md"
128
287
 
129
288
  if not buildlog_dir.exists():
130
289
  click.echo("No buildlog/ directory found. Run 'buildlog init' first.", err=True)
@@ -139,11 +298,11 @@ def new(slug: str, entry_date: str | None):
139
298
  # Determine date
140
299
  if entry_date:
141
300
  try:
142
- # Validate date format
143
- year, month, day = entry_date.split("-")
144
- date_str = f"{int(year):04d}-{int(month):02d}-{int(day):02d}"
301
+ # Validate date format AND range (month 1-12, day 1-31)
302
+ parsed = datetime.strptime(entry_date, "%Y-%m-%d").date()
303
+ date_str = parsed.isoformat()
145
304
  except ValueError:
146
- click.echo("Invalid date format. Use YYYY-MM-DD.", err=True)
305
+ click.echo("Invalid date. Use YYYY-MM-DD with valid values.", err=True)
147
306
  raise SystemExit(1)
148
307
  else:
149
308
  date_str = date.today().isoformat()
@@ -172,6 +331,161 @@ def new(slug: str, entry_date: str | None):
172
331
  click.echo(f"\nOpen it: $EDITOR {entry_path}")
173
332
 
174
333
 
334
+ @main.command(
335
+ context_settings={"ignore_unknown_options": True, "allow_extra_args": True}
336
+ )
337
+ @click.option(
338
+ "--slug",
339
+ "-s",
340
+ default=None,
341
+ help="Entry slug (default: derived from branch name)",
342
+ )
343
+ @click.option(
344
+ "--entry",
345
+ "-e",
346
+ type=click.Path(),
347
+ default=None,
348
+ help="Explicit entry file to append to",
349
+ )
350
+ @click.option(
351
+ "--no-entry",
352
+ is_flag=True,
353
+ help="Skip buildlog entry update (just run git commit)",
354
+ )
355
+ @click.pass_context
356
+ def commit(ctx, slug: str | None, entry: str | None, no_entry: bool):
357
+ """Commit code and update the buildlog entry in one step.
358
+
359
+ Wraps `git commit` and appends commit context to today's buildlog
360
+ entry. If no entry exists for today, creates one automatically.
361
+
362
+ All unknown options/arguments are passed through to git commit.
363
+
364
+ Examples:
365
+
366
+ buildlog commit -m "feat: add LLM extractor"
367
+ buildlog commit --slug llm-extractor -m "feat: add LLM extractor"
368
+ buildlog commit --no-entry -m "chore: formatting"
369
+ """
370
+ buildlog_dir = Path("buildlog")
371
+
372
+ # Build git commit command — extra args are passed through from Click context
373
+ git_cmd = ["git", "commit", *ctx.args]
374
+
375
+ # Run git commit first
376
+ result = subprocess.run(git_cmd, capture_output=True, text=True)
377
+ sys.stdout.write(result.stdout)
378
+ sys.stderr.write(result.stderr)
379
+
380
+ if result.returncode != 0:
381
+ raise SystemExit(result.returncode)
382
+
383
+ if no_entry or not buildlog_dir.exists():
384
+ return
385
+
386
+ # Get commit info from what we just committed
387
+ try:
388
+ commit_hash = subprocess.run(
389
+ ["git", "rev-parse", "--short", "HEAD"],
390
+ capture_output=True,
391
+ text=True,
392
+ check=True,
393
+ ).stdout.strip()
394
+ commit_msg = subprocess.run(
395
+ ["git", "log", "-1", "--format=%s"],
396
+ capture_output=True,
397
+ text=True,
398
+ check=True,
399
+ ).stdout.strip()
400
+ # diff-tree needs special handling for root commit
401
+ diff_result = subprocess.run(
402
+ ["git", "diff-tree", "--no-commit-id", "--name-only", "-r", "HEAD"],
403
+ capture_output=True,
404
+ text=True,
405
+ )
406
+ if diff_result.returncode == 0 and diff_result.stdout.strip():
407
+ files_changed = diff_result.stdout.strip()
408
+ else:
409
+ # Root commit fallback
410
+ files_changed = subprocess.run(
411
+ ["git", "diff", "--name-only", "--cached", "HEAD~1"],
412
+ capture_output=True,
413
+ text=True,
414
+ ).stdout.strip()
415
+ if not files_changed:
416
+ # Truly initial commit — list all tracked files
417
+ files_changed = subprocess.run(
418
+ ["git", "ls-tree", "--name-only", "-r", "HEAD"],
419
+ capture_output=True,
420
+ text=True,
421
+ ).stdout.strip()
422
+ except subprocess.CalledProcessError:
423
+ click.echo("Warning: could not read commit info", err=True)
424
+ return
425
+
426
+ # Resolve entry file
427
+ today = date.today().isoformat()
428
+ entry_path = _resolve_entry_path(buildlog_dir, today, slug, entry)
429
+
430
+ # Append commit block
431
+ commit_block = f"\n### `{commit_hash}` — {commit_msg}\n\n"
432
+ if files_changed:
433
+ file_list = files_changed.split("\n")
434
+ commit_block += "Files:\n"
435
+ for f in file_list[:20]: # cap at 20 to avoid noise
436
+ commit_block += f"- `{f}`\n"
437
+ if len(file_list) > 20:
438
+ commit_block += f"- ...and {len(file_list) - 20} more\n"
439
+ commit_block += "\n"
440
+
441
+ # Ensure commits section exists, append to it
442
+ if entry_path.exists():
443
+ content = entry_path.read_text()
444
+ if "## Commits" not in content:
445
+ content = content.rstrip() + "\n\n## Commits\n"
446
+ content += commit_block
447
+ else:
448
+ # Auto-create minimal entry
449
+ content = f"# {today}\n\n## Commits\n{commit_block}"
450
+
451
+ entry_path.write_text(content)
452
+ click.echo(f"buildlog: updated {entry_path}")
453
+
454
+
455
+ def _resolve_entry_path(
456
+ buildlog_dir: Path, today: str, slug: str | None, explicit: str | None
457
+ ) -> Path:
458
+ """Find or create the entry path for today."""
459
+ if explicit:
460
+ return Path(explicit)
461
+
462
+ # Check for existing entry with today's date
463
+ existing = list(buildlog_dir.glob(f"{today}-*.md"))
464
+ if existing:
465
+ return existing[0]
466
+
467
+ # Derive slug from branch name if not provided
468
+ if slug is None:
469
+ try:
470
+ branch = subprocess.run(
471
+ ["git", "branch", "--show-current"],
472
+ capture_output=True,
473
+ text=True,
474
+ check=True,
475
+ ).stdout.strip()
476
+ # Clean branch name into slug
477
+ slug = branch.split("/")[-1] # strip prefix like feat/
478
+ slug = slug.lower().replace("_", "-")
479
+ slug = "".join(c for c in slug if c.isalnum() or c == "-")
480
+ except subprocess.CalledProcessError:
481
+ slug = "session"
482
+
483
+ if not slug:
484
+ slug = "session"
485
+
486
+ return buildlog_dir / f"{today}-{slug}.md"
487
+
488
+
175
489
  @main.command("list")
176
490
  def list_entries():
177
491
  """List all buildlog entries."""
@@ -257,7 +571,18 @@ def update():
257
571
  type=click.Choice(CATEGORIES),
258
572
  help="Filter to a specific category",
259
573
  )
260
- def distill(output: str | None, fmt: str, since: datetime | None, category: str | None):
574
+ @click.option(
575
+ "--llm",
576
+ is_flag=True,
577
+ help="Use LLM-backed extraction (Ollama/Anthropic, falls back to regex)",
578
+ )
579
+ def distill(
580
+ output: str | None,
581
+ fmt: str,
582
+ since: datetime | None,
583
+ category: str | None,
584
+ llm: bool,
585
+ ):
261
586
  """Extract patterns from all buildlog entries.
262
587
 
263
588
  Parses the Improvements section of each buildlog entry and aggregates
@@ -282,7 +607,9 @@ def distill(output: str | None, fmt: str, since: datetime | None, category: str
282
607
 
283
608
  # Run distillation
284
609
  try:
285
- result = distill_all(buildlog_dir, since=since_date, category_filter=category)
610
+ result = distill_all(
611
+ buildlog_dir, since=since_date, category_filter=category, llm=llm
612
+ )
286
613
  except Exception as e:
287
614
  click.echo(f"Failed to distill entries: {e}", err=True)
288
615
  raise SystemExit(1)
@@ -383,12 +710,18 @@ def stats(output_json: bool, detailed: bool, since_date: str | None):
383
710
  default=None,
384
711
  help="Embedding backend for semantic deduplication",
385
712
  )
713
+ @click.option(
714
+ "--llm",
715
+ is_flag=True,
716
+ help="Use LLM for extraction, canonical selection, and scoring (Ollama/Anthropic)",
717
+ )
386
718
  def skills(
387
719
  output: str | None,
388
720
  fmt: str,
389
721
  min_frequency: int,
390
722
  since: datetime | None,
391
723
  embeddings: str | None,
724
+ llm: bool = False,
392
725
  ):
393
726
  """Generate agent-consumable skills from buildlog patterns.
394
727
 
@@ -424,6 +757,7 @@ def skills(
424
757
  min_frequency=min_frequency,
425
758
  since_date=since_date,
426
759
  embedding_backend=embeddings,
760
+ llm=llm,
427
761
  )
428
762
  except ImportError as e:
429
763
  click.echo(f"Missing dependency: {e}", err=True)
@@ -584,6 +918,219 @@ def rewards(limit: int | None, output_json: bool):
584
918
  click.echo("Log your first with: buildlog reward accepted")
585
919
 
586
920
 
921
+ # -----------------------------------------------------------------------------
922
+ # Skill Management Commands (status, promote, reject, diff)
923
+ # -----------------------------------------------------------------------------
924
+
925
+
926
+ @main.command()
927
+ @click.option(
928
+ "--min-confidence",
929
+ type=click.Choice(["low", "medium", "high"]),
930
+ default="low",
931
+ help="Minimum confidence level to include",
932
+ )
933
+ @click.option("--json", "output_json", is_flag=True, help="Output as JSON")
934
+ def status_cmd(min_confidence: str, output_json: bool):
935
+ """Show extracted skills by category and confidence.
936
+
937
+ Displays all skills extracted from buildlog entries, grouped by category,
938
+ with confidence levels and promotion status.
939
+
940
+ Examples:
941
+
942
+ buildlog status
943
+ buildlog status --min-confidence medium
944
+ buildlog status --json
945
+ """
946
+ import json as json_module
947
+ from dataclasses import asdict
948
+
949
+ buildlog_dir = Path("buildlog")
950
+
951
+ if not buildlog_dir.exists():
952
+ click.echo("No buildlog/ directory found. Run 'buildlog init' first.", err=True)
953
+ raise SystemExit(1)
954
+
955
+ result = status(buildlog_dir, min_confidence=min_confidence) # type: ignore[arg-type]
956
+
957
+ if result.error:
958
+ click.echo(f"Error: {result.error}", err=True)
959
+ raise SystemExit(1)
960
+
961
+ if output_json:
962
+ click.echo(json_module.dumps(asdict(result), indent=2))
963
+ else:
964
+ click.echo(
965
+ f"Skills: {result.total_skills} total from {result.total_entries} entries"
966
+ )
967
+ conf_str = ", ".join(
968
+ f"{k}={v}" for k, v in result.by_confidence.items() if v > 0
969
+ )
970
+ click.echo(f" By confidence: {conf_str}")
971
+ click.echo()
972
+ for category, skills in result.skills.items():
973
+ if not skills:
974
+ continue
975
+ click.echo(f" {category} ({len(skills)})")
976
+ for s in skills:
977
+ conf = s.get("confidence", "?")
978
+ click.echo(f" [{conf}] {s['id']}: {s['rule'][:70]}")
979
+ if result.promotable_ids:
980
+ click.echo(f"\nPromotable: {', '.join(result.promotable_ids)}")
981
+
982
+
983
+ # Register with the name "status" (avoiding collision with Python builtin)
984
+ status_cmd.name = "status"
985
+
986
+
987
+ @main.command()
988
+ @click.argument("skill_ids", nargs=-1, required=True)
989
+ @click.option(
990
+ "--target",
991
+ type=click.Choice(
992
+ [
993
+ "claude_md",
994
+ "settings_json",
995
+ "skill",
996
+ "cursor",
997
+ "copilot",
998
+ "windsurf",
999
+ "continue_dev",
1000
+ ]
1001
+ ),
1002
+ default="claude_md",
1003
+ help="Where to write promoted rules",
1004
+ )
1005
+ @click.option(
1006
+ "--target-path", type=click.Path(), help="Custom path for the target file"
1007
+ )
1008
+ @click.option("--json", "output_json", is_flag=True, help="Output as JSON")
1009
+ def promote_cmd(
1010
+ skill_ids: tuple[str, ...], target: str, target_path: str | None, output_json: bool
1011
+ ):
1012
+ """Promote skills to agent rules.
1013
+
1014
+ Surface high-confidence skills to your agent via CLAUDE.md, settings.json,
1015
+ or Agent Skills.
1016
+
1017
+ Examples:
1018
+
1019
+ buildlog promote arch-b0fcb62a1e
1020
+ buildlog promote arch-123 wf-456 --target skill
1021
+ buildlog promote arch-123 --target settings_json --target-path .claude/settings.json
1022
+ """
1023
+ import json as json_module
1024
+ from dataclasses import asdict
1025
+
1026
+ buildlog_dir = Path("buildlog")
1027
+
1028
+ if not buildlog_dir.exists():
1029
+ click.echo("No buildlog/ directory found. Run 'buildlog init' first.", err=True)
1030
+ raise SystemExit(1)
1031
+
1032
+ result = promote(
1033
+ buildlog_dir,
1034
+ skill_ids=list(skill_ids),
1035
+ target=target, # type: ignore[arg-type]
1036
+ target_path=Path(target_path) if target_path else None,
1037
+ )
1038
+
1039
+ if result.error:
1040
+ click.echo(f"Error: {result.error}", err=True)
1041
+ raise SystemExit(1)
1042
+
1043
+ if output_json:
1044
+ click.echo(json_module.dumps(asdict(result), indent=2))
1045
+ else:
1046
+ click.echo(f"✓ {result.message}")
1047
+ if result.not_found_ids:
1048
+ click.echo(f" Not found: {', '.join(result.not_found_ids)}")
1049
+
1050
+
1051
+ promote_cmd.name = "promote"
1052
+
1053
+
1054
+ @main.command("reject")
1055
+ @click.argument("skill_ids", nargs=-1, required=True)
1056
+ @click.option("--json", "output_json", is_flag=True, help="Output as JSON")
1057
+ def reject_cmd(skill_ids: tuple[str, ...], output_json: bool):
1058
+ """Mark skills as rejected (false positives).
1059
+
1060
+ Rejected skills won't be suggested for promotion again.
1061
+
1062
+ Examples:
1063
+
1064
+ buildlog reject arch-b0fcb62a1e
1065
+ buildlog reject dk-123 wf-456
1066
+ """
1067
+ import json as json_module
1068
+ from dataclasses import asdict
1069
+
1070
+ buildlog_dir = Path("buildlog")
1071
+
1072
+ if not buildlog_dir.exists():
1073
+ click.echo("No buildlog/ directory found. Run 'buildlog init' first.", err=True)
1074
+ raise SystemExit(1)
1075
+
1076
+ result = reject(buildlog_dir, skill_ids=list(skill_ids))
1077
+
1078
+ if result.error:
1079
+ click.echo(f"Error: {result.error}", err=True)
1080
+ raise SystemExit(1)
1081
+
1082
+ if output_json:
1083
+ click.echo(json_module.dumps(asdict(result), indent=2))
1084
+ else:
1085
+ click.echo(f"✓ Rejected {len(result.rejected_ids)} skills")
1086
+ click.echo(f" Total rejected: {result.total_rejected}")
1087
+
1088
+
1089
+ @main.command("diff")
1090
+ @click.option("--json", "output_json", is_flag=True, help="Output as JSON")
1091
+ def diff_cmd(output_json: bool):
1092
+ """Show skills pending review (not yet promoted or rejected).
1093
+
1094
+ Useful for seeing what's new since the last time you reviewed skills.
1095
+
1096
+ Examples:
1097
+
1098
+ buildlog diff
1099
+ buildlog diff --json
1100
+ """
1101
+ import json as json_module
1102
+ from dataclasses import asdict
1103
+
1104
+ buildlog_dir = Path("buildlog")
1105
+
1106
+ if not buildlog_dir.exists():
1107
+ click.echo("No buildlog/ directory found. Run 'buildlog init' first.", err=True)
1108
+ raise SystemExit(1)
1109
+
1110
+ result = core_diff(buildlog_dir)
1111
+
1112
+ if result.error:
1113
+ click.echo(f"Error: {result.error}", err=True)
1114
+ raise SystemExit(1)
1115
+
1116
+ if output_json:
1117
+ click.echo(json_module.dumps(asdict(result), indent=2))
1118
+ else:
1119
+ click.echo(
1120
+ f"Pending: {result.total_pending} | "
1121
+ f"Promoted: {result.already_promoted} | "
1122
+ f"Rejected: {result.already_rejected}"
1123
+ )
1124
+ click.echo()
1125
+ for category, skills in result.pending.items():
1126
+ if not skills:
1127
+ continue
1128
+ click.echo(f" {category} ({len(skills)})")
1129
+ for s in skills:
1130
+ conf = s.get("confidence", "?")
1131
+ click.echo(f" [{conf}] {s['id']}: {s['rule'][:70]}")
1132
+
1133
+
587
1134
  # -----------------------------------------------------------------------------
588
1135
  # Experiment Commands (Session Tracking for Issue #21)
589
1136
  # -----------------------------------------------------------------------------
@@ -708,7 +1255,7 @@ def experiment_end(
708
1255
 
709
1256
  @experiment.command("log-mistake")
710
1257
  @click.option(
711
- "--class",
1258
+ "--error-class",
712
1259
  "error_class",
713
1260
  required=True,
714
1261
  help="Error class (e.g., 'missing_test', 'validation_boundary')",
@@ -739,8 +1286,8 @@ def experiment_log_mistake(
739
1286
 
740
1287
  Examples:
741
1288
 
742
- buildlog experiment log-mistake --class missing_test -d "Forgot tests"
743
- buildlog experiment log-mistake --class validation -d "No max length" -r val-123
1289
+ buildlog experiment log-mistake --error-class missing_test -d "Forgot tests"
1290
+ buildlog experiment log-mistake --error-class validation -d "No max length" -r val-123
744
1291
  """
745
1292
  import json as json_module
746
1293
  from dataclasses import asdict
@@ -885,6 +1432,7 @@ PERSONAS = {
885
1432
  "security_karen": "OWASP Top 10 security review",
886
1433
  "test_terrorist": "Comprehensive testing coverage audit",
887
1434
  "ruthless_reviewer": "Code quality and functional principles",
1435
+ "bragi": "Detect and flag LLM-ish prose patterns in markdown",
888
1436
  }
889
1437
 
890
1438
 
@@ -1264,6 +1812,69 @@ def gauntlet_learn(issues_file: str, source: str | None, output_json: bool):
1264
1812
  click.echo(f" Total processed: {result.total_issues_processed}")
1265
1813
 
1266
1814
 
1815
+ @gauntlet.command("generate")
1816
+ @click.argument("source_text", type=click.Path(exists=True))
1817
+ @click.option("--persona", "-p", required=True, help="Persona name for the seed file")
1818
+ @click.option(
1819
+ "--output-dir",
1820
+ "-o",
1821
+ type=click.Path(),
1822
+ default=".buildlog/seeds",
1823
+ help="Output directory for seed YAML",
1824
+ )
1825
+ @click.option("--dry-run", is_flag=True, help="Preview without writing")
1826
+ def gauntlet_generate(source_text: str, persona: str, output_dir: str, dry_run: bool):
1827
+ """Generate seed rules from source text using LLM extraction.
1828
+
1829
+ Runs the seed engine pipeline with LLMExtractor to produce
1830
+ a YAML seed file from arbitrary source content.
1831
+
1832
+ Examples:
1833
+
1834
+ buildlog gauntlet generate docs/security.md --persona security_karen
1835
+ buildlog gauntlet generate notes.txt -p test_terrorist --dry-run
1836
+ """
1837
+ import json as json_module
1838
+
1839
+ from buildlog.llm import get_llm_backend
1840
+ from buildlog.seed_engine import Pipeline, Source, SourceType
1841
+
1842
+ backend = get_llm_backend()
1843
+ if backend is None:
1844
+ click.echo(
1845
+ "No LLM backend available. Install ollama or set ANTHROPIC_API_KEY.",
1846
+ err=True,
1847
+ )
1848
+ raise SystemExit(1)
1849
+
1850
+ content = Path(source_text).read_text()
1851
+ source = Source(
1852
+ name=Path(source_text).stem,
1853
+ url=f"file://{Path(source_text).resolve()}",
1854
+ source_type=SourceType.REFERENCE_DOC,
1855
+ domain=persona.split("_")[0] if "_" in persona else "general",
1856
+ description=content,
1857
+ )
1858
+
1859
+ pipeline = Pipeline.with_llm(
1860
+ persona=persona,
1861
+ backend=backend,
1862
+ source_content={source.url: content},
1863
+ )
1864
+
1865
+ if dry_run:
1866
+ preview = pipeline.dry_run([source])
1867
+ click.echo(json_module.dumps(preview, indent=2))
1868
+ return
1869
+
1870
+ out = Path(output_dir)
1871
+ out.mkdir(parents=True, exist_ok=True)
1872
+ result = pipeline.run([source], output_dir=out)
1873
+ click.echo(f"Generated {result.rule_count} rules for {persona}")
1874
+ if result.output_path:
1875
+ click.echo(f"Seed file: {result.output_path}")
1876
+
1877
+
1267
1878
  @gauntlet.command("loop")
1268
1879
  @click.argument("target", type=click.Path(exists=True))
1269
1880
  @click.option(