buildlog 0.6.1__py3-none-any.whl → 0.8.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (40) hide show
  1. buildlog/__init__.py +1 -1
  2. buildlog/cli.py +589 -44
  3. buildlog/confidence.py +27 -0
  4. buildlog/core/__init__.py +12 -0
  5. buildlog/core/bandit.py +699 -0
  6. buildlog/core/operations.py +499 -11
  7. buildlog/distill.py +80 -1
  8. buildlog/engine/__init__.py +61 -0
  9. buildlog/engine/bandit.py +23 -0
  10. buildlog/engine/confidence.py +28 -0
  11. buildlog/engine/embeddings.py +28 -0
  12. buildlog/engine/experiments.py +619 -0
  13. buildlog/engine/types.py +31 -0
  14. buildlog/llm.py +461 -0
  15. buildlog/mcp/server.py +12 -6
  16. buildlog/mcp/tools.py +166 -13
  17. buildlog/render/__init__.py +19 -2
  18. buildlog/render/claude_md.py +74 -26
  19. buildlog/render/continue_dev.py +102 -0
  20. buildlog/render/copilot.py +100 -0
  21. buildlog/render/cursor.py +105 -0
  22. buildlog/render/tracking.py +20 -1
  23. buildlog/render/windsurf.py +95 -0
  24. buildlog/seeds.py +41 -0
  25. buildlog/skills.py +69 -6
  26. {buildlog-0.6.1.data → buildlog-0.8.0.data}/data/share/buildlog/copier.yml +0 -4
  27. buildlog-0.8.0.data/data/share/buildlog/template/buildlog/_TEMPLATE_QUICK.md +21 -0
  28. buildlog-0.8.0.dist-info/METADATA +151 -0
  29. buildlog-0.8.0.dist-info/RECORD +54 -0
  30. buildlog-0.6.1.dist-info/METADATA +0 -490
  31. buildlog-0.6.1.dist-info/RECORD +0 -41
  32. {buildlog-0.6.1.data → buildlog-0.8.0.data}/data/share/buildlog/post_gen.py +0 -0
  33. {buildlog-0.6.1.data → buildlog-0.8.0.data}/data/share/buildlog/template/buildlog/.gitkeep +0 -0
  34. {buildlog-0.6.1.data → buildlog-0.8.0.data}/data/share/buildlog/template/buildlog/2026-01-01-example.md +0 -0
  35. {buildlog-0.6.1.data → buildlog-0.8.0.data}/data/share/buildlog/template/buildlog/BUILDLOG_SYSTEM.md +0 -0
  36. {buildlog-0.6.1.data → buildlog-0.8.0.data}/data/share/buildlog/template/buildlog/_TEMPLATE.md +0 -0
  37. {buildlog-0.6.1.data → buildlog-0.8.0.data}/data/share/buildlog/template/buildlog/assets/.gitkeep +0 -0
  38. {buildlog-0.6.1.dist-info → buildlog-0.8.0.dist-info}/WHEEL +0 -0
  39. {buildlog-0.6.1.dist-info → buildlog-0.8.0.dist-info}/entry_points.txt +0 -0
  40. {buildlog-0.6.1.dist-info → buildlog-0.8.0.dist-info}/licenses/LICENSE +0 -0
buildlog/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)
@@ -257,7 +416,18 @@ def update():
257
416
  type=click.Choice(CATEGORIES),
258
417
  help="Filter to a specific category",
259
418
  )
