buildlog 0.9.0__py3-none-any.whl → 0.10.1__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 (23) hide show
  1. buildlog/cli.py +304 -26
  2. buildlog/constants.py +160 -0
  3. buildlog/core/__init__.py +44 -0
  4. buildlog/core/operations.py +1170 -0
  5. buildlog/data/seeds/bragi.yaml +61 -0
  6. buildlog/mcp/__init__.py +51 -3
  7. buildlog/mcp/server.py +36 -0
  8. buildlog/mcp/tools.py +526 -12
  9. {buildlog-0.9.0.data → buildlog-0.10.1.data}/data/share/buildlog/post_gen.py +10 -5
  10. buildlog-0.10.1.data/data/share/buildlog/template/buildlog/.gitkeep +0 -0
  11. buildlog-0.10.1.data/data/share/buildlog/template/buildlog/assets/.gitkeep +0 -0
  12. {buildlog-0.9.0.dist-info → buildlog-0.10.1.dist-info}/METADATA +28 -26
  13. {buildlog-0.9.0.dist-info → buildlog-0.10.1.dist-info}/RECORD +23 -19
  14. {buildlog-0.9.0.data → buildlog-0.10.1.data}/data/share/buildlog/copier.yml +0 -0
  15. {buildlog-0.9.0.data/data/share/buildlog/template/buildlog → buildlog-0.10.1.data/data/share/buildlog/template/buildlog/.buildlog}/.gitkeep +0 -0
  16. {buildlog-0.9.0.data/data/share/buildlog/template/buildlog/assets → buildlog-0.10.1.data/data/share/buildlog/template/buildlog/.buildlog/seeds}/.gitkeep +0 -0
  17. {buildlog-0.9.0.data → buildlog-0.10.1.data}/data/share/buildlog/template/buildlog/2026-01-01-example.md +0 -0
  18. {buildlog-0.9.0.data → buildlog-0.10.1.data}/data/share/buildlog/template/buildlog/BUILDLOG_SYSTEM.md +0 -0
  19. {buildlog-0.9.0.data → buildlog-0.10.1.data}/data/share/buildlog/template/buildlog/_TEMPLATE.md +0 -0
  20. {buildlog-0.9.0.data → buildlog-0.10.1.data}/data/share/buildlog/template/buildlog/_TEMPLATE_QUICK.md +0 -0
  21. {buildlog-0.9.0.dist-info → buildlog-0.10.1.dist-info}/WHEEL +0 -0
  22. {buildlog-0.9.0.dist-info → buildlog-0.10.1.dist-info}/entry_points.txt +0 -0
  23. {buildlog-0.9.0.dist-info → buildlog-0.10.1.dist-info}/licenses/LICENSE +0 -0
@@ -57,6 +57,29 @@ __all__ = [
57
57
  # Gauntlet loop operations
58
58
  "gauntlet_process_issues",
59
59
  "gauntlet_accept_risk",
60
+ # Entry & overview operations
61
+ "GauntletRulesResult",
62
+ "OverviewResult",
63
+ "CreateEntryResult",
64
+ "ListEntriesResult",
65
+ "get_gauntlet_rules",
66
+ "get_overview",
67
+ "create_entry",
68
+ "list_entries",
69
+ # P0: Gauntlet loop
70
+ "CommitResult",
71
+ "GauntletPromptResult",
72
+ "GauntletLoopConfigResult",
73
+ "commit",
74
+ "generate_gauntlet_prompt",
75
+ "gauntlet_loop_config",
76
+ # P2: Nice-to-have
77
+ "GauntletGenerateResult",
78
+ "InitResult",
79
+ "UpdateResult",
80
+ "gauntlet_generate",
81
+ "init_buildlog",
82
+ "update_buildlog",
60
83
  ]
61
84
 
62
85
 
@@ -2146,3 +2169,1150 @@ def gauntlet_accept_risk(
2146
2169
  ),
2147
2170
  error=error,
2148
2171
  )
