buildlog 0.9.0__py3-none-any.whl → 0.10.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- buildlog/cli.py +268 -26
- buildlog/constants.py +121 -0
- buildlog/core/__init__.py +44 -0
- buildlog/core/operations.py +1170 -0
- buildlog/data/seeds/bragi.yaml +61 -0
- buildlog/mcp/__init__.py +51 -3
- buildlog/mcp/server.py +36 -0
- buildlog/mcp/tools.py +526 -12
- {buildlog-0.9.0.data → buildlog-0.10.0.data}/data/share/buildlog/post_gen.py +10 -5
- buildlog-0.10.0.data/data/share/buildlog/template/buildlog/.gitkeep +0 -0
- buildlog-0.10.0.data/data/share/buildlog/template/buildlog/assets/.gitkeep +0 -0
- {buildlog-0.9.0.dist-info → buildlog-0.10.0.dist-info}/METADATA +22 -22
- {buildlog-0.9.0.dist-info → buildlog-0.10.0.dist-info}/RECORD +23 -19
- {buildlog-0.9.0.data → buildlog-0.10.0.data}/data/share/buildlog/copier.yml +0 -0
- {buildlog-0.9.0.data/data/share/buildlog/template/buildlog → buildlog-0.10.0.data/data/share/buildlog/template/buildlog/.buildlog}/.gitkeep +0 -0
- {buildlog-0.9.0.data/data/share/buildlog/template/buildlog/assets → buildlog-0.10.0.data/data/share/buildlog/template/buildlog/.buildlog/seeds}/.gitkeep +0 -0
- {buildlog-0.9.0.data → buildlog-0.10.0.data}/data/share/buildlog/template/buildlog/2026-01-01-example.md +0 -0
- {buildlog-0.9.0.data → buildlog-0.10.0.data}/data/share/buildlog/template/buildlog/BUILDLOG_SYSTEM.md +0 -0
- {buildlog-0.9.0.data → buildlog-0.10.0.data}/data/share/buildlog/template/buildlog/_TEMPLATE.md +0 -0
- {buildlog-0.9.0.data → buildlog-0.10.0.data}/data/share/buildlog/template/buildlog/_TEMPLATE_QUICK.md +0 -0
- {buildlog-0.9.0.dist-info → buildlog-0.10.0.dist-info}/WHEEL +0 -0
- {buildlog-0.9.0.dist-info → buildlog-0.10.0.dist-info}/entry_points.txt +0 -0
- {buildlog-0.9.0.dist-info → buildlog-0.10.0.dist-info}/licenses/LICENSE +0 -0
buildlog/core/operations.py
CHANGED
|
@@ -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
|
+
)
|