buildlog 0.8.0__py3-none-any.whl → 0.10.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 (28) hide show
  1. buildlog/cli.py +491 -30
  2. buildlog/constants.py +121 -0
  3. buildlog/core/__init__.py +44 -0
  4. buildlog/core/operations.py +1189 -13
  5. buildlog/data/seeds/bragi.yaml +61 -0
  6. buildlog/llm.py +51 -4
  7. buildlog/mcp/__init__.py +51 -3
  8. buildlog/mcp/server.py +40 -0
  9. buildlog/mcp/tools.py +526 -12
  10. buildlog/seed_engine/__init__.py +2 -0
  11. buildlog/seed_engine/llm_extractor.py +121 -0
  12. buildlog/seed_engine/pipeline.py +45 -1
  13. {buildlog-0.8.0.data → buildlog-0.10.0.data}/data/share/buildlog/post_gen.py +10 -5
  14. buildlog-0.10.0.data/data/share/buildlog/template/buildlog/.gitkeep +0 -0
  15. buildlog-0.10.0.data/data/share/buildlog/template/buildlog/assets/.gitkeep +0 -0
  16. buildlog-0.10.0.dist-info/METADATA +248 -0
  17. {buildlog-0.8.0.dist-info → buildlog-0.10.0.dist-info}/RECORD +27 -22
  18. buildlog-0.8.0.dist-info/METADATA +0 -151
  19. {buildlog-0.8.0.data → buildlog-0.10.0.data}/data/share/buildlog/copier.yml +0 -0
  20. {buildlog-0.8.0.data/data/share/buildlog/template/buildlog → buildlog-0.10.0.data/data/share/buildlog/template/buildlog/.buildlog}/.gitkeep +0 -0
  21. {buildlog-0.8.0.data/data/share/buildlog/template/buildlog/assets → buildlog-0.10.0.data/data/share/buildlog/template/buildlog/.buildlog/seeds}/.gitkeep +0 -0
  22. {buildlog-0.8.0.data → buildlog-0.10.0.data}/data/share/buildlog/template/buildlog/2026-01-01-example.md +0 -0
  23. {buildlog-0.8.0.data → buildlog-0.10.0.data}/data/share/buildlog/template/buildlog/BUILDLOG_SYSTEM.md +0 -0
  24. {buildlog-0.8.0.data → buildlog-0.10.0.data}/data/share/buildlog/template/buildlog/_TEMPLATE.md +0 -0
  25. {buildlog-0.8.0.data → buildlog-0.10.0.data}/data/share/buildlog/template/buildlog/_TEMPLATE_QUICK.md +0 -0
  26. {buildlog-0.8.0.dist-info → buildlog-0.10.0.dist-info}/WHEEL +0 -0
  27. {buildlog-0.8.0.dist-info → buildlog-0.10.0.dist-info}/entry_points.txt +0 -0
  28. {buildlog-0.8.0.dist-info → buildlog-0.10.0.dist-info}/licenses/LICENSE +0 -0
buildlog/cli.py CHANGED
@@ -50,12 +50,13 @@ def main():
50
50
 
51
51
  @main.command()
52
52
  @click.option("--no-claude-md", is_flag=True, help="Don't update CLAUDE.md")
53
+ @click.option("--no-mcp", is_flag=True, help="Don't register MCP server")
53
54
  @click.option(
54
55
  "--defaults",
55
56
  is_flag=True,
56
57
  help="Use default values for all prompts (non-interactive)",
57
58
  )