260
- def distill(output: str | None, fmt: str, since: datetime | None, category: str | None):
419
+ @click.option(
420
+ "--llm",
421
+ is_flag=True,
422
+ help="Use LLM-backed extraction (Ollama/Anthropic, falls back to regex)",
423
+ )
424
+ def distill(
425
+ output: str | None,
426
+ fmt: str,
427
+ since: datetime | None,
428
+ category: str | None,
429
+ llm: bool,
430
+ ):
261
431
  """Extract patterns from all buildlog entries.
262
432
 
263
433
  Parses the Improvements section of each buildlog entry and aggregates
@@ -282,7 +452,9 @@ def distill(output: str | None, fmt: str, since: datetime | None, category: str
282
452
 
283
453
  # Run distillation
284
454
  try:
285
- result = distill_all(buildlog_dir, since=since_date, category_filter=category)
455
+ result = distill_all(
456
+ buildlog_dir, since=since_date, category_filter=category, llm=llm
457
+ )
286
458
  except Exception as e:
287
459
  click.echo(f"Failed to distill entries: {e}", err=True)
288
460
  raise SystemExit(1)
@@ -383,12 +555,18 @@ def stats(output_json: bool, detailed: bool, since_date: str | None):
383
555
  default=None,
384
556
  help="Embedding backend for semantic deduplication",
385
557
  )
558
+ @click.option(
559
+ "--llm",
560
+ is_flag=True,
561
+ help="Use LLM for extraction, canonical selection, and scoring (Ollama/Anthropic)",
562
+ )
386
563
  def skills(
387
564
  output: str | None,
388
565
  fmt: str,
389
566
  min_frequency: int,
390
567
  since: datetime | None,
391
568
  embeddings: str | None,
569
+ llm: bool = False,
392
570
  ):
393
571
  """Generate agent-consumable skills from buildlog patterns.
394
572
 
@@ -424,6 +602,7 @@ def skills(
424
602
  min_frequency=min_frequency,
425
603
  since_date=since_date,
426
604
  embedding_backend=embeddings,
605
+ llm=llm,
427
606
  )
428
607
  except ImportError as e:
429
608
  click.echo(f"Missing dependency: {e}", err=True)
@@ -584,6 +763,219 @@ def rewards(limit: int | None, output_json: bool):
584
763
  click.echo("Log your first with: buildlog reward accepted")
585
764
 
586
765
 
