buildlog 0.4.0__py3-none-any.whl → 0.6.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 +799 -3
  2. buildlog/core/__init__.py +34 -0
  3. buildlog/core/operations.py +925 -0
  4. buildlog/mcp/server.py +16 -0
  5. buildlog/mcp/tools.py +266 -1
  6. buildlog/seed_engine/__init__.py +74 -0
  7. buildlog/seed_engine/categorizers.py +145 -0
  8. buildlog/seed_engine/extractors.py +148 -0
  9. buildlog/seed_engine/generators.py +144 -0
  10. buildlog/seed_engine/models.py +113 -0
  11. buildlog/seed_engine/pipeline.py +202 -0
  12. buildlog/seed_engine/sources.py +362 -0
  13. buildlog/seeds.py +211 -0
  14. buildlog/skills.py +26 -3
  15. buildlog-0.6.0.dist-info/METADATA +490 -0
  16. buildlog-0.6.0.dist-info/RECORD +38 -0
  17. buildlog-0.4.0.dist-info/METADATA +0 -894
  18. buildlog-0.4.0.dist-info/RECORD +0 -30
  19. {buildlog-0.4.0.data → buildlog-0.6.0.data}/data/share/buildlog/copier.yml +0 -0
  20. {buildlog-0.4.0.data → buildlog-0.6.0.data}/data/share/buildlog/post_gen.py +0 -0
  21. {buildlog-0.4.0.data → buildlog-0.6.0.data}/data/share/buildlog/template/buildlog/.gitkeep +0 -0
  22. {buildlog-0.4.0.data → buildlog-0.6.0.data}/data/share/buildlog/template/buildlog/2026-01-01-example.md +0 -0
  23. {buildlog-0.4.0.data → buildlog-0.6.0.data}/data/share/buildlog/template/buildlog/BUILDLOG_SYSTEM.md +0 -0
  24. {buildlog-0.4.0.data → buildlog-0.6.0.data}/data/share/buildlog/template/buildlog/_TEMPLATE.md +0 -0
  25. {buildlog-0.4.0.data → buildlog-0.6.0.data}/data/share/buildlog/template/buildlog/assets/.gitkeep +0 -0
  26. {buildlog-0.4.0.dist-info → buildlog-0.6.0.dist-info}/WHEEL +0 -0
  27. {buildlog-0.4.0.dist-info → buildlog-0.6.0.dist-info}/entry_points.txt +0 -0
  28. {buildlog-0.4.0.dist-info → buildlog-0.6.0.dist-info}/licenses/LICENSE +0 -0
buildlog/cli.py CHANGED
@@ -8,6 +8,7 @@ from pathlib import Path
8
8
 
9
9
  import click
10
10
 
11
+ from buildlog.core import get_rewards, log_reward
11
12
  from buildlog.distill import CATEGORIES, distill_all, format_output
12
13
  from buildlog.skills import format_skills, generate_skills
13
14
  from buildlog.stats import calculate_stats, format_dashboard, format_json
@@ -171,8 +172,8 @@ def new(slug: str, entry_date: str | None):
171
172
  click.echo(f"\nOpen it: $EDITOR {entry_path}")
172
173
 
173
174
 
174
- @main.command()
175
- def list():
175
+ @main.command("list")
176
+ def list_entries():
176
177
  """List all buildlog entries."""
177
178
  buildlog_dir = Path("buildlog")
178
179
 
@@ -181,7 +182,8 @@ def list():
181
182
  raise SystemExit(1)
182
183
 
183
184
  entries = sorted(
184
- buildlog_dir.glob("20??-??-??-*.md"), reverse=True # Most recent first
185
+ buildlog_dir.glob("20??-??-??-*.md"),
186
+ reverse=True, # Most recent first
185
187
  )
186
188
 
187
189
  if not entries:
@@ -456,5 +458,799 @@ def skills(
456
458
  click.echo(formatted)
457
459
 
458
460
 
461
+ @main.command()
462
+ @click.argument("outcome", type=click.Choice(["accepted", "revision", "rejected"]))
463
+ @click.option(
464
+ "--distance",
465
+ "-d",
466
+ type=float,
467
+ help="Revision distance (0-1, 0=minor tweak, 1=complete redo)",
468
+ )
469
+ @click.option("--error-class", "-e", help="Category of error (e.g., missing_test)")
470
+ @click.option("--notes", "-n", help="Additional notes about the feedback")
471
+ @click.option("--rules", "-r", multiple=True, help="Active rule IDs")
472
+ @click.option("--json", "output_json", is_flag=True, help="Output as JSON")
473
+ def reward(
474
+ outcome: str,
475
+ distance: float | None,
476
+ error_class: str | None,
477
+ notes: str | None,
478
+ rules: tuple[str, ...],
479
+ output_json: bool,
480
+ ):
481
+ """Log a reward signal for the learning loop.
482
+
483
+ Used to provide feedback on agent work for bandit learning.
484
+
485
+ OUTCOME is one of:
486
+ - accepted: Work was accepted as-is (reward=1.0)
487
+ - revision: Work needed changes (reward=1-distance)
488
+ - rejected: Work was rejected entirely (reward=0.0)
489
+
490
+ Examples:
491
+
492
+ buildlog reward accepted
493
+ buildlog reward revision --distance 0.3 --error-class missing_test
494
+ buildlog reward rejected --notes "Completely wrong approach"
495
+ buildlog reward accepted --rules arch-123 --rules wf-456
496
+ """
497
+ import json as json_module
498
+ from dataclasses import asdict
499
+
500
+ buildlog_dir = Path("buildlog")
501
+
502
+ if not buildlog_dir.exists():
503
+ click.echo("No buildlog/ directory found. Run 'buildlog init' first.", err=True)
504
+ raise SystemExit(1)
505
+
506
+ result = log_reward(
507
+ buildlog_dir,
508
+ outcome=outcome, # type: ignore[arg-type]
509
+ rules_active=list(rules) if rules else None,
510
+ revision_distance=distance,
511
+ error_class=error_class,
512
+ notes=notes,
513
+ source="cli",
514
+ )
515
+
516
+ if output_json:
517
+ click.echo(json_module.dumps(asdict(result), indent=2))
518
+ else:
519
+ click.echo(f"✓ {result.message}")
520
+ click.echo(f" Reward ID: {result.reward_id}")
521
+ click.echo(f" Total events: {result.total_events}")
522
+
523
+
524
+ @main.command()
525
+ @click.option("--limit", "-n", type=int, help="Limit number of events to show")
526
+ @click.option("--json", "output_json", is_flag=True, help="Output as JSON")
527
+ def rewards(limit: int | None, output_json: bool):
528
+ """List reward events and summary statistics.
529
+
530
+ Shows recent reward events and aggregate statistics useful for
531
+ tracking learning progress.
532
+
533
+ Examples:
534
+
535
+ buildlog rewards # Show all with summary
536
+ buildlog rewards --limit 10 # Show 10 most recent
537
+ buildlog rewards --json # JSON output for scripts
538
+ """
539
+ import json as json_module
540
+
541
+ buildlog_dir = Path("buildlog")
542
+
543
+ if not buildlog_dir.exists():
544
+ click.echo("No buildlog/ directory found. Run 'buildlog init' first.", err=True)
545
+ raise SystemExit(1)
546
+
547
+ summary = get_rewards(buildlog_dir, limit=limit)
548
+
549
+ if output_json:
550
+ data = {
551
+ "total_events": summary.total_events,
552
+ "accepted": summary.accepted,
553
+ "revisions": summary.revisions,
554
+ "rejected": summary.rejected,
555
+ "mean_reward": summary.mean_reward,
556
+ "events": [e.to_dict() for e in summary.events],
557
+ }
558
+ click.echo(json_module.dumps(data, indent=2))
559
+ else:
560
+ # Summary header
561
+ click.echo("Reward Signal Summary")
562
+ click.echo("=" * 40)
563
+ click.echo(f"Total events: {summary.total_events}")
564
+ click.echo(f" Accepted: {summary.accepted}")
565
+ click.echo(f" Revisions: {summary.revisions}")
566
+ click.echo(f" Rejected: {summary.rejected}")
567
+ click.echo(f"Mean reward: {summary.mean_reward:.3f}")
568
+ click.echo()
569
+
570
+ if summary.events:
571
+ click.echo("Recent Events")
572
+ click.echo("-" * 40)
573
+ for event in summary.events:
574
+ ts = event.timestamp.strftime("%Y-%m-%d %H:%M")
575
+ outcome_str = event.outcome.upper()
576
+ reward_str = f"r={event.reward_value:.2f}"
577
+ click.echo(f" [{ts}] {outcome_str} ({reward_str})")
578
+ if event.error_class:
579
+ click.echo(f" error_class: {event.error_class}")
580
+ if event.notes:
581
+ click.echo(f" notes: {event.notes}")
582
+ else:
583
+ click.echo("No reward events yet.")
584
+ click.echo("Log your first with: buildlog reward accepted")
585
+
586
+
587
+ # -----------------------------------------------------------------------------
588
+ # Experiment Commands (Session Tracking for Issue #21)
589
+ # -----------------------------------------------------------------------------
590
+
591
+
592
+ @main.group()
593
+ def experiment():
594
+ """Commands for running learning experiments.
595
+
596
+ Track sessions, log mistakes, and measure repeated-mistake rates
597
+ to evaluate buildlog's effectiveness.
598
+
599
+ Example workflow:
600
+
601
+ buildlog experiment start --error-class missing_test
602
+ # ... do work, log mistakes as you encounter them ...
603
+ buildlog experiment log-mistake --class missing_test --description "..."
604
+ buildlog experiment end
605
+ buildlog experiment report
606
+ """
607
+ pass
608
+
609
+
610
+ @experiment.command("start")
611
+ @click.option(
612
+ "--error-class",
613
+ "-e",
614
+ help="Error class being targeted (e.g., 'missing_test')",
615
+ )
616
+ @click.option("--notes", "-n", help="Notes about this session")
617
+ @click.option("--json", "output_json", is_flag=True, help="Output as JSON")
618
+ def experiment_start(
619
+ error_class: str | None,
620
+ notes: str | None,
621
+ output_json: bool,
622
+ ):
623
+ """Start a new experiment session.
624
+
625
+ This begins tracking for a learning experiment. Captures the current
626
+ set of active rules to measure learning over time.
627
+
628
+ Examples:
629
+
630
+ buildlog experiment start
631
+ buildlog experiment start --error-class missing_test
632
+ buildlog experiment start --error-class validation_boundary --notes "Testing edge cases"
633
+ """
634
+ import json as json_module
635
+ from dataclasses import asdict
636
+
637
+ from buildlog.core import start_session
638
+
639
+ buildlog_dir = Path("buildlog")
640
+
641
+ if not buildlog_dir.exists():
642
+ click.echo("No buildlog/ directory found. Run 'buildlog init' first.", err=True)
643
+ raise SystemExit(1)
644
+
645
+ try:
646
+ result = start_session(buildlog_dir, error_class=error_class, notes=notes)
647
+ except ValueError as e:
648
+ click.echo(f"Error: {e}", err=True)
649
+ raise SystemExit(1)
650
+
651
+ if output_json:
652
+ click.echo(json_module.dumps(asdict(result), indent=2))
653
+ else:
654
+ click.echo(f"✓ {result.message}")
655
+ if error_class:
656
+ click.echo(f" Error class: {error_class}")
657
+
658
+
659
+ @experiment.command("end")
660
+ @click.option("--entry-file", "-f", help="Corresponding buildlog entry file")
661
+ @click.option("--notes", "-n", help="Additional notes about this session")
662
+ @click.option("--json", "output_json", is_flag=True, help="Output as JSON")
663
+ def experiment_end(
664
+ entry_file: str | None,
665
+ notes: str | None,
666
+ output_json: bool,
667
+ ):
668
+ """End the current experiment session.
669
+
670
+ Finalizes the session and calculates metrics including:
671
+ - Total mistakes logged
672
+ - Repeated mistakes (from prior sessions)
673
+ - Rules added during session
674
+
675
+ Examples:
676
+
677
+ buildlog experiment end
678
+ buildlog experiment end --entry-file 2026-01-21.md
679
+ buildlog experiment end --notes "Good session, learned 2 new rules"
680
+ """
681
+ import json as json_module
682
+ from dataclasses import asdict
683
+
684
+ from buildlog.core import end_session
685
+
686
+ buildlog_dir = Path("buildlog")
687
+
688
+ if not buildlog_dir.exists():
689
+ click.echo("No buildlog/ directory found. Run 'buildlog init' first.", err=True)
690
+ raise SystemExit(1)
691
+
692
+ try:
693
+ result = end_session(buildlog_dir, entry_file=entry_file, notes=notes)
694
+ except ValueError as e:
695
+ click.echo(f"Error: {e}", err=True)
696
+ raise SystemExit(1)
697
+
698
+ if output_json:
699
+ click.echo(json_module.dumps(asdict(result), indent=2))
700
+ else:
701
+ click.echo(f"✓ {result.message}")
702
+ click.echo(f" Duration: {result.duration_minutes} minutes")
703
+ click.echo(
704
+ f" Mistakes: {result.mistakes_logged} ({result.repeated_mistakes} repeats)"
705
+ )
706
+ click.echo(f" Rules: {result.rules_at_start} → {result.rules_at_end}")
707
+
708
+
709
+ @experiment.command("log-mistake")
710
+ @click.option(
711
+ "--class",
712
+ "error_class",
713
+ required=True,
714
+ help="Error class (e.g., 'missing_test', 'validation_boundary')",
715
+ )
716
+ @click.option(
717
+ "--description",
718
+ "-d",
719
+ required=True,
720
+ help="Description of the mistake",
721
+ )
722
+ @click.option(
723
+ "--rule",
724
+ "-r",
725
+ "corrected_by_rule",
726
+ help="Rule ID that should have prevented this",
727
+ )
728
+ @click.option("--json", "output_json", is_flag=True, help="Output as JSON")
729
+ def experiment_log_mistake(
730
+ error_class: str,
731
+ description: str,
732
+ corrected_by_rule: str | None,
733
+ output_json: bool,
734
+ ):
735
+ """Log a mistake during the current session.
736
+
737
+ Records the mistake and checks if it's a repeat of a prior mistake
738
+ (from earlier sessions). This enables measuring repeated-mistake rates.
739
+
740
+ Examples:
741
+
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
744
+ """
745
+ import json as json_module
746
+ from dataclasses import asdict
747
+
748
+ from buildlog.core import log_mistake
749
+
750
+ buildlog_dir = Path("buildlog")
751
+
752
+ if not buildlog_dir.exists():
753
+ click.echo("No buildlog/ directory found. Run 'buildlog init' first.", err=True)
754
+ raise SystemExit(1)
755
+
756
+ try:
757
+ result = log_mistake(
758
+ buildlog_dir,
759
+ error_class=error_class,
760
+ description=description,
761
+ corrected_by_rule=corrected_by_rule,
762
+ )
763
+ except ValueError as e:
764
+ click.echo(f"Error: {e}", err=True)
765
+ raise SystemExit(1)
766
+
767
+ if output_json:
768
+ click.echo(json_module.dumps(asdict(result), indent=2))
769
+ else:
770
+ if result.was_repeat:
771
+ click.echo(f"⚠ REPEAT: {result.message}")
772
+ click.echo(f" Similar to: {result.similar_prior}")
773
+ else:
774
+ click.echo(f"✓ {result.message}")
775
+
776
+
777
+ @experiment.command("metrics")
778
+ @click.option(
779
+ "--session", "-s", "session_id", help="Specific session ID (or aggregate)"
780
+ )
781
+ @click.option("--json", "output_json", is_flag=True, help="Output as JSON")
782
+ def experiment_metrics(session_id: str | None, output_json: bool):
783
+ """Show metrics for a session or all sessions.
784
+
785
+ Displays mistake rates and rule changes.
786
+
787
+ Examples:
788
+
789
+ buildlog experiment metrics # Aggregate metrics
790
+ buildlog experiment metrics --session session-20260121-140000
791
+ """
792
+ import json as json_module
793
+ from dataclasses import asdict
794
+
795
+ from buildlog.core import get_session_metrics
796
+
797
+ buildlog_dir = Path("buildlog")
798
+
799
+ if not buildlog_dir.exists():
800
+ click.echo("No buildlog/ directory found. Run 'buildlog init' first.", err=True)
801
+ raise SystemExit(1)
802
+
803
+ try:
804
+ metrics = get_session_metrics(buildlog_dir, session_id=session_id)
805
+ except ValueError as e:
806
+ click.echo(f"Error: {e}", err=True)
807
+ raise SystemExit(1)
808
+
809
+ if output_json:
810
+ click.echo(json_module.dumps(asdict(metrics), indent=2))
811
+ else:
812
+ click.echo(f"Session Metrics: {metrics.session_id}")
813
+ click.echo("=" * 40)
814
+ click.echo(f"Total mistakes: {metrics.total_mistakes}")
815
+ click.echo(f"Repeated mistakes: {metrics.repeated_mistakes}")
816
+ click.echo(f"Repeat rate: {metrics.repeated_mistake_rate:.1%}")
817
+ click.echo(f"Rules at start: {metrics.rules_at_start}")
818
+ click.echo(f"Rules at end: {metrics.rules_at_end}")
819
+ click.echo(f"Rules added: {metrics.rules_added:+d}")
820
+
821
+
822
+ @experiment.command("report")
823
+ @click.option("--json", "output_json", is_flag=True, help="Output as JSON")
824
+ def experiment_report(output_json: bool):
825
+ """Generate a comprehensive experiment report.
826
+
827
+ Shows summary statistics, per-session breakdown, and error class analysis.
828
+
829
+ Examples:
830
+
831
+ buildlog experiment report
832
+ buildlog experiment report --json > report.json
833
+ """
834
+ import json as json_module
835
+
836
+ from buildlog.core import get_experiment_report
837
+
838
+ buildlog_dir = Path("buildlog")
839
+
840
+ if not buildlog_dir.exists():
841
+ click.echo("No buildlog/ directory found. Run 'buildlog init' first.", err=True)
842
+ raise SystemExit(1)
843
+
844
+ report = get_experiment_report(buildlog_dir)
845
+
846
+ if output_json:
847
+ click.echo(json_module.dumps(report, indent=2))
848
+ else:
849
+ summary = report["summary"]
850
+ click.echo("Experiment Report")
851
+ click.echo("=" * 50)
852
+ click.echo(f"Total sessions: {summary['total_sessions']}")
853
+ click.echo(f"Total mistakes: {summary['total_mistakes']}")
854
+ click.echo(f"Repeated mistakes: {summary['total_repeated']}")
855
+ click.echo(f"Overall repeat rate: {summary['overall_repeat_rate']:.1%}")
856
+ click.echo()
857
+
858
+ if report["sessions"]:
859
+ click.echo("Per-Session Breakdown")
860
+ click.echo("-" * 50)
861
+ for sess in report["sessions"]:
862
+ rate = sess["repeated_mistake_rate"]
863
+ click.echo(f" {sess['session_id']}")
864
+ click.echo(
865
+ f" Mistakes: {sess['total_mistakes']} ({sess['repeated_mistakes']} repeats, {rate:.0%})"
866
+ )
867
+ click.echo(f" Rules added: {sess['rules_added']:+d}")
868
+ click.echo()
869
+
870
+ if report["error_classes"]:
871
+ click.echo("Error Class Breakdown")
872
+ click.echo("-" * 50)
873
+ for ec, data in report["error_classes"].items():
874
+ rate = data["repeated"] / data["total"] if data["total"] > 0 else 0
875
+ click.echo(
876
+ f" {ec}: {data['total']} mistakes ({data['repeated']} repeats, {rate:.0%})"
877
+ )
878
+
879
+
880
+ # -----------------------------------------------------------------------------
881
+ # Gauntlet Commands (Review Personas)
882
+ # -----------------------------------------------------------------------------
883
+
884
+ PERSONAS = {
885
+ "security_karen": "OWASP Top 10 security review",
886
+ "test_terrorist": "Comprehensive testing coverage audit",
887
+ "ruthless_reviewer": "Code quality and functional principles",
888
+ }
889
+
890
+
891
+ @main.group()
892
+ def gauntlet():
893
+ """Run the review gauntlet with curated personas.
894
+
895
+ The gauntlet runs your code through multiple ruthless reviewers,
896
+ each with domain-specific rules loaded from seed files.
897
+
898
+ Personas:
899
+ - security_karen: OWASP security review (12 rules)
900
+ - test_terrorist: Testing coverage audit (21 rules)
901
+ - ruthless_reviewer: Code quality review (coming soon)
902
+
903
+ Example workflow:
904
+
905
+ buildlog gauntlet list # See available personas
906
+ buildlog gauntlet rules --persona all # Show all rules
907
+ buildlog gauntlet prompt src/ # Generate review prompt
908
+ """
909
+ pass
910
+
911
+
912
+ @gauntlet.command("list")
913
+ @click.option("--json", "output_json", is_flag=True, help="Output as JSON")
914
+ def gauntlet_list(output_json: bool):
915
+ """List available reviewer personas and their rule counts.
916
+
917
+ Examples:
918
+
919
+ buildlog gauntlet list
920
+ buildlog gauntlet list --json
921
+ """
922
+ import json as json_module
923
+
924
+ from buildlog.seeds import load_all_seeds
925
+
926
+ # Find seeds directory
927
+ buildlog_dir = Path("buildlog")
928
+ seeds_dir = buildlog_dir / ".buildlog" / "seeds"
929
+
930
+ # Also check .buildlog at repo root (common for installed templates)
931
+ if not seeds_dir.exists():
932
+ seeds_dir = Path(".buildlog") / "seeds"
933
+
934
+ seeds = load_all_seeds(seeds_dir)
935
+
936
+ if output_json:
937
+ data = {
938
+ "personas": {
939
+ name: {
940
+ "description": PERSONAS.get(name, "Custom persona"),
941
+ "rules_count": len(sf.rules),
942
+ "version": sf.version,
943
+ }
944
+ for name, sf in seeds.items()
945
+ },
946
+ "total_rules": sum(len(sf.rules) for sf in seeds.values()),
947
+ }
948
+ click.echo(json_module.dumps(data, indent=2))
949
+ else:
950
+ click.echo("Review Gauntlet Personas")
951
+ click.echo("=" * 50)
952
+
953
+ if not seeds:
954
+ click.echo("\nNo seed files found.")
955
+ click.echo("Initialize with: buildlog init")
956
+ click.echo("Or create seeds in: .buildlog/seeds/")
957
+ return
958
+
959
+ total = 0
960
+ for name, sf in sorted(seeds.items()):
961
+ desc = PERSONAS.get(name, "Custom persona")
962
+ click.echo(f"\n {name}")
963
+ click.echo(f" {desc}")
964
+ click.echo(f" Rules: {len(sf.rules)} (v{sf.version})")
965
+ total += len(sf.rules)
966
+
967
+ click.echo(f"\nTotal: {len(seeds)} personas, {total} rules")
968
+
969
+
970
+ @gauntlet.command("rules")
971
+ @click.option(
972
+ "--persona",
973
+ "-p",
974
+ default="all",
975
+ help="Persona to show rules for (or 'all')",
976
+ )
977
+ @click.option(
978
+ "--format",
979
+ "fmt",
980
+ type=click.Choice(["yaml", "json", "markdown"]),
981
+ default="yaml",
982
+ help="Output format",
983
+ )
984
+ @click.option("--output", "-o", type=click.Path(), help="Output file")
985
+ def gauntlet_rules(persona: str, fmt: str, output: str | None):
986
+ """Show rules for reviewer personas.
987
+
988
+ Use this to see what rules are loaded for each persona,
989
+ or export them for use in prompts.
990
+
991
+ Examples:
992
+
993
+ buildlog gauntlet rules # All rules (YAML)
994
+ buildlog gauntlet rules -p security_karen # Single persona
995
+ buildlog gauntlet rules --format json -o rules.json
996
+ buildlog gauntlet rules --format markdown # For docs
997
+ """
998
+ import json as json_module
999
+
1000
+ from buildlog.seeds import load_all_seeds
1001
+
1002
+ # Find seeds directory
1003
+ seeds_dir = Path(".buildlog") / "seeds"
1004
+ if not seeds_dir.exists():
1005
+ seeds_dir = Path("buildlog") / ".buildlog" / "seeds"
1006
+
1007
+ seeds = load_all_seeds(seeds_dir)
1008
+
1009
+ if not seeds:
1010
+ click.echo("No seed files found.", err=True)
1011
+ click.echo("Initialize with: buildlog init", err=True)
1012
+ raise SystemExit(1)
1013
+
1014
+ # Filter personas
1015
+ if persona != "all":
1016
+ if persona not in seeds:
1017
+ available = ", ".join(seeds.keys())
1018
+ click.echo(f"Unknown persona: {persona}", err=True)
1019
+ click.echo(f"Available: {available}", err=True)
1020
+ raise SystemExit(1)
1021
+ seeds = {persona: seeds[persona]}
1022
+
1023
+ # Build output data
1024
+ if fmt == "json":
1025
+ data = {}
1026
+ for name, sf in seeds.items():
1027
+ data[name] = {
1028
+ "version": sf.version,
1029
+ "rules": [
1030
+ {
1031
+ "rule": r.rule,
1032
+ "category": r.category,
1033
+ "context": r.context,
1034
+ "antipattern": r.antipattern,
1035
+ "rationale": r.rationale,
1036
+ "tags": r.tags,
1037
+ "references": [
1038
+ {"url": ref.url, "title": ref.title} for ref in r.references
1039
+ ],
1040
+ }
1041
+ for r in sf.rules
1042
+ ],
1043
+ }
1044
+ formatted = json_module.dumps(data, indent=2)
1045
+
1046
+ elif fmt == "markdown":
1047
+ lines = ["# Review Gauntlet Rules\n"]
1048
+ for name, sf in seeds.items():
1049
+ lines.append(f"## {name.replace('_', ' ').title()}\n")
1050
+ lines.append(f"*{len(sf.rules)} rules, v{sf.version}*\n")
1051
+ for i, r in enumerate(sf.rules, 1):
1052
+ lines.append(f"### {i}. {r.rule}\n")
1053
+ lines.append(f"**Category**: {r.category} ")
1054
+ lines.append(f"**Tags**: {', '.join(r.tags)}\n")
1055
+ if r.context:
1056
+ lines.append(f"**When**: {r.context}\n")
1057
+ if r.antipattern:
1058
+ lines.append(f"**Antipattern**: {r.antipattern}\n")
1059
+ if r.rationale:
1060
+ lines.append(f"**Why**: {r.rationale}\n")
1061
+ if r.references:
1062
+ lines.append("**References**:")
1063
+ for ref in r.references:
1064
+ lines.append(f"- [{ref.title}]({ref.url})")
1065
+ lines.append("")
1066
+ formatted = "\n".join(lines)
1067
+
1068
+ else: # yaml
1069
+ import yaml as yaml_module
1070
+
1071
+ data = {}
1072
+ for name, sf in seeds.items():
1073
+ data[name] = {
1074
+ "version": sf.version,
1075
+ "rules": [
1076
+ {
1077
+ "rule": r.rule,
1078
+ "category": r.category,
1079
+ "context": r.context,
1080
+ "antipattern": r.antipattern,
1081
+ "rationale": r.rationale,
1082
+ "tags": r.tags,
1083
+ }
1084
+ for r in sf.rules
1085
+ ],
1086
+ }
1087
+ formatted = yaml_module.dump(data, default_flow_style=False, sort_keys=False)
1088
+
1089
+ # Output
1090
+ if output:
1091
+ output_path = Path(output)
1092
+ output_path.write_text(formatted, encoding="utf-8")
1093
+ total = sum(len(sf.rules) for sf in seeds.values())
1094
+ click.echo(f"Wrote {total} rules to {output_path}")
1095
+ else:
1096
+ click.echo(formatted)
1097
+
1098
+
1099
+ @gauntlet.command("prompt")
1100
+ @click.argument("target", type=click.Path(exists=True))
1101
+ @click.option(
1102
+ "--persona",
1103
+ "-p",
1104
+ multiple=True,
1105
+ help="Personas to include (default: all)",
1106
+ )
1107
+ @click.option("--output", "-o", type=click.Path(), help="Output file")
1108
+ def gauntlet_prompt(target: str, persona: tuple[str, ...], output: str | None):
1109
+ """Generate a review prompt for the gauntlet.
1110
+
1111
+ Creates a prompt with rules and target code that can be
1112
+ used with Claude or another LLM to run a review.
1113
+
1114
+ Examples:
1115
+
1116
+ buildlog gauntlet prompt src/
1117
+ buildlog gauntlet prompt src/api.py -p security_karen
1118
+ buildlog gauntlet prompt . -o review_prompt.md
1119
+ """
1120
+ from buildlog.seeds import load_all_seeds
1121
+
1122
+ # Find seeds directory
1123
+ seeds_dir = Path(".buildlog") / "seeds"
1124
+ if not seeds_dir.exists():
1125
+ seeds_dir = Path("buildlog") / ".buildlog" / "seeds"
1126
+
1127
+ seeds = load_all_seeds(seeds_dir)
1128
+
1129
+ if not seeds:
1130
+ click.echo("No seed files found.", err=True)
1131
+ raise SystemExit(1)
1132
+
1133
+ # Filter personas
1134
+ if persona:
1135
+ seeds = {k: v for k, v in seeds.items() if k in persona}
1136
+ if not seeds:
1137
+ click.echo(f"No matching personas: {', '.join(persona)}", err=True)
1138
+ raise SystemExit(1)
1139
+
1140
+ # Build the prompt
1141
+ target_path = Path(target)
1142
+ lines = [
1143
+ "# Review Gauntlet Prompt\n",
1144
+ "You are running the Review Gauntlet. Apply these rules ruthlessly.\n",
1145
+ "## Target\n",
1146
+ f"Review: `{target_path}`\n",
1147
+ "## Reviewers and Rules\n",
1148
+ ]
1149
+
1150
+ for name, sf in seeds.items():
1151
+ persona_name = name.replace("_", " ").title()
1152
+ lines.append(f"### {persona_name}\n")
1153
+ for r in sf.rules:
1154
+ lines.append(f"- **{r.rule}**")
1155
+ if r.antipattern:
1156
+ lines.append(f" - Antipattern: {r.antipattern}")
1157
+ lines.append("")
1158
+
1159
+ lines.extend(
1160
+ [
1161
+ "## Output Format\n",
1162
+ "For each issue found, output:\n",
1163
+ "```json",
1164
+ "{",
1165
+ ' "reviewer": "<persona>",',
1166
+ ' "severity": "critical|major|minor|nitpick",',
1167
+ ' "category": "<category>",',
1168
+ ' "location": "<file:line>",',
1169
+ ' "description": "<what is wrong>",',
1170
+ ' "rule_learned": "<generalizable rule>"',
1171
+ "}",
1172
+ "```\n",
1173
+ "## Instructions\n",
1174
+ "1. Read the target code thoroughly",
1175
+ "2. Apply each rule from each reviewer",
1176
+ "3. Report ALL violations found",
1177
+ "4. Be ruthless - this is the gauntlet",
1178
+ "",
1179
+ ]
1180
+ )
1181
+
1182
+ formatted = "\n".join(lines)
1183
+
1184
+ if output:
1185
+ output_path = Path(output)
1186
+ output_path.write_text(formatted, encoding="utf-8")
1187
+ click.echo(f"Wrote prompt to {output_path}")
1188
+ else:
1189
+ click.echo(formatted)
1190
+
1191
+
1192
+ @gauntlet.command("learn")
1193
+ @click.argument("issues_file", type=click.Path(exists=True))
1194
+ @click.option("--source", "-s", help="Source identifier (e.g., 'gauntlet:PR#42')")
1195
+ @click.option("--json", "output_json", is_flag=True, help="Output as JSON")
1196
+ def gauntlet_learn(issues_file: str, source: str | None, output_json: bool):
1197
+ """Persist learnings from a gauntlet review.
1198
+
1199
+ Takes a JSON file of issues (in the gauntlet output format)
1200
+ and calls learn_from_review to persist them.
1201
+
1202
+ Examples:
1203
+
1204
+ buildlog gauntlet learn review_issues.json
1205
+ buildlog gauntlet learn issues.json --source "gauntlet:2026-01-22"
1206
+ """
1207
+ import json as json_module
1208
+ from dataclasses import asdict
1209
+
1210
+ from buildlog.core import learn_from_review
1211
+
1212
+ buildlog_dir = Path("buildlog")
1213
+
1214
+ if not buildlog_dir.exists():
1215
+ click.echo("No buildlog/ directory found. Run 'buildlog init' first.", err=True)
1216
+ raise SystemExit(1)
1217
+
1218
+ # Load issues
1219
+ try:
1220
+ with open(issues_file) as f:
1221
+ data = json_module.load(f)
1222
+ except json_module.JSONDecodeError as e:
1223
+ click.echo(f"Invalid JSON: {e}", err=True)
1224
+ raise SystemExit(1)
1225
+
1226
+ # Handle different formats
1227
+ if isinstance(data, list):
1228
+ issues = data
1229
+ elif isinstance(data, dict) and "all_issues" in data:
1230
+ issues = data["all_issues"]
1231
+ elif isinstance(data, dict) and "issues" in data:
1232
+ issues = data["issues"]
1233
+ else:
1234
+ click.echo(
1235
+ "Expected list of issues or dict with 'issues'/'all_issues'", err=True
1236
+ )
1237
+ raise SystemExit(1)
1238
+
1239
+ if not issues:
1240
+ click.echo("No issues found in file.", err=True)
1241
+ raise SystemExit(1)
1242
+
1243
+ # Learn from review
1244
+ result = learn_from_review(buildlog_dir, issues, source=source or "gauntlet")
1245
+
1246
+ if output_json:
1247
+ click.echo(json_module.dumps(asdict(result), indent=2))
1248
+ else:
1249
+ click.echo(f"✓ {result.message}")
1250
+ click.echo(f" New learnings: {result.new_learnings}")
1251
+ click.echo(f" Reinforced: {result.reinforced_learnings}")
1252
+ click.echo(f" Total processed: {result.total_issues_processed}")
1253
+
1254
+
459
1255
  if __name__ == "__main__":
460
1256
  main()