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.
- buildlog/cli.py +799 -3
- buildlog/core/__init__.py +34 -0
- buildlog/core/operations.py +925 -0
- buildlog/mcp/server.py +16 -0
- buildlog/mcp/tools.py +266 -1
- buildlog/seed_engine/__init__.py +74 -0
- buildlog/seed_engine/categorizers.py +145 -0
- buildlog/seed_engine/extractors.py +148 -0
- buildlog/seed_engine/generators.py +144 -0
- buildlog/seed_engine/models.py +113 -0
- buildlog/seed_engine/pipeline.py +202 -0
- buildlog/seed_engine/sources.py +362 -0
- buildlog/seeds.py +211 -0
- buildlog/skills.py +26 -3
- buildlog-0.6.0.dist-info/METADATA +490 -0
- buildlog-0.6.0.dist-info/RECORD +38 -0
- buildlog-0.4.0.dist-info/METADATA +0 -894
- buildlog-0.4.0.dist-info/RECORD +0 -30
- {buildlog-0.4.0.data → buildlog-0.6.0.data}/data/share/buildlog/copier.yml +0 -0
- {buildlog-0.4.0.data → buildlog-0.6.0.data}/data/share/buildlog/post_gen.py +0 -0
- {buildlog-0.4.0.data → buildlog-0.6.0.data}/data/share/buildlog/template/buildlog/.gitkeep +0 -0
- {buildlog-0.4.0.data → buildlog-0.6.0.data}/data/share/buildlog/template/buildlog/2026-01-01-example.md +0 -0
- {buildlog-0.4.0.data → buildlog-0.6.0.data}/data/share/buildlog/template/buildlog/BUILDLOG_SYSTEM.md +0 -0
- {buildlog-0.4.0.data → buildlog-0.6.0.data}/data/share/buildlog/template/buildlog/_TEMPLATE.md +0 -0
- {buildlog-0.4.0.data → buildlog-0.6.0.data}/data/share/buildlog/template/buildlog/assets/.gitkeep +0 -0
- {buildlog-0.4.0.dist-info → buildlog-0.6.0.dist-info}/WHEEL +0 -0
- {buildlog-0.4.0.dist-info → buildlog-0.6.0.dist-info}/entry_points.txt +0 -0
- {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
|
|
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"),
|
|
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()
|