58
- def init(no_claude_md: bool, defaults: bool):
59
+ def init(no_claude_md: bool, no_mcp: bool, defaults: bool):
59
60
  """Initialize buildlog in the current directory.
60
61
 
61
62
  Sets up the buildlog/ directory with templates and optionally
@@ -109,23 +110,40 @@ def init(no_claude_md: bool, defaults: bool):
109
110
  click.echo("Failed to initialize buildlog.", err=True)
110
111
  raise SystemExit(1)
111
112
 
113
+ # Ensure .buildlog/ directory exists (copier skips dot-prefixed paths)
114
+ dot_buildlog = buildlog_dir / ".buildlog"
115
+ dot_buildlog.mkdir(exist_ok=True)
116
+ (dot_buildlog / "seeds").mkdir(exist_ok=True)
117
+
112
118
  # Update CLAUDE.md if it exists and user didn't opt out
113
119
  if not no_claude_md:
114
120
  claude_md = Path("CLAUDE.md")
115
121
  if claude_md.exists():
116
122
  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
- )
123
+ if (
124
+ "## buildlog Integration" not in content
125
+ and "## Build Journal" not in content
126
+ ):
127
+ try:
128
+ from buildlog.constants import CLAUDE_MD_BUILDLOG_SECTION
129
+
130
+ section = CLAUDE_MD_BUILDLOG_SECTION
131
+ except ImportError:
132
+ section = (
133
+ "\n## Build Journal\n\n"
134
+ "After completing significant work (features, debugging "
135
+ "sessions, deployments,\n"
136
+ "2+ hour focused sessions), write a build journal entry.\n\n"
137
+ "**Location:** `buildlog/YYYY-MM-DD-{slug}.md`\n"
138
+ "**Template:** `buildlog/_TEMPLATE.md`\n"
139
+ )
126
140
  with open(claude_md, "a") as f:
127
141
  f.write(section)
128
- click.echo("Added Build Journal section to CLAUDE.md")
142
+ click.echo("Added buildlog Integration section to CLAUDE.md")
143
+
144
+ # Register MCP server unless opted out
145
+ if not no_mcp:
146
+ _init_mcp()
129
147
 
130
148
  click.echo("\n✓ buildlog initialized!")
131
149
  click.echo()
@@ -142,12 +160,125 @@ def init(no_claude_md: bool, defaults: bool):
142
160
  click.echo("Start now: buildlog new my-first-task --quick")
143
161
 
144
162
 
163
+ def _init_mcp(settings_path: Path | None = None, global_mode: bool = False) -> None:
164
+ """Register buildlog as an MCP server in settings.json.
165
+
166
+ Args:
167
+ settings_path: Path to settings.json. Defaults to .claude/settings.json
168
+ global_mode: If True, display global-specific messaging
169
+ """
170
+ import json as json_module
171
+
172
+ if settings_path is None:
173
+ settings_path = Path(".claude") / "settings.json"
174
+
175
+ location = "~/.claude/settings.json" if global_mode else ".claude/settings.json"
176
+
177
+ try:
178
+ if settings_path.exists():
179
+ try:
180
+ data = json_module.loads(settings_path.read_text())
181
+ except json_module.JSONDecodeError:
182
+ click.echo(
183
+ f"Warning: {location} is malformed, skipping MCP registration",
184
+ err=True,
185
+ )
186
+ return
187
+ else:
188
+ data = {}
189
+
190
+ if "mcpServers" not in data:
191
+ data["mcpServers"] = {}
192
+
193
+ if "buildlog" in data["mcpServers"]:
194
+ click.echo(f"buildlog MCP server already registered in {location}")
195
+ return
196
+
197
+ data["mcpServers"]["buildlog"] = {"command": "buildlog-mcp", "args": []}
198
+
199
+ settings_path.parent.mkdir(parents=True, exist_ok=True)
200
+ settings_path.write_text(json_module.dumps(data, indent=2) + "\n")
201
+ click.echo(f"Registered buildlog MCP server in {location}")
202
+ if global_mode:
203
+ click.echo("Claude Code now has access to buildlog tools in all projects.")
204
+ except Exception as e:
205
+ click.echo(f"Warning: could not register MCP server: {e}", err=True)
206
+
207
+
208
+ @main.command("init-mcp")
209
+ @click.option(
210
+ "--global",
211
+ "global_",
212
+ is_flag=True,
213
+ help="Register globally in ~/.claude/settings.json (works in any project)",
214
+ )
215
+ def init_mcp(global_: bool):
216
+ """Register buildlog as an MCP server for Claude Code.
217
+
218
+ Creates or updates .claude/settings.json with the buildlog MCP
219
+ server configuration. Idempotent — safe to run multiple times.
220
+
221
+ Use --global to register in ~/.claude/settings.json so buildlog
222
+ tools are available in every project without per-project init.
223
+
224
+ Examples:
225
+
226
+ buildlog init-mcp # local (current project)
227
+ buildlog init-mcp --global # global (all projects)
228
+ """
229
+ if global_:
230
+ settings_path = Path.home() / ".claude" / "settings.json"
231
+ _init_mcp(settings_path=settings_path, global_mode=True)
232
+ else:
233
+ _init_mcp()
234
+
235
+
236
+ @main.command("mcp-test")
237
+ def mcp_test():
238
+ """Verify the MCP server starts and all tools are registered.
239
+
240
+ Checks that the buildlog-mcp server can be imported and lists
241
+ all registered tools. Exits 0 if all 29 tools are found, 1 otherwise.
242
+
243
+ Examples:
244
+
245
+ buildlog mcp-test
246
+ """
247
+ try:
248
+ from buildlog.mcp.server import mcp as mcp_server
249
+ except ImportError:
250
+ click.echo("MCP not installed. Run: pip install buildlog", err=True)
251
+ raise SystemExit(1)
252
+
253
+ try:
254
+ # FastMCP stores tools internally
255
+ tools = mcp_server._tool_manager._tools
256
+ tool_names = sorted(tools.keys())
257
+ except AttributeError:
258
+ # Fallback: try to count via the public API pattern
259
+ click.echo("Warning: could not inspect tools via internal API", err=True)
260
+ tool_names = []
261
+
262
+ expected = 29
263
+ click.echo(f"buildlog MCP server: {len(tool_names)} tools registered")
264
+ for name in tool_names:
265
+ click.echo(f" {name}")
266
+
267
+ if len(tool_names) >= expected:
268
+ click.echo(f"\nAll {expected} tools registered.")
269
+ raise SystemExit(0)
270
+ else:
271
+ click.echo(f"\nExpected {expected} tools, found {len(tool_names)}.", err=True)
272
+ raise SystemExit(1)
273
+
274
+
145
275
  @main.command()
146
276
  @click.option("--json", "output_json", is_flag=True, help="Output as JSON")
147
277
  def overview(output_json: bool):
148
278
  """Show the full state of your buildlog at a glance.