766
+ # -----------------------------------------------------------------------------
767
+ # Skill Management Commands (status, promote, reject, diff)
768
+ # -----------------------------------------------------------------------------
769
+
770
+
771
+ @main.command()
772
+ @click.option(
773
+ "--min-confidence",
774
+ type=click.Choice(["low", "medium", "high"]),
775
+ default="low",
776
+ help="Minimum confidence level to include",
777
+ )
778
+ @click.option("--json", "output_json", is_flag=True, help="Output as JSON")
779
+ def status_cmd(min_confidence: str, output_json: bool):
780
+ """Show extracted skills by category and confidence.
781
+
782
+ Displays all skills extracted from buildlog entries, grouped by category,
783
+ with confidence levels and promotion status.
784
+
785
+ Examples:
786
+
787
+ buildlog status
788
+ buildlog status --min-confidence medium
789
+ buildlog status --json
790
+ """
791
+ import json as json_module
792
+ from dataclasses import asdict
793
+
794
+ buildlog_dir = Path("buildlog")
795
+
796
+ if not buildlog_dir.exists():
797
+ click.echo("No buildlog/ directory found. Run 'buildlog init' first.", err=True)
798
+ raise SystemExit(1)
799
+
800
+ result = status(buildlog_dir, min_confidence=min_confidence) # type: ignore[arg-type]
801
+
802
+ if result.error:
803
+ click.echo(f"Error: {result.error}", err=True)
804
+ raise SystemExit(1)
805
+
806
+ if output_json:
807
+ click.echo(json_module.dumps(asdict(result), indent=2))
808
+ else:
809
+ click.echo(
810
+ f"Skills: {result.total_skills} total from {result.total_entries} entries"
811
+ )
812
+ conf_str = ", ".join(
813
+ f"{k}={v}" for k, v in result.by_confidence.items() if v > 0
814
+ )
815
+ click.echo(f" By confidence: {conf_str}")
816
+ click.echo()
817
+ for category, skills in result.skills.items():
818
+ if not skills:
819
+ continue
820
+ click.echo(f" {category} ({len(skills)})")
821
+ for s in skills:
822
+ conf = s.get("confidence", "?")
823
+ click.echo(f" [{conf}] {s['id']}: {s['rule'][:70]}")
824
+ if result.promotable_ids:
825
+ click.echo(f"\nPromotable: {', '.join(result.promotable_ids)}")
826
+
827
+
828
+ # Register with the name "status" (avoiding collision with Python builtin)
829
+ status_cmd.name = "status"
830
+
831
+
832
+ @main.command()
833
+ @click.argument("skill_ids", nargs=-1, required=True)
834
+ @click.option(
835
+ "--target",
836
+ type=click.Choice(
837
+ [
838
+ "claude_md",
839
+ "settings_json",
840
+ "skill",
841
+ "cursor",
842
+ "copilot",
843
+ "windsurf",
844
+ "continue_dev",
845
+ ]
846
+ ),
847
+ default="claude_md",
848
+ help="Where to write promoted rules",
849
+ )
850
+ @click.option(
851
+ "--target-path", type=click.Path(), help="Custom path for the target file"
852
+ )
853
+ @click.option("--json", "output_json", is_flag=True, help="Output as JSON")
854
+ def promote_cmd(
855
+ skill_ids: tuple[str, ...], target: str, target_path: str | None, output_json: bool
856
+ ):
857
+ """Promote skills to agent rules.
858
+
859
+ Surface high-confidence skills to your agent via CLAUDE.md, settings.json,
860
+ or Agent Skills.
861
+
862
+ Examples:
863
+
864
+ buildlog promote arch-b0fcb62a1e
865
+ buildlog promote arch-123 wf-456 --target skill
866
+ buildlog promote arch-123 --target settings_json --target-path .claude/settings.json
867
+ """
868
+ import json as json_module
869
+ from dataclasses import asdict
870
+
871
+ buildlog_dir = Path("buildlog")
872
+
873
+ if not buildlog_dir.exists():
874
+ click.echo("No buildlog/ directory found. Run 'buildlog init' first.", err=True)
875
+ raise SystemExit(1)
876
+
877
+ result = promote(
878
+ buildlog_dir,
879
+ skill_ids=list(skill_ids),
880
+ target=target, # type: ignore[arg-type]
881
+ target_path=Path(target_path) if target_path else None,
882
+ )
883
+
884
+ if result.error:
885
+ click.echo(f"Error: {result.error}", err=True)
886
+ raise SystemExit(1)
887
+
888
+ if output_json:
889
+ click.echo(json_module.dumps(asdict(result), indent=2))
890
+ else:
891
+ click.echo(f"✓ {result.message}")
892
+ if result.not_found_ids:
893
+ click.echo(f" Not found: {', '.join(result.not_found_ids)}")
894
+
895
+
896
+ promote_cmd.name = "promote"
897
+
898
+
899
+ @main.command("reject")
900
+ @click.argument("skill_ids", nargs=-1, required=True)
901
+ @click.option("--json", "output_json", is_flag=True, help="Output as JSON")
902
+ def reject_cmd(skill_ids: tuple[str, ...], output_json: bool):
903
+ """Mark skills as rejected (false positives).
904
+
905
+ Rejected skills won't be suggested for promotion again.
906
+
907
+ Examples:
908
+
909
+ buildlog reject arch-b0fcb62a1e
910
+ buildlog reject dk-123 wf-456
911
+ """
912
+ import json as json_module
913
+ from dataclasses import asdict
914
+
915
+ buildlog_dir = Path("buildlog")
916
+
917
+ if not buildlog_dir.exists():
918
+ click.echo("No buildlog/ directory found. Run 'buildlog init' first.", err=True)
919
+ raise SystemExit(1)
920
+
921
+ result = reject(buildlog_dir, skill_ids=list(skill_ids))
922
+
923
+ if result.error:
924
+ click.echo(f"Error: {result.error}", err=True)
925
+ raise SystemExit(1)
926
+
927
+ if output_json:
928
+ click.echo(json_module.dumps(asdict(result), indent=2))
929
+ else:
930
+ click.echo(f"✓ Rejected {len(result.rejected_ids)} skills")
931
+ click.echo(f" Total rejected: {result.total_rejected}")
932
+
933
+
934
+ @main.command("diff")
935
+ @click.option("--json", "output_json", is_flag=True, help="Output as JSON")
936
+ def diff_cmd(output_json: bool):
937
+ """Show skills pending review (not yet promoted or rejected).
938
+
939
+ Useful for seeing what's new since the last time you reviewed skills.
940
+
941
+ Examples:
942
+
943
+ buildlog diff
944
+ buildlog diff --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 = core_diff(buildlog_dir)
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"Pending: {result.total_pending} | "
966
+ f"Promoted: {result.already_promoted} | "
967
+ f"Rejected: {result.already_rejected}"
968
+ )
969
+ click.echo()
970
+ for category, skills in result.pending.items():
971
+ if not skills:
972
+ continue
973
+ click.echo(f" {category} ({len(skills)})")
974
+ for s in skills:
975
+ conf = s.get("confidence", "?")
976
+ click.echo(f" [{conf}] {s['id']}: {s['rule'][:70]}")
977
+
978
+
587
979
  # -----------------------------------------------------------------------------
588
980
  # Experiment Commands (Session Tracking for Issue #21)
589
981
  # -----------------------------------------------------------------------------
@@ -708,7 +1100,7 @@ def experiment_end(
708
1100
 
709
1101
  @experiment.command("log-mistake")
710
1102
  @click.option(
711
- "--class",
1103
+ "--error-class",
712
1104
  "error_class",
713
1105
  required=True,
714
1106
  help="Error class (e.g., 'missing_test', 'validation_boundary')",
@@ -739,8 +1131,8 @@ def experiment_log_mistake(
739
1131
 
740
1132
  Examples:
741
1133
 
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
1134
+ buildlog experiment log-mistake --error-class missing_test -d "Forgot tests"
1135
+ buildlog experiment log-mistake --error-class validation -d "No max length" -r val-123
744
1136
  """