2172
+
2173
+
2174
+ # =============================================================================
2175
+ # Entry & Overview Operations
2176
+ # =============================================================================
2177
+
2178
+
2179
+ @dataclass
2180
+ class GauntletRulesResult:
2181
+ """Result of loading gauntlet reviewer rules."""
2182
+
2183
+ formatted: str
2184
+ format: str
2185
+ total_rules: int
2186
+ personas: list[str]
2187
+ error: str | None = None
2188
+
2189
+
2190
+ @dataclass
2191
+ class OverviewResult:
2192
+ """Result of getting buildlog overview."""
2193
+
2194
+ entries: int
2195
+ skills: dict
2196
+ active_session: str | None
2197
+ render_targets: list[str]
2198
+
2199
+
2200
+ @dataclass
2201
+ class CreateEntryResult:
2202
+ """Result of creating a new entry."""
2203
+
2204
+ entry_path: str
2205
+ entry_name: str
2206
+ date_str: str
2207
+ template_used: str
2208
+ message: str
2209
+ error: str | None = None
2210
+
2211
+
2212
+ @dataclass
2213
+ class ListEntriesResult:
2214
+ """Result of listing entries."""
2215
+
2216
+ entries: list[dict]
2217
+ count: int
2218
+ message: str | None = None
2219
+
2220
+
2221
+ def get_gauntlet_rules(
2222
+ persona: str | None = None,
2223
+ format: str = "json",
2224
+ ) -> GauntletRulesResult:
2225
+ """Load gauntlet reviewer rules.
2226
+
2227
+ Args:
2228
+ persona: Filter to a specific persona, or None for all.
2229
+ format: Output format (json, yaml, markdown).
2230
+
2231
+ Returns:
2232
+ GauntletRulesResult with formatted rules.
2233
+ """
2234
+ from buildlog.seeds import get_default_seeds_dir, load_all_seeds
2235
+
2236
+ seeds_dir = get_default_seeds_dir()
2237
+ if seeds_dir is None:
2238
+ return GauntletRulesResult(
2239
+ formatted="",
2240
+ format=format,
2241
+ total_rules=0,
2242
+ personas=[],
2243
+ error="No seed files found. Check your buildlog installation.",
2244
+ )
2245
+
2246
+ seeds = load_all_seeds(seeds_dir)
2247
+ if not seeds:
2248
+ return GauntletRulesResult(
2249
+ formatted="",
2250
+ format=format,
2251
+ total_rules=0,
2252
+ personas=[],
2253
+ error="No seed files found in seeds directory.",
2254
+ )
2255
+
2256
+ # Filter by persona
2257
+ if persona is not None:
2258
+ if persona not in seeds:
2259
+ available = ", ".join(seeds.keys())
2260
+ return GauntletRulesResult(
2261
+ formatted="",
2262
+ format=format,
2263
+ total_rules=0,
2264
+ personas=[],
2265
+ error=f"Unknown persona: {persona}. Available: {available}",
2266
+ )
2267
+ seeds = {persona: seeds[persona]}
2268
+
2269
+ # Build data structure
2270
+ data: dict = {}
2271
+ total_rules = 0
2272
+ for name, sf in seeds.items():
2273
+ data[name] = {
2274
+ "version": sf.version,
2275
+ "rules": [
2276
+ {
2277
+ "rule": r.rule,
2278
+ "category": r.category,
2279
+ "context": r.context,
2280
+ "antipattern": r.antipattern,
2281
+ "rationale": r.rationale,
2282
+ "tags": r.tags,
2283
+ }
2284
+ for r in sf.rules
2285
+ ],
2286
+ }
2287
+ total_rules += len(sf.rules)
2288
+
2289
+ # Format output
2290
+ if format == "json":
2291
+ formatted = json.dumps(data, indent=2)
2292
+ elif format == "yaml":
2293
+ import yaml
2294
+
2295
+ formatted = yaml.dump(data, default_flow_style=False, sort_keys=False)
2296
+ elif format == "markdown":
2297
+ lines = ["# Review Gauntlet Rules\n"]
2298
+ for name, sf in seeds.items():
2299
+ lines.append(f"## {name.replace('_', ' ').title()}\n")
2300
+ lines.append(f"*{len(sf.rules)} rules, v{sf.version}*\n")
2301
+ for i, r in enumerate(sf.rules, 1):
2302
+ lines.append(f"### {i}. {r.rule}\n")
2303
+ lines.append(f"**Category**: {r.category} ")
2304
+ if r.context:
2305
+ lines.append(f"**When**: {r.context}\n")
2306
+ if r.antipattern:
2307
+ lines.append(f"**Antipattern**: {r.antipattern}\n")
2308
+ if r.rationale:
2309
+ lines.append(f"**Why**: {r.rationale}\n")
2310
+ lines.append("")
2311
+ formatted = "\n".join(lines)
2312
+ else:
2313
+ formatted = json.dumps(data, indent=2)
2314
+
2315
+ return GauntletRulesResult(
2316
+ formatted=formatted,
2317
+ format=format,
2318
+ total_rules=total_rules,
2319
+ personas=list(seeds.keys()),
2320
+ )
2321
+
2322
+
2323
+ def get_overview(
2324
+ buildlog_dir: Path,
2325
+ ) -> OverviewResult:
2326
+ """Get project buildlog state at a glance.
2327
+
2328
+ Args:
2329
+ buildlog_dir: Path to buildlog directory.
2330
+
2331
+ Returns:
2332
+ OverviewResult with entries, skills, session, and targets.
2333
+ """
2334
+ from buildlog.render import RENDERERS
2335
+
2336
+ # Count entries
2337
+ entries = sorted(buildlog_dir.glob("20??-??-??-*.md"))
2338
+
2339
+ # Skills
2340
+ try:
2341
+ skill_set = generate_skills(buildlog_dir)
2342
+ total_skills = skill_set.total_skills
2343
+ by_confidence: dict[str, int] = {"high": 0, "medium": 0, "low": 0}
2344
+ for cat_skills in skill_set.skills.values():
2345
+ for s in cat_skills:
2346
+ by_confidence[s.confidence] += 1
2347
+ except Exception:
2348
+ total_skills = 0
2349
+ by_confidence = {"high": 0, "medium": 0, "low": 0}
2350
+
2351
+ # Promoted/rejected
2352
+ promoted_path = _get_promoted_path(buildlog_dir)
2353
+ rejected_path = _get_rejected_path(buildlog_dir)
2354
+ promoted_count = len(_load_json_set(promoted_path, "skill_ids"))
2355
+ rejected_count = len(_load_json_set(rejected_path, "skill_ids"))
2356
+
2357
+ # Active session
2358
+ active_session_path = _get_active_session_path(buildlog_dir)
2359
+ active_session = None
2360
+ if active_session_path.exists():
2361
+ try:
2362
+ session_data = json.loads(active_session_path.read_text())
2363
+ active_session = session_data.get("id")
2364
+ except (json.JSONDecodeError, OSError):
2365
+ pass
2366
+
2367
+ return OverviewResult(
2368
+ entries=len(entries),
2369
+ skills={
2370
+ "total": total_skills,
2371
+ "by_confidence": by_confidence,
2372
+ "promoted": promoted_count,
2373
+ "rejected": rejected_count,
2374
+ "pending": total_skills - promoted_count - rejected_count,
2375
+ },
2376
+ active_session=active_session,
2377
+ render_targets=list(RENDERERS.keys()),
2378
+ )
2379
+
2380
+
2381
+ def create_entry(
2382
+ buildlog_dir: Path,
2383
+ slug: str,
2384
+ entry_date: str | None = None,
2385
+ quick: bool = False,
2386
+ ) -> CreateEntryResult:
2387
+ """Create a new buildlog journal entry.
2388
+
2389
+ Args:
2390
+ buildlog_dir: Path to buildlog directory.
2391
+ slug: Short identifier for the entry.
2392
+ entry_date: Date in YYYY-MM-DD format, or None for today.
2393
+ quick: Use short template if True.
2394
+
2395
+ Returns:
2396
+ CreateEntryResult with path and metadata.
2397
+ """
2398
+ import shutil
2399
+ from datetime import date as date_cls
2400
+ from datetime import datetime as dt_cls
2401
+
2402
+ if not buildlog_dir.exists():
2403
+ return CreateEntryResult(
2404
+ entry_path="",
2405
+ entry_name="",
2406
+ date_str="",
2407
+ template_used="",
2408
+ message="",
2409
+ error=f"No buildlog directory found at {buildlog_dir}",
2410
+ )
2411
+
2412
+ # Template selection
2413
+ template_name = "_TEMPLATE_QUICK.md" if quick else "_TEMPLATE.md"
2414
+ template_file = buildlog_dir / template_name
2415
+ if quick and not template_file.exists():
2416
+ template_file = buildlog_dir / "_TEMPLATE.md"
2417
+ template_name = "_TEMPLATE.md"
2418
+
2419
+ if not template_file.exists():
2420
+ return CreateEntryResult(
2421
+ entry_path="",
2422
+ entry_name="",
2423
+ date_str="",
2424
+ template_used="",
2425
+ message="",
2426
+ error=f"No {template_name} found in {buildlog_dir}",
2427
+ )
2428
+
2429
+ # Date
2430
+ if entry_date:
2431
+ try:
2432
+ parsed = dt_cls.strptime(entry_date, "%Y-%m-%d").date()
2433
+ date_str = parsed.isoformat()
2434
+ except ValueError:
2435
+ return CreateEntryResult(
2436
+ entry_path="",
2437
+ entry_name="",
2438
+ date_str="",
2439
+ template_used="",
2440
+ message="",
2441
+ error=f"Invalid date: {entry_date}. Use YYYY-MM-DD.",
2442
+ )
2443
+ else:
2444
+ date_str = date_cls.today().isoformat()
2445
+
2446
+ # Sanitize slug
2447
+ safe_slug = slug.lower().replace(" ", "-").replace("_", "-")
2448
+ safe_slug = "".join(c for c in safe_slug if c.isalnum() or c == "-")
2449
+
2450
+ # Create entry
2451
+ entry_name = f"{date_str}-{safe_slug}.md"
2452
+ entry_path = buildlog_dir / entry_name
2453
+
2454
+ if entry_path.exists():
2455
+ return CreateEntryResult(
2456
+ entry_path=str(entry_path),
2457
+ entry_name=entry_name,
2458
+ date_str=date_str,
2459
+ template_used=template_name,
2460
+ message="",
2461
+ error=f"Entry already exists: {entry_path}",
2462
+ )
2463
+
2464
+ shutil.copy(template_file, entry_path)
2465
+
2466
+ # Replace date placeholder
2467
+ content = entry_path.read_text()
2468
+ content = content.replace("[YYYY-MM-DD]", date_str)
2469
+ entry_path.write_text(content)
2470
+
2471
+ return CreateEntryResult(
2472
+ entry_path=str(entry_path),
2473
+ entry_name=entry_name,
2474
+ date_str=date_str,
2475
+ template_used=template_name,
2476
+ message=f"Created {entry_path}",
2477
+ )
2478
+
2479
+
2480
+ def list_entries(
2481
+ buildlog_dir: Path,
2482
+ ) -> ListEntriesResult:
2483
+ """List all buildlog journal entries, most recent first.
2484
+
2485
+ Args:
2486
+ buildlog_dir: Path to buildlog directory.
2487
+
2488
+ Returns:
2489
+ ListEntriesResult with entry list.
2490
+ """
2491
+ if not buildlog_dir.exists():
2492
+ return ListEntriesResult(
2493
+ entries=[],
2494
+ count=0,
2495
+ message=f"No buildlog directory found at {buildlog_dir}",
2496
+ )
2497
+
2498
+ entry_paths = sorted(
2499
+ buildlog_dir.glob("20??-??-??-*.md"),
2500
+ reverse=True,
2501
+ )
2502
+
2503
+ entries: list[dict] = []
2504
+ for ep in entry_paths:
2505
+ try:
2506
+ first_line = ep.read_text().split("\n")[0]
2507
+ title = (
2508
+ first_line.replace("# Build Journal: ", "").replace("# ", "").strip()
2509
+ )
2510
+ if title == "[TITLE]":
2511
+ title = "(untitled)"
2512
+ except Exception:
2513
+ title = "(unreadable)"
2514
+ entries.append({"name": ep.name, "title": title})
2515
+
2516
+ message = None
2517
+ if not entries:
2518
+ message = "No entries yet. Create one with: buildlog new my-feature"
2519
+
2520
+ return ListEntriesResult(
2521
+ entries=entries,
2522
+ count=len(entries),
2523
+ message=message,
2524
+ )
2525
+
2526
+
2527
+ # =============================================================================
2528
+ # P0: Gauntlet loop operations
2529
+ # =============================================================================
2530
+
2531
+
2532
+ @dataclass
2533
+ class CommitResult:
2534
+ """Result of a commit operation."""
2535
+
2536
+ commit_hash: str
2537
+ commit_message: str
2538
+ files_changed: list[str]
2539
+ entry_path: str | None
2540
+ entry_updated: bool
2541
+ message: str
2542
+ error: str | None = None
2543
+
2544
+
2545
+ @dataclass
2546
+ class GauntletPromptResult:
2547
+ """Result of generating a gauntlet review prompt."""
2548
+
2549
+ prompt: str
2550
+ target: str
2551
+ personas: list[str]
2552
+ total_rules: int
2553
+ message: str
2554
+ error: str | None = None
2555
+
2556
+
2557
+ @dataclass
2558
+ class GauntletLoopConfigResult:
2559
+ """Configuration and instructions for running the gauntlet loop."""
2560
+
2561
+ target: str
2562
+ personas: list[str]
2563
+ max_iterations: int
2564
+ stop_at: str
2565
+ auto_gh_issues: bool
2566
+ rules_by_persona: dict[str, list[dict]]
2567
+ instructions: list[str]
2568
+ issue_format: dict[str, str]
2569
+ prompt: str
2570
+ message: str
2571
+ error: str | None = None
2572
+
2573
+
2574
+ def _resolve_entry_path_core(
2575
+ buildlog_dir: Path,
2576
+ today: str,
2577
+ slug: str | None,
2578
+ explicit: str | None,
2579
+ cwd: str | None = None,
2580
+ ) -> Path:
2581
+ """Find or create the entry path for today."""
2582
+ import subprocess
2583
+
2584
+ if explicit:
2585
+ return Path(explicit)
2586
+
2587
+ existing = list(buildlog_dir.glob(f"{today}-*.md"))
2588
+ if existing:
2589
+ return existing[0]
2590
+
2591
+ if slug is None:
2592
+ try:
2593
+ run_kwargs: dict = {"cwd": cwd} if cwd else {}
2594
+ branch = subprocess.run(
2595
+ ["git", "branch", "--show-current"],
2596
+ capture_output=True,
2597
+ text=True,
2598
+ check=True,
2599
+ **run_kwargs,
2600
+ ).stdout.strip()
2601
+ slug = branch.split("/")[-1].lower().replace("_", "-")
2602
+ slug = "".join(c for c in slug if c.isalnum() or c == "-")
2603
+ except subprocess.CalledProcessError:
2604
+ slug = "session"
2605
+
2606
+ if not slug:
2607
+ slug = "session"
2608
+
2609
+ return buildlog_dir / f"{today}-{slug}.md"
2610
+
2611
+
2612
+ def commit(
2613
+ buildlog_dir: Path,
2614
+ git_args: list[str],
2615
+ slug: str | None = None,
2616
+ entry: str | None = None,
2617
+ no_entry: bool = False,
2618
+ cwd: str | None = None,
2619
+ ) -> CommitResult:
2620
+ """Run git commit and append commit block to today's buildlog entry.
2621
+
2622
+ Args:
2623
+ buildlog_dir: Path to buildlog directory.
2624
+ git_args: Arguments to pass to git commit (e.g., ["-m", "feat: thing"]).
2625
+ slug: Entry slug (default: derived from branch name).
2626
+ entry: Explicit entry file path to append to.
2627
+ no_entry: Skip buildlog entry update.
2628
+ cwd: Working directory for git commands.
2629
+
2630
+ Returns:
2631
+ CommitResult with commit info and entry update status.
2632
+ """
2633
+ import subprocess
2634
+ from datetime import date
2635
+
2636
+ run_kwargs: dict = {"cwd": cwd} if cwd else {}
2637
+
2638
+ git_cmd = ["git", "commit", *git_args]
2639
+ result = subprocess.run(git_cmd, capture_output=True, text=True, **run_kwargs)
2640
+
2641
+ if result.returncode != 0:
2642
+ return CommitResult(
2643
+ commit_hash="",
2644
+ commit_message="",
2645
+ files_changed=[],
2646
+ entry_path=None,
2647
+ entry_updated=False,
2648
+ message="",
2649
+ error=f"git commit failed: {result.stderr.strip()}",
2650
+ )
2651
+
2652
+ try:
2653
+ commit_hash = subprocess.run(
2654
+ ["git", "rev-parse", "--short", "HEAD"],
2655
+ capture_output=True,
2656
+ text=True,
2657
+ check=True,
2658
+ **run_kwargs,
2659
+ ).stdout.strip()
2660
+ commit_msg = subprocess.run(
2661
+ ["git", "log", "-1", "--format=%s"],
2662
+ capture_output=True,
2663
+ text=True,
2664
+ check=True,
2665
+ **run_kwargs,
2666
+ ).stdout.strip()
2667
+ diff_result = subprocess.run(
2668
+ ["git", "diff-tree", "--no-commit-id", "--name-only", "-r", "HEAD"],
2669
+ capture_output=True,
2670
+ text=True,
2671
+ **run_kwargs,
2672
+ )
2673
+ if diff_result.returncode == 0 and diff_result.stdout.strip():
2674
+ files_changed = diff_result.stdout.strip().split("\n")
2675
+ else:
2676
+ # Root commit fallback
2677
+ ls_result = subprocess.run(
2678
+ ["git", "ls-tree", "--name-only", "-r", "HEAD"],
2679
+ capture_output=True,
2680
+ text=True,
2681
+ **run_kwargs,
2682
+ )
2683
+ if ls_result.returncode == 0 and ls_result.stdout.strip():
2684
+ files_changed = ls_result.stdout.strip().split("\n")
2685
+ else:
2686
+ files_changed = []
2687
+ except subprocess.CalledProcessError:
2688
+ return CommitResult(
2689
+ commit_hash="",
2690
+ commit_message="",
2691
+ files_changed=[],
2692
+ entry_path=None,
2693
+ entry_updated=False,
2694
+ message="",
2695
+ error="git commit succeeded but could not read commit info",
2696
+ )
2697
+
2698
+ entry_path_str = None
2699
+ entry_updated = False
2700
+
2701
+ if not no_entry and buildlog_dir.exists():
2702
+ today_str = date.today().isoformat()
2703
+ resolved = _resolve_entry_path_core(buildlog_dir, today_str, slug, entry, cwd)
2704
+
2705
+ commit_block = f"\n### `{commit_hash}` — {commit_msg}\n\n"
2706
+ if files_changed:
2707
+ commit_block += "Files:\n"
2708
+ for f in files_changed[:20]:
2709
+ commit_block += f"- `{f}`\n"
2710
+ if len(files_changed) > 20:
2711
+ commit_block += f"- ...and {len(files_changed) - 20} more\n"
2712
+ commit_block += "\n"
2713
+
2714
+ if resolved.exists():
2715
+ content = resolved.read_text()
2716
+ if "## Commits" not in content:
2717
+ content = content.rstrip() + "\n\n## Commits\n"
2718
+ content += commit_block
2719
+ else:
2720
+ content = f"# {today_str}\n\n## Commits\n{commit_block}"
2721
+
2722
+ resolved.write_text(content)
2723
+ entry_path_str = str(resolved)
2724
+ entry_updated = True
2725
+
2726
+ return CommitResult(
2727
+ commit_hash=commit_hash,
2728
+ commit_message=commit_msg,
2729
+ files_changed=files_changed,
2730
+ entry_path=entry_path_str,
2731
+ entry_updated=entry_updated,
2732
+ message=f"Committed {commit_hash}: {commit_msg}",
2733
+ )
2734
+
2735
+
2736
+ def generate_gauntlet_prompt(
2737
+ target: str,
2738
+ personas: list[str] | None = None,
2739
+ ) -> GauntletPromptResult:
2740
+ """Generate a review prompt combining gauntlet rules with target info.
2741
+
2742
+ Args:
2743
+ target: Path to target code (file or directory).
2744
+ personas: List of persona names to include, or None for all.
2745
+
2746
+ Returns:
2747
+ GauntletPromptResult with the formatted prompt.
2748
+ """
2749
+ from buildlog.seeds import get_default_seeds_dir, load_all_seeds
2750
+
2751
+ seeds_dir = get_default_seeds_dir()
2752
+ if seeds_dir is None:
2753
+ return GauntletPromptResult(
2754
+ prompt="",
2755
+ target=target,
2756
+ personas=[],
2757
+ total_rules=0,
2758
+ message="",
2759
+ error="No seed files found. Check your buildlog installation.",
2760
+ )
2761
+
2762
+ seeds = load_all_seeds(seeds_dir)
2763
+ if not seeds:
2764
+ return GauntletPromptResult(
2765
+ prompt="",
2766
+ target=target,
2767
+ personas=[],
2768
+ total_rules=0,
2769
+ message="",
2770
+ error="No seed files found in seeds directory.",
2771
+ )
2772
+
2773
+ if personas:
2774
+ filtered = {k: v for k, v in seeds.items() if k in personas}
2775
+ if not filtered:
2776
+ available = ", ".join(seeds.keys())
2777
+ return GauntletPromptResult(
2778
+ prompt="",
2779
+ target=target,
2780
+ personas=[],
2781
+ total_rules=0,
2782
+ message="",
2783
+ error=(
2784
+ f"No matching personas: {', '.join(personas)}."
2785
+ f" Available: {available}"
2786
+ ),
2787
+ )
2788
+ seeds = filtered
2789
+
2790
+ lines = [
2791
+ "# Review Gauntlet Prompt\n",
2792
+ "You are running the Review Gauntlet." " Apply these rules ruthlessly.\n",
2793
+ "## Target\n",
2794
+ f"Review: `{target}`\n",
2795
+ "## Reviewers and Rules\n",
2796
+ ]
2797
+
2798
+ total_rules = 0
2799
+ for name, sf in seeds.items():
2800
+ persona_name = name.replace("_", " ").title()
2801
+ lines.append(f"### {persona_name}\n")
2802
+ for r in sf.rules:
2803
+ lines.append(f"- **{r.rule}**")
2804
+ if r.antipattern:
2805
+ lines.append(f" - Antipattern: {r.antipattern}")
2806
+ lines.append("")
2807
+ total_rules += len(sf.rules)
2808
+
2809
+ lines.extend(
2810
+ [
2811
+ "## Output Format\n",
2812
+ "For each issue found, output:\n",
2813
+ "```json",
2814
+ "{",
2815
+ ' "reviewer": "<persona>",',
2816
+ ' "severity": "critical|major|minor|nitpick",',
2817
+ ' "category": "<category>",',
2818
+ ' "location": "<file:line>",',
2819
+ ' "description": "<what is wrong>",',
2820
+ ' "rule_learned": "<generalizable rule>"',
2821
+ "}",
2822
+ "```\n",
2823
+ "## Instructions\n",
2824
+ "1. Read the target code thoroughly",
2825
+ "2. Apply each rule from each reviewer",
2826
+ "3. Report ALL violations found",
2827
+ "4. Be ruthless - this is the gauntlet",
2828
+ "",
2829
+ ]
2830
+ )
2831
+
2832
+ formatted = "\n".join(lines)
2833
+
2834
+ return GauntletPromptResult(
2835
+ prompt=formatted,
2836
+ target=target,
2837
+ personas=list(seeds.keys()),
2838
+ total_rules=total_rules,
2839
+ message=(
2840
+ f"Generated prompt with {total_rules} rules" f" from {len(seeds)} personas"
2841
+ ),
2842
+ )
2843
+
2844
+
2845
+ def gauntlet_loop_config(
2846
+ target: str,
2847
+ personas: list[str] | None = None,
2848
+ max_iterations: int = 10,
2849
+ stop_at: str = "minors",
2850
+ auto_gh_issues: bool = False,
2851
+ ) -> GauntletLoopConfigResult:
2852
+ """Generate gauntlet loop configuration for an agent.
2853
+
2854
+ Args:
2855
+ target: Path to target code.
2856
+ personas: Persona names to include, or None for all.
2857
+ max_iterations: Max loop iterations (default: 10).
2858
+ stop_at: Severity level to stop at (criticals/majors/minors).
2859
+ auto_gh_issues: Create GitHub issues for accepted risk items.
2860
+
2861
+ Returns:
2862
+ GauntletLoopConfigResult with full loop configuration.
2863
+ """
2864
+ from buildlog.seeds import get_default_seeds_dir, load_all_seeds
2865
+
2866
+ seeds_dir = get_default_seeds_dir()
2867
+ _empty = GauntletLoopConfigResult(
2868
+ target=target,
2869
+ personas=[],
2870
+ max_iterations=max_iterations,
2871
+ stop_at=stop_at,
2872
+ auto_gh_issues=auto_gh_issues,
2873
+ rules_by_persona={},
2874
+ instructions=[],
2875
+ issue_format={},
2876
+ prompt="",
2877
+ message="",
2878
+ )
2879
+
2880
+ if seeds_dir is None:
2881
+ _empty.error = "No seed files found. Check your buildlog installation."
2882
+ return _empty
2883
+
2884
+ seeds = load_all_seeds(seeds_dir)
2885
+ if not seeds:
2886
+ _empty.error = "No seed files found in seeds directory."
2887
+ return _empty
2888
+
2889
+ if personas:
2890
+ filtered = {k: v for k, v in seeds.items() if k in personas}
2891
+ if not filtered:
2892
+ available = ", ".join(seeds.keys())
2893
+ _empty.error = f"No matching personas. Available: {available}"
2894
+ return _empty
2895
+ seeds = filtered
2896
+
2897
+ rules_by_persona: dict[str, list[dict]] = {}
2898
+ for name, sf in seeds.items():
2899
+ rules_by_persona[name] = [
2900
+ {
2901
+ "rule": r.rule,
2902
+ "antipattern": r.antipattern,
2903
+ "category": r.category,
2904
+ }
2905
+ for r in sf.rules
2906
+ ]
2907
+
2908
+ prompt_result = generate_gauntlet_prompt(target=target, personas=list(seeds.keys()))
2909
+ prompt = prompt_result.prompt if not prompt_result.error else ""
2910
+
2911
+ instructions = [
2912
+ "1. Review the target code using the rules from each persona",
2913
+ "2. Report all violations as JSON issues with: severity,"
2914
+ " category, description, rule_learned, location",
2915
+ "3. Call `buildlog_gauntlet_issues` with the issues list"
2916
+ " to determine next action",
2917
+ "4. If action='fix_criticals': Fix critical+major issues,"
2918
+ " then re-run gauntlet",
2919
+ "5. If action='checkpoint_majors': Ask user whether to"
2920
+ " continue fixing majors",
2921
+ "6. If action='checkpoint_minors': Ask user whether to"
2922
+ " accept risk or continue",
2923
+ "7. If user accepts risk and auto_gh_issues: Call"
2924
+ " `buildlog_gauntlet_accept_risk` with remaining issues",
2925
+ "8. Repeat until action='clean' or max_iterations reached",
2926
+ ]
2927
+
2928
+ issue_format = {
2929
+ "severity": "critical|major|minor|nitpick",
2930
+ "category": "security|testing|architectural|workflow|...",
2931
+ "description": "Concrete description of what's wrong",
2932
+ "rule_learned": "Generalizable rule for the future",
2933
+ "location": "file:line (optional)",
2934
+ }
2935
+
2936
+ return GauntletLoopConfigResult(
2937
+ target=target,
2938
+ personas=list(seeds.keys()),
2939
+ max_iterations=max_iterations,
2940
+ stop_at=stop_at,
2941
+ auto_gh_issues=auto_gh_issues,
2942
+ rules_by_persona=rules_by_persona,
2943
+ instructions=instructions,
2944
+ issue_format=issue_format,
2945
+ prompt=prompt,
2946
+ message=(
2947
+ f"Gauntlet loop ready: {len(seeds)} personas,"
2948
+ f" max {max_iterations} iterations"
2949
+ ),
2950
+ )
2951
+
2952
+
2953
+ # =============================================================================
2954
+ # P2: Nice-to-have operations
2955
+ # =============================================================================
2956
+
2957
+
2958
+ @dataclass
2959
+ class GauntletGenerateResult:
2960
+ """Result of generating seed rules from source text."""
2961
+
2962
+ persona: str
2963
+ rule_count: int
2964
+ source_count: int
2965
+ output_path: str | None
2966
+ preview: dict | None
2967
+ message: str
2968
+ error: str | None = None
2969
+
2970
+
2971
+ @dataclass
2972
+ class InitResult:
2973
+ """Result of initializing buildlog in a project."""
2974
+
2975
+ initialized: bool
2976
+ buildlog_dir: str
2977
+ claude_md_updated: bool
2978
+ mcp_registered: bool
2979
+ message: str
2980
+ error: str | None = None
2981
+
2982
+
2983
+ @dataclass
2984
+ class UpdateResult:
2985
+ """Result of updating buildlog templates."""
2986
+
2987
+ updated: bool
2988
+ message: str
2989
+ error: str | None = None
2990
+
2991
+
2992
+ def gauntlet_generate(
2993
+ source_text: str,
2994
+ persona: str,
2995
+ output_dir: Path | None = None,
2996
+ dry_run: bool = False,
2997
+ ) -> GauntletGenerateResult:
2998
+ """Generate seed rules from source text using LLM extraction.
2999
+
3000
+ Args:
3001
+ source_text: The text content to extract rules from.
3002
+ persona: Persona name for the seed file.
3003
+ output_dir: Output directory for seed YAML.
3004
+ dry_run: Preview without writing to disk.
3005
+
3006
+ Returns:
3007
+ GauntletGenerateResult with generation info.
3008
+ """
3009
+ if not source_text.strip():
3010
+ return GauntletGenerateResult(
3011
+ persona=persona,
3012
+ rule_count=0,
3013
+ source_count=0,
3014
+ output_path=None,
3015
+ preview=None,
3016
+ message="",
3017
+ error="Empty source text provided.",
3018
+ )
3019
+
3020
+ try:
3021
+ from buildlog.seed_engine import Pipeline
3022
+ except ImportError:
3023
+ return GauntletGenerateResult(
3024
+ persona=persona,
3025
+ rule_count=0,
3026
+ source_count=0,
3027
+ output_path=None,
3028
+ preview=None,
3029
+ message="",
3030
+ error="Seed engine not available. Check installation.",
3031
+ )
3032
+
3033
+ if output_dir is None:
3034
+ output_dir = Path("buildlog/.buildlog/seeds")
3035
+
3036
+ # Get LLM backend
3037
+ try:
3038
+ from buildlog.llm import get_llm_backend
3039
+
3040
+ backend = get_llm_backend()
3041
+ except Exception:
3042
+ backend = None
3043
+
3044
+ if backend is None:
3045
+ return GauntletGenerateResult(
3046
+ persona=persona,
3047
+ rule_count=0,
3048
+ source_count=1,
3049
+ output_path=None,
3050
+ preview=None,
3051
+ message="",
3052
+ error=(
3053
+ "No LLM backend available. Set ANTHROPIC_API_KEY" " or install Ollama."
3054
+ ),
3055
+ )
3056
+
3057
+ source_content = {"inline": source_text}
3058
+
3059
+ try:
3060
+ from buildlog.seed_engine.models import Source, SourceType
3061
+
3062
+ sources = [
3063
+ Source(
3064
+ name="inline",
3065
+ url="mcp://inline",
3066
+ source_type=SourceType.REFERENCE_DOC,
3067
+ domain="general",
3068
+ )
3069
+ ]
3070
+ except ImportError:
3071
+ return GauntletGenerateResult(
3072
+ persona=persona,
3073
+ rule_count=0,
3074
+ source_count=1,
3075
+ output_path=None,
3076
+ preview=None,
3077
+ message="",
3078
+ error="Seed engine models not available.",
3079
+ )
3080
+
3081
+ try:
3082
+ pipeline = Pipeline.with_llm(
3083
+ persona=persona,
3084
+ backend=backend,
3085
+ source_content=source_content,
3086
+ )
3087
+ except Exception as e:
3088
+ return GauntletGenerateResult(
3089
+ persona=persona,
3090
+ rule_count=0,
3091
+ source_count=1,
3092
+ output_path=None,
3093
+ preview=None,
3094
+ message="",
3095
+ error=f"Failed to initialize pipeline: {e}",
3096
+ )
3097
+
3098
+ try:
3099
+ if dry_run:
3100
+ preview = pipeline.dry_run(sources)
3101
+ return GauntletGenerateResult(
3102
+ persona=persona,
3103
+ rule_count=preview.get("rule_count", 0),
3104
+ source_count=1,
3105
+ output_path=None,
3106
+ preview=preview,
3107
+ message="Dry run complete (no files written).",
3108
+ )
3109
+ else:
3110
+ pipe_result = pipeline.run(sources, output_dir=output_dir)
3111
+ output_path_str = str(output_dir / f"{persona}.yaml")
3112
+ return GauntletGenerateResult(
3113
+ persona=persona,
3114
+ rule_count=pipe_result.rule_count,
3115
+ source_count=1,
3116
+ output_path=output_path_str,
3117
+ preview=None,
3118
+ message=f"Generated seed file: {output_path_str}",
3119
+ )
3120
+ except Exception as e:
3121
+ return GauntletGenerateResult(
3122
+ persona=persona,
3123
+ rule_count=0,
3124
+ source_count=1,
3125
+ output_path=None,
3126
+ preview=None,
3127
+ message="",
3128
+ error=f"Pipeline execution failed: {e}",
3129
+ )
3130
+
3131
+
3132
+ def init_buildlog(
3133
+ project_dir: Path,
3134
+ defaults: bool = True,
3135
+ no_claude_md: bool = False,
3136
+ no_mcp: bool = False,
3137
+ ) -> InitResult:
3138
+ """Initialize buildlog in a project directory.
3139
+
3140
+ Args:
3141
+ project_dir: Project root directory.
3142
+ defaults: Use default values (non-interactive).
3143
+ no_claude_md: Skip CLAUDE.md update.
3144
+ no_mcp: Skip MCP server registration.
3145
+
3146
+ Returns:
3147
+ InitResult with initialization status.
3148
+ """
3149
+ import subprocess
3150
+ import sys
3151
+
3152
+ buildlog_dir = project_dir / "buildlog"
3153
+ if buildlog_dir.exists():
3154
+ return InitResult(
3155
+ initialized=False,
3156
+ buildlog_dir=str(buildlog_dir),
3157
+ claude_md_updated=False,
3158
+ mcp_registered=False,
3159
+ message="",
3160
+ error=f"buildlog/ already exists at {buildlog_dir}",
3161
+ )
3162
+
3163
+ # Find template directory
3164
+ template_src = None
3165
+ # Check for local template
3166
+ here = Path(__file__).resolve().parent.parent
3167
+ local_template = here / "template"
3168
+ if not local_template.exists():
3169
+ local_template = here.parent / "template"
3170
+ if local_template.exists():
3171
+ template_src = str(local_template)
3172
+ else:
3173
+ template_src = "gh:Peleke/buildlog-template"
3174
+
3175
+ cmd = [
3176
+ sys.executable,
3177
+ "-m",
3178
+ "copier",
3179
+ "copy",
3180
+ "--trust",
3181
+ ]
3182
+ if defaults:
3183
+ cmd.append("--defaults")
3184
+ cmd.extend([template_src, str(project_dir)])
3185
+
3186
+ try:
3187
+ result = subprocess.run(
3188
+ cmd,
3189
+ capture_output=True,
3190
+ text=True,
3191
+ timeout=60,
3192
+ )
3193
+ if result.returncode != 0:
3194
+ return InitResult(
3195
+ initialized=False,
3196
+ buildlog_dir=str(buildlog_dir),
3197
+ claude_md_updated=False,
3198
+ mcp_registered=False,
3199
+ message="",
3200
+ error=f"copier failed: {result.stderr.strip()}",
3201
+ )
3202
+ except FileNotFoundError:
3203
+ return InitResult(
3204
+ initialized=False,
3205
+ buildlog_dir=str(buildlog_dir),
3206
+ claude_md_updated=False,
3207
+ mcp_registered=False,
3208
+ message="",
3209
+ error="copier not found. Install with: pip install copier",
3210
+ )
3211
+ except subprocess.TimeoutExpired:
3212
+ return InitResult(
3213
+ initialized=False,
3214
+ buildlog_dir=str(buildlog_dir),
3215
+ claude_md_updated=False,
3216
+ mcp_registered=False,
3217
+ message="",
3218
+ error="copier timed out after 60 seconds",
3219
+ )
3220
+
3221
+ # Create .buildlog directories (copier skips dot-prefixed paths)
3222
+ dot_buildlog = buildlog_dir / ".buildlog"
3223
+ dot_buildlog.mkdir(exist_ok=True)
3224
+ (dot_buildlog / "seeds").mkdir(exist_ok=True)
3225
+
3226
+ claude_md_updated = False
3227
+ if not no_claude_md:
3228
+ claude_md = project_dir / "CLAUDE.md"
3229
+ if claude_md.exists():
3230
+ try:
3231
+ from buildlog.constants import CLAUDE_MD_BUILDLOG_SECTION
3232
+
3233
+ content = claude_md.read_text()
3234
+ if "## buildlog Integration" not in content:
3235
+ content = content.rstrip() + "\n\n" + CLAUDE_MD_BUILDLOG_SECTION
3236
+ claude_md.write_text(content)
3237
+ claude_md_updated = True
3238
+ except ImportError:
3239
+ pass
3240
+
3241
+ mcp_registered = False
3242
+ if not no_mcp:
3243
+ try:
3244
+ claude_dir = project_dir / ".claude"
3245
+ claude_dir.mkdir(exist_ok=True)
3246
+ settings_path = claude_dir / "settings.json"
3247
+ settings: dict = {}
3248
+ if settings_path.exists():
3249
+ try:
3250
+ settings = json.loads(settings_path.read_text())
3251
+ except json.JSONDecodeError:
3252
+ pass
3253
+ if "mcpServers" not in settings:
3254
+ settings["mcpServers"] = {}
3255
+ if "buildlog" not in settings["mcpServers"]:
3256
+ settings["mcpServers"]["buildlog"] = {
3257
+ "command": "buildlog-mcp",
3258
+ "args": [],
3259
+ }
3260
+ settings_path.write_text(json.dumps(settings, indent=2) + "\n")
3261
+ mcp_registered = True
3262
+ except Exception:
3263
+ pass
3264
+
3265
+ return InitResult(
3266
+ initialized=True,
3267
+ buildlog_dir=str(buildlog_dir),
3268
+ claude_md_updated=claude_md_updated,
3269
+ mcp_registered=mcp_registered,
3270
+ message="buildlog initialized successfully.",
3271
+ )
3272
+
3273
+
3274
+ def update_buildlog(
3275
+ project_dir: Path,
3276
+ ) -> UpdateResult:
3277
+ """Update buildlog templates to the latest version.
3278
+
3279
+ Args:
3280
+ project_dir: Project root directory.
3281
+
3282
+ Returns:
3283
+ UpdateResult with update status.
3284
+ """
3285
+ import subprocess
3286
+ import sys
3287
+
3288
+ try:
3289
+ result = subprocess.run(
3290
+ [sys.executable, "-m", "copier", "update", "--trust"],
3291
+ capture_output=True,
3292
+ text=True,
3293
+ cwd=str(project_dir),
3294
+ timeout=120,
3295
+ )
3296
+ if result.returncode != 0:
3297
+ return UpdateResult(
3298
+ updated=False,
3299
+ message="",
3300
+ error=f"copier update failed: {result.stderr.strip()}",
3301
+ )
3302
+ except FileNotFoundError:
3303
+ return UpdateResult(
3304
+ updated=False,
3305
+ message="",
3306
+ error="copier not found. Install with: pip install copier",
3307
+ )
3308
+ except subprocess.TimeoutExpired:
3309
+ return UpdateResult(
3310
+ updated=False,
3311
+ message="",
3312
+ error="copier update timed out after 120 seconds",
3313
+ )
3314
+
3315
+ return UpdateResult(
3316
+ updated=True,
3317
+ message="buildlog templates updated successfully.",
3318
+ )