149
279
 
150
280
  Entries, skills, promoted rules, experiments — everything in one view.
281
+ Works even without buildlog init (shows uninitialized state).
151
282
 
152
283
  Examples:
153
284
 
@@ -158,9 +289,38 @@ def overview(output_json: bool):
158
289
 
159
290
  buildlog_dir = Path("buildlog")
160
291
 
292
+ # Handle uninitialized state gracefully
161
293
  if not buildlog_dir.exists():
162
- click.echo("No buildlog/ directory found. Run 'buildlog init' first.", err=True)
163
- raise SystemExit(1)
294
+ result = {
295
+ "initialized": False,
296
+ "entries": 0,
297
+ "skills": {
298
+ "total": 0,
299
+ "by_confidence": {},
300
+ "promoted": 0,
301
+ "rejected": 0,
302
+ "pending": 0,
303
+ },
304
+ "active_session": None,
305
+ "render_targets": [],
306
+ "message": "buildlog not initialized. Run 'buildlog init' to enable full features.",
307
+ }
308
+ if output_json:
309
+ click.echo(json_module.dumps(result, indent=2))
310
+ else:
311
+ click.echo("buildlog overview")
312
+ click.echo("=" * 40)
313
+ click.echo(" Status: Not initialized")
314
+ click.echo()
315
+ click.echo("Get started:")
316
+ click.echo(" buildlog init --defaults # Initialize buildlog")
317
+ click.echo()
318
+ click.echo("Or use globally without init:")
319
+ click.echo(" buildlog init-mcp --global # Register MCP server globally")
320
+ click.echo(
321
+ " buildlog gauntlet list # Review personas work without init"
322
+ )
323
+ return
164
324
 
165
325
  # Count entries
166
326
  entries = sorted(buildlog_dir.glob("20??-??-??-*.md"))
@@ -208,6 +368,7 @@ def overview(output_json: bool):
208
368
  from buildlog.render import RENDERERS
209
369
 