745
1137
  import json as json_module
746
1138
  from dataclasses import asdict
@@ -1264,5 +1656,158 @@ def gauntlet_learn(issues_file: str, source: str | None, output_json: bool):
1264
1656
  click.echo(f" Total processed: {result.total_issues_processed}")
1265
1657
 
1266
1658
 
1659
+ @gauntlet.command("loop")
1660
+ @click.argument("target", type=click.Path(exists=True))
1661
+ @click.option(
1662
+ "--persona",
1663
+ "-p",
1664
+ multiple=True,
1665
+ help="Personas to run (default: all)",
1666
+ )
1667
+ @click.option(
1668
+ "--max-iterations",
1669
+ "-n",
1670
+ default=10,
1671
+ help="Maximum iterations to prevent infinite loops (default: 10)",
1672
+ )
1673
+ @click.option(
1674
+ "--stop-at",
1675
+ type=click.Choice(["criticals", "majors", "minors"]),
1676
+ default="minors",
1677
+ help="Stop after clearing this severity level (default: minors)",
1678
+ )
1679
+ @click.option(
1680
+ "--auto-gh-issues",
1681
+ is_flag=True,
1682
+ help="Create GitHub issues for remaining items when accepting risk",
1683
+ )
1684
+ @click.option("--json", "output_json", is_flag=True, help="Output as JSON")
1685
+ def gauntlet_loop(
1686
+ target: str,
1687
+ persona: tuple[str, ...],
1688
+ max_iterations: int,
1689
+ stop_at: str,
1690
+ auto_gh_issues: bool,
1691
+ output_json: bool,
1692
+ ):
1693
+ """Run the gauntlet loop: review, fix, repeat until clean.
1694
+
1695
+ This command orchestrates the gauntlet loop workflow:
1696
+
1697
+ 1. Generate review prompt for target code
1698
+ 2. Process issues and determine action
1699
+ 3. On criticals: output fix instructions, expect re-run
1700
+ 4. On majors only: checkpoint (ask to continue)
1701
+ 5. On minors only: checkpoint (accept risk?)
1702
+ 6. Optionally create GitHub issues for remaining items
1703
+
1704
+ The loop is designed to be run interactively with an agent
1705
+ (Claude Code, Cursor, etc.) that does the actual fixing.
1706
+
1707
+ Examples:
1708
+
1709
+ buildlog gauntlet loop src/
1710
+ buildlog gauntlet loop tests/ --stop-at majors
1711
+ buildlog gauntlet loop . --auto-gh-issues
1712
+ """
1713
+ import json as json_module
1714
+
1715
+ from buildlog.seeds import get_default_seeds_dir, load_all_seeds
1716
+
1717
+ # Find seeds directory
1718
+ seeds_dir = get_default_seeds_dir()
1719
+
1720
+ if seeds_dir is None:
1721
+ click.echo("No seed files found.", err=True)
1722
+ raise SystemExit(1)
1723
+
1724
+ seeds = load_all_seeds(seeds_dir)
1725
+
1726
+ if not seeds:
1727
+ click.echo("No seed files found in directory.", err=True)
1728
+ raise SystemExit(1)
1729
+
1730
+ # Filter personas
1731
+ if persona:
1732
+ seeds = {k: v for k, v in seeds.items() if k in persona}
1733
+ if not seeds:
1734
+ click.echo(f"No matching personas: {', '.join(persona)}", err=True)
1735
+ raise SystemExit(1)
1736
+
1737
+ target_path = Path(target)
1738
+
1739
+ # Generate persona rules summary
1740
+ rules_by_persona: dict[str, list[dict[str, str]]] = {}
1741
+ for name, sf in seeds.items():
1742
+ rules_by_persona[name] = [
1743
+ {"rule": r.rule, "antipattern": r.antipattern, "category": r.category}
1744
+ for r in sf.rules
1745
+ ]
1746
+
1747
+ # Loop instructions
1748
+ instructions = [
1749
+ "1. Review the target code using the rules from each persona",
1750
+ "2. Report all violations as JSON issues with: severity, category, description, rule_learned, location",
1751
+ "3. Call `buildlog_gauntlet_issues` with the issues list to determine next action",
1752
+ "4. If action='fix_criticals': Fix critical+major issues, then re-run gauntlet",
1753
+ "5. If action='checkpoint_majors': Ask user whether to continue fixing majors",
1754
+ "6. If action='checkpoint_minors': Ask user whether to accept risk or continue",
1755
+ "7. If user accepts risk and --auto-gh-issues: Call `buildlog_gauntlet_accept_risk` with remaining issues",
1756
+ "8. Repeat until action='clean' or max_iterations reached",
1757
+ ]
1758
+
1759
+ # Expected issue format
1760
+ issue_format = {
1761
+ "severity": "critical|major|minor|nitpick",
1762
+ "category": "security|testing|architectural|workflow|...",
1763
+ "description": "Concrete description of what's wrong",
1764
+ "rule_learned": "Generalizable rule for the future",
1765
+ "location": "file:line (optional)",
1766
+ }
1767
+
1768
+ # Build the loop output
1769
+ output = {
1770
+ "command": "gauntlet_loop",
1771
+ "target": str(target_path),
1772
+ "personas": list(seeds.keys()),
1773
+ "max_iterations": max_iterations,
1774
+ "stop_at": stop_at,
1775
+ "auto_gh_issues": auto_gh_issues,
1776
+ "rules_by_persona": rules_by_persona,
1777
+ "instructions": instructions,
1778
+ "issue_format": issue_format,
1779
+ }
1780
+
1781
+ if output_json:
1782
+ click.echo(json_module.dumps(output, indent=2))
1783
+ else:
1784
+ # Human-readable output
1785
+ click.echo("=" * 60)
1786
+ click.echo("GAUNTLET LOOP")
1787
+ click.echo("=" * 60)
1788
+ click.echo(f"\nTarget: {target_path}")
1789
+ click.echo(f"Personas: {', '.join(seeds.keys())}")
1790
+ click.echo(f"Max iterations: {max_iterations}")
1791
+ click.echo(f"Stop at: {stop_at}")
1792
+ click.echo(f"Auto GH issues: {auto_gh_issues}")
1793
+
1794
+ click.echo("\n--- RULES ---")
1795
+ for name, rules in rules_by_persona.items():
1796
+ click.echo(f"\n## {name.replace('_', ' ').title()}")
1797
+ for r in rules:
1798
+ click.echo(f" • {r['rule']}")
1799
+
1800
+ click.echo("\n--- LOOP WORKFLOW ---")
1801
+ for instruction in instructions:
1802
+ click.echo(f" {instruction}")
1803
+
1804
+ click.echo("\n--- ISSUE FORMAT ---")
1805
+ click.echo(json_module.dumps(issue_format, indent=2))
1806
+
1807
+ click.echo("\n" + "=" * 60)
1808
+ click.echo("Ready. Run gauntlet review and process issues.")
1809
+ click.echo("=" * 60)
1810
+
1811
+
1267
1812
  if __name__ == "__main__":
1268
1813
  main()