210
370
  result = {
371
+ "initialized": True,
211
372
  "entries": len(entries),
212
373
  "skills": {
213
374
  "total": total_skills,
@@ -298,11 +459,11 @@ def new(slug: str, entry_date: str | None, quick: bool):
298
459
  # Determine date
299
460
  if entry_date:
300
461
  try:
301
- # Validate date format
302
- year, month, day = entry_date.split("-")
303
- date_str = f"{int(year):04d}-{int(month):02d}-{int(day):02d}"
462
+ # Validate date format AND range (month 1-12, day 1-31)
463
+ parsed = datetime.strptime(entry_date, "%Y-%m-%d").date()
464
+ date_str = parsed.isoformat()
304
465
  except ValueError:
305
- click.echo("Invalid date format. Use YYYY-MM-DD.", err=True)
466
+ click.echo("Invalid date. Use YYYY-MM-DD with valid values.", err=True)
306
467
  raise SystemExit(1)
307
468
  else:
308
469
  date_str = date.today().isoformat()
@@ -331,6 +492,161 @@ def new(slug: str, entry_date: str | None, quick: bool):
331
492
  click.echo(f"\nOpen it: $EDITOR {entry_path}")
332
493
 
333
494
 
495
+ @main.command(
496
+ context_settings={"ignore_unknown_options": True, "allow_extra_args": True}
497
+ )
498
+ @click.option(
499
+ "--slug",
500
+ "-s",
501
+ default=None,
502
+ help="Entry slug (default: derived from branch name)",
503
+ )
504
+ @click.option(
505
+ "--entry",
506
+ "-e",
507
+ type=click.Path(),
508
+ default=None,
509
+ help="Explicit entry file to append to",
510
+ )
511
+ @click.option(
512
+ "--no-entry",
513
+ is_flag=True,
514
+ help="Skip buildlog entry update (just run git commit)",
515
+ )
516
+ @click.pass_context
517
+ def commit(ctx, slug: str | None, entry: str | None, no_entry: bool):
518
+ """Commit code and update the buildlog entry in one step.
519
+
520
+ Wraps `git commit` and appends commit context to today's buildlog
521
+ entry. If no entry exists for today, creates one automatically.
522
+
523
+ All unknown options/arguments are passed through to git commit.
524
+
525
+ Examples:
526
+
527
+ buildlog commit -m "feat: add LLM extractor"
528
+ buildlog commit --slug llm-extractor -m "feat: add LLM extractor"
529
+ buildlog commit --no-entry -m "chore: formatting"
530
+ """
531
+ buildlog_dir = Path("buildlog")
532
+
533
+ # Build git commit command — extra args are passed through from Click context
534
+ git_cmd = ["git", "commit", *ctx.args]
535
+
536
+ # Run git commit first
537
+ result = subprocess.run(git_cmd, capture_output=True, text=True)
538
+ sys.stdout.write(result.stdout)
539
+ sys.stderr.write(result.stderr)
540
+
541
+ if result.returncode != 0:
542
+ raise SystemExit(result.returncode)
543
+
544
+ if no_entry or not buildlog_dir.exists():
545
+ return
546
+
547
+ # Get commit info from what we just committed
548
+ try:
549
+ commit_hash = subprocess.run(
550
+ ["git", "rev-parse", "--short", "HEAD"],
551
+ capture_output=True,
552
+ text=True,
553
+ check=True,
554
+ ).stdout.strip()
555
+ commit_msg = subprocess.run(
556
+ ["git", "log", "-1", "--format=%s"],
557
+ capture_output=True,
558
+ text=True,
559
+ check=True,
560
+ ).stdout.strip()
561
+ # diff-tree needs special handling for root commit
562
+ diff_result = subprocess.run(
563
+ ["git", "diff-tree", "--no-commit-id", "--name-only", "-r", "HEAD"],
564
+ capture_output=True,
565
+ text=True,
566
+ )
567
+ if diff_result.returncode == 0 and diff_result.stdout.strip():
568
+ files_changed = diff_result.stdout.strip()
569
+ else:
570
+ # Root commit fallback
571
+ files_changed = subprocess.run(
572
+ ["git", "diff", "--name-only", "--cached", "HEAD~1"],
573
+ capture_output=True,
574
+ text=True,
575
+ ).stdout.strip()
576
+ if not files_changed:
577
+ # Truly initial commit — list all tracked files
578
+ files_changed = subprocess.run(
579
+ ["git", "ls-tree", "--name-only", "-r", "HEAD"],
580
+ capture_output=True,
581
+ text=True,
582
+ ).stdout.strip()
583
+ except subprocess.CalledProcessError:
584
+ click.echo("Warning: could not read commit info", err=True)
585
+ return
586
+
587
+ # Resolve entry file
588
+ today = date.today().isoformat()
589
+ entry_path = _resolve_entry_path(buildlog_dir, today, slug, entry)
590
+
591
+ # Append commit block
592
+ commit_block = f"\n### `{commit_hash}` — {commit_msg}\n\n"
593
+ if files_changed:
594
+ file_list = files_changed.split("\n")
595
+ commit_block += "Files:\n"
596
+ for f in file_list[:20]: # cap at 20 to avoid noise
597
+ commit_block += f"- `{f}`\n"
598
+ if len(file_list) > 20:
599
+ commit_block += f"- ...and {len(file_list) - 20} more\n"
600
+ commit_block += "\n"
601
+
602
+ # Ensure commits section exists, append to it
603
+ if entry_path.exists():
604
+ content = entry_path.read_text()
605
+ if "## Commits" not in content:
606
+ content = content.rstrip() + "\n\n## Commits\n"
607
+ content += commit_block
608
+ else:
609
+ # Auto-create minimal entry
610
+ content = f"# {today}\n\n## Commits\n{commit_block}"
611
+
612
+ entry_path.write_text(content)
613
+ click.echo(f"buildlog: updated {entry_path}")
614
+
615
+
616
+ def _resolve_entry_path(
617
+ buildlog_dir: Path, today: str, slug: str | None, explicit: str | None
618
+ ) -> Path:
619
+ """Find or create the entry path for today."""
620
+ if explicit:
621
+ return Path(explicit)
622
+
623
+ # Check for existing entry with today's date
624
+ existing = list(buildlog_dir.glob(f"{today}-*.md"))
625
+ if existing:
626
+ return existing[0]
627
+
628
+ # Derive slug from branch name if not provided
629
+ if slug is None:
630
+ try:
631
+ branch = subprocess.run(
632
+ ["git", "branch", "--show-current"],
633
+ capture_output=True,
634
+ text=True,
635
+ check=True,
636
+ ).stdout.strip()
637
+ # Clean branch name into slug
638
+ slug = branch.split("/")[-1] # strip prefix like feat/
639
+ slug = slug.lower().replace("_", "-")
640
+ slug = "".join(c for c in slug if c.isalnum() or c == "-")
641
+ except subprocess.CalledProcessError:
642
+ slug = "session"
643
+
644
+ if not slug:
645
+ slug = "session"
646
+
647
+ return buildlog_dir / f"{today}-{slug}.md"
648
+
649
+
334
650
  @main.command("list")
335
651
  def list_entries():
336
652
  """List all buildlog entries."""
@@ -431,7 +747,7 @@ def distill(
431
747
  """Extract patterns from all buildlog entries.
432
748
 
433
749
  Parses the Improvements section of each buildlog entry and aggregates
434
- insights into structured output (JSON or YAML).
750
+ insights into structured output (JSON or YAML). Returns empty result if not initialized.
435
751
 
436
752
  Examples:
437
753
 
@@ -441,11 +757,25 @@ def distill(
441
757
  buildlog distill --since 2026-01-01 # Filter by date
442
758
  buildlog distill --category workflow # Filter by category
443
759
  """
760
+ import json as json_module
761
+
444
762
  buildlog_dir = Path("buildlog")
445
763
 
764
+ # Handle uninitialized state gracefully
446
765
  if not buildlog_dir.exists():
447
- click.echo("No buildlog/ directory found. Run 'buildlog init' first.", err=True)
448
- raise SystemExit(1)
766
+ empty_result = {
767
+ "initialized": False,
768
+ "patterns": [],
769
+ "statistics": {"total_patterns": 0, "total_entries": 0},
770
+ "message": "buildlog not initialized",
771
+ }
772
+ if fmt == "json":
773
+ click.echo(json_module.dumps(empty_result, indent=2))
774
+ else:
775
+ click.echo(
776
+ "# buildlog not initialized - run 'buildlog init' first\npatterns: []"
777
+ )
778
+ return
449
779
 
450
780
  # Convert datetime to date if provided
451
781
  since_date = since.date() if since else None
@@ -496,6 +826,7 @@ def stats(output_json: bool, detailed: bool, since_date: str | None):
496
826
  """Show buildlog statistics and analytics.
497
827
 
498
828
  Provides insights on buildlog usage, coverage, and quality.
829
+ Returns empty stats if not initialized.
499
830
 
500
831
  Examples:
501
832
 
@@ -504,11 +835,27 @@ def stats(output_json: bool, detailed: bool, since_date: str | None):
504
835
  buildlog stats --detailed # Include top sources
505
836
  buildlog stats --since 2026-01-01
506
837
  """
838
+ import json as json_module
839
+
507
840
  buildlog_dir = Path("buildlog")
508
841
 
842
+ # Handle uninitialized state gracefully
509
843
  if not buildlog_dir.exists():
510
- click.echo("No buildlog/ directory found. Run 'buildlog init' first.", err=True)
511
- raise SystemExit(1)
844
+ empty_stats = {
845
+ "initialized": False,
846
+ "total_entries": 0,
847
+ "total_patterns": 0,
848
+ "categories": {},
849
+ "date_range": None,
850
+ "message": "buildlog not initialized",
851
+ }
852
+ if output_json:
853
+ click.echo(json_module.dumps(empty_stats, indent=2))
854
+ else:
855
+ click.echo("buildlog stats")
856
+ click.echo("=" * 40)
857
+ click.echo(" Not initialized. Run 'buildlog init' first.")
858
+ return
512
859
 
513
860
  # Parse since date if provided
514
861
  parsed_since = None
@@ -571,7 +918,7 @@ def skills(
571
918
  """Generate agent-consumable skills from buildlog patterns.
572
919
 
573
920
  Transforms distilled patterns into actionable rules with deduplication,
574
- confidence scoring, and stable IDs.
921
+ confidence scoring, and stable IDs. Returns empty set if not initialized.
575
922
 
576
923
  Examples:
577
924
 
@@ -588,9 +935,31 @@ def skills(
588
935
  """
589
936
  buildlog_dir = Path("buildlog")
590
937
 
938
+ # Handle uninitialized state gracefully - return empty skill set
591
939
  if not buildlog_dir.exists():
592
- click.echo("No buildlog/ directory found. Run 'buildlog init' first.", err=True)
593
- raise SystemExit(1)
940
+ if fmt == "json":
941
+ import json as json_module
942
+
943
+ click.echo(
944
+ json_module.dumps(
945
+ {
946
+ "initialized": False,
947
+ "skills": {},
948
+ "total_skills": 0,
949
+ "message": "buildlog not initialized",
950
+ },
951
+ indent=2,
952
+ )
953
+ )
954
+ elif fmt == "yaml":
955
+ click.echo(
956
+ "# buildlog not initialized - run 'buildlog init' first\nskills: {}\ntotal_skills: 0"
957
+ )
958
+ else:
959
+ click.echo(
960
+ "No buildlog/ directory found. Run 'buildlog init' to extract skills."
961
+ )
962
+ return
594
963
 
595
964
  # Convert datetime to date if provided
596
965
  since_date = since.date() if since else None
@@ -780,7 +1149,7 @@ def status_cmd(min_confidence: str, output_json: bool):
780
1149
  """Show extracted skills by category and confidence.
781
1150
 
782
1151
  Displays all skills extracted from buildlog entries, grouped by category,
783
- with confidence levels and promotion status.
1152
+ with confidence levels and promotion status. Returns empty state if not initialized.
784
1153
 
785
1154
  Examples:
786
1155
 
@@ -793,9 +1162,23 @@ def status_cmd(min_confidence: str, output_json: bool):
793
1162
 
794
1163
  buildlog_dir = Path("buildlog")
795
1164
 
1165
+ # Handle uninitialized state gracefully
796
1166
  if not buildlog_dir.exists():
797
- click.echo("No buildlog/ directory found. Run 'buildlog init' first.", err=True)
798
- raise SystemExit(1)
1167
+ empty_result = {
1168
+ "initialized": False,
1169
+ "total_skills": 0,
1170
+ "total_entries": 0,
1171
+ "skills": {},
1172
+ "by_confidence": {"high": 0, "medium": 0, "low": 0},
1173
+ "promotable_ids": [],
1174
+ "error": None,
1175
+ }
1176
+ if output_json:
1177
+ click.echo(json_module.dumps(empty_result, indent=2))
1178
+ else:
1179
+ click.echo("Skills: 0 total from 0 entries")
1180
+ click.echo(" buildlog not initialized. Run 'buildlog init' first.")
1181
+ return
799
1182
 
800
1183
  result = status(buildlog_dir, min_confidence=min_confidence) # type: ignore[arg-type]
801
1184
 
@@ -937,6 +1320,7 @@ def diff_cmd(output_json: bool):
937
1320
  """Show skills pending review (not yet promoted or rejected).
938
1321
 
939
1322
  Useful for seeing what's new since the last time you reviewed skills.
1323
+ Returns empty diff if not initialized.
940
1324
 
941
1325
  Examples:
942
1326
 
@@ -948,9 +1332,22 @@ def diff_cmd(output_json: bool):
948
1332
 
949
1333
  buildlog_dir = Path("buildlog")
950
1334
 
1335
+ # Handle uninitialized state gracefully
951
1336
  if not buildlog_dir.exists():
952
- click.echo("No buildlog/ directory found. Run 'buildlog init' first.", err=True)
953
- raise SystemExit(1)
1337
+ empty_result = {
1338
+ "initialized": False,
1339
+ "pending": {},
1340
+ "total_pending": 0,
1341
+ "already_promoted": 0,
1342
+ "already_rejected": 0,
1343
+ "error": None,
1344
+ }
1345
+ if output_json:
1346
+ click.echo(json_module.dumps(empty_result, indent=2))
1347
+ else:
1348
+ click.echo("Pending: 0 | Promoted: 0 | Rejected: 0")
1349
+ click.echo(" buildlog not initialized. Run 'buildlog init' first.")
1350
+ return
954
1351
 
955
1352
  result = core_diff(buildlog_dir)
956
1353
 
@@ -1277,6 +1674,7 @@ PERSONAS = {
1277
1674
  "security_karen": "OWASP Top 10 security review",
1278
1675
  "test_terrorist": "Comprehensive testing coverage audit",
1279
1676
  "ruthless_reviewer": "Code quality and functional principles",
1677
+ "bragi": "Detect and flag LLM-ish prose patterns in markdown",
1280
1678
  }
1281
1679
 
1282
1680
 
@@ -1656,6 +2054,69 @@ def gauntlet_learn(issues_file: str, source: str | None, output_json: bool):
1656
2054
  click.echo(f" Total processed: {result.total_issues_processed}")
1657
2055
 
1658
2056
 
2057
+ @gauntlet.command("generate")
2058
+ @click.argument("source_text", type=click.Path(exists=True))
2059
+ @click.option("--persona", "-p", required=True, help="Persona name for the seed file")
2060
+ @click.option(
2061
+ "--output-dir",
2062
+ "-o",
2063
+ type=click.Path(),
2064
+ default=".buildlog/seeds",
2065
+ help="Output directory for seed YAML",
2066
+ )
2067
+ @click.option("--dry-run", is_flag=True, help="Preview without writing")
2068
+ def gauntlet_generate(source_text: str, persona: str, output_dir: str, dry_run: bool):
2069
+ """Generate seed rules from source text using LLM extraction.
2070
+
2071
+ Runs the seed engine pipeline with LLMExtractor to produce
2072
+ a YAML seed file from arbitrary source content.
2073
+
2074
+ Examples:
2075
+
2076
+ buildlog gauntlet generate docs/security.md --persona security_karen
2077
+ buildlog gauntlet generate notes.txt -p test_terrorist --dry-run
2078
+ """
2079
+ import json as json_module
2080
+
2081
+ from buildlog.llm import get_llm_backend
2082
+ from buildlog.seed_engine import Pipeline, Source, SourceType
2083
+
2084
+ backend = get_llm_backend()
2085
+ if backend is None:
2086
+ click.echo(
2087
+ "No LLM backend available. Install ollama or set ANTHROPIC_API_KEY.",
2088
+ err=True,
2089
+ )
2090
+ raise SystemExit(1)
2091
+
2092
+ content = Path(source_text).read_text()
2093
+ source = Source(
2094
+ name=Path(source_text).stem,
2095
+ url=f"file://{Path(source_text).resolve()}",
2096
+ source_type=SourceType.REFERENCE_DOC,
2097
+ domain=persona.split("_")[0] if "_" in persona else "general",
2098
+ description=content,
2099
+ )
2100
+
2101
+ pipeline = Pipeline.with_llm(
2102
+ persona=persona,
2103
+ backend=backend,
2104
+ source_content={source.url: content},
2105
+ )
2106
+
2107
+ if dry_run:
2108
+ preview = pipeline.dry_run([source])
2109
+ click.echo(json_module.dumps(preview, indent=2))
2110
+ return
2111
+
2112
+ out = Path(output_dir)
2113
+ out.mkdir(parents=True, exist_ok=True)
2114
+ result = pipeline.run([source], output_dir=out)
2115
+ click.echo(f"Generated {result.rule_count} rules for {persona}")
2116
+ if result.output_path:
2117
+ click.echo(f"Seed file: {result.output_path}")
2118
+
2119
+
1659
2120
  @gauntlet.command("loop")
1660
2121
  @click.argument("target", type=click.Path(exists=True))
1661
2122
  @click.option(