buildlog 0.8.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 +491 -30
- buildlog/constants.py +121 -0
- buildlog/core/__init__.py +44 -0
- buildlog/core/operations.py +1189 -13
- buildlog/data/seeds/bragi.yaml +61 -0
- buildlog/llm.py +51 -4
- buildlog/mcp/__init__.py +51 -3
- buildlog/mcp/server.py +40 -0
- buildlog/mcp/tools.py +526 -12
- buildlog/seed_engine/__init__.py +2 -0
- buildlog/seed_engine/llm_extractor.py +121 -0
- buildlog/seed_engine/pipeline.py +45 -1
- {buildlog-0.8.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.10.0.dist-info/METADATA +248 -0
- {buildlog-0.8.0.dist-info → buildlog-0.10.0.dist-info}/RECORD +27 -22
- buildlog-0.8.0.dist-info/METADATA +0 -151
- {buildlog-0.8.0.data → buildlog-0.10.0.data}/data/share/buildlog/copier.yml +0 -0
- {buildlog-0.8.0.data/data/share/buildlog/template/buildlog → buildlog-0.10.0.data/data/share/buildlog/template/buildlog/.buildlog}/.gitkeep +0 -0
- {buildlog-0.8.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.8.0.data → buildlog-0.10.0.data}/data/share/buildlog/template/buildlog/2026-01-01-example.md +0 -0
- {buildlog-0.8.0.data → buildlog-0.10.0.data}/data/share/buildlog/template/buildlog/BUILDLOG_SYSTEM.md +0 -0
- {buildlog-0.8.0.data → buildlog-0.10.0.data}/data/share/buildlog/template/buildlog/_TEMPLATE.md +0 -0
- {buildlog-0.8.0.data → buildlog-0.10.0.data}/data/share/buildlog/template/buildlog/_TEMPLATE_QUICK.md +0 -0
- {buildlog-0.8.0.dist-info → buildlog-0.10.0.dist-info}/WHEEL +0 -0
- {buildlog-0.8.0.dist-info → buildlog-0.10.0.dist-info}/entry_points.txt +0 -0
- {buildlog-0.8.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
|
|
|
@@ -653,7 +676,7 @@ def reject(
|
|
|
653
676
|
rejected = {"rejected_at": {}, "skill_ids": []}
|
|
654
677
|
|
|
655
678
|
# Add new rejections
|
|
656
|
-
now = datetime.now().isoformat()
|
|
679
|
+
now = datetime.now(timezone.utc).isoformat()
|
|
657
680
|
newly_rejected: list[str] = []
|
|
658
681
|
for skill_id in skill_ids:
|
|
659
682
|
if skill_id not in rejected["skill_ids"]:
|
|
@@ -2035,6 +2058,18 @@ def gauntlet_process_issues(
|
|
|
2035
2058
|
)
|
|
2036
2059
|
|
|
2037
2060
|
|
|
2061
|
+
def _sanitize_for_gh(text: str, max_len: int = 256) -> str:
|
|
2062
|
+
"""Sanitize text for GitHub issue fields.
|
|
2063
|
+
|
|
2064
|
+
Defense-in-depth: we use list args (not shell=True) for subprocess,
|
|
2065
|
+
but sanitize anyway to prevent injection via gh's argument parsing.
|
|
2066
|
+
"""
|
|
2067
|
+
sanitized = text.replace("\n", " ").replace("\r", " ")
|
|
2068
|
+
if len(sanitized) > max_len:
|
|
2069
|
+
sanitized = sanitized[: max_len - 3] + "..."
|
|
2070
|
+
return sanitized.strip()
|
|
2071
|
+
|
|
2072
|
+
|
|
2038
2073
|
def gauntlet_accept_risk(
|
|
2039
2074
|
remaining_issues: list[dict],
|
|
2040
2075
|
create_github_issues: bool = False,
|
|
@@ -2062,17 +2097,6 @@ def gauntlet_accept_risk(
|
|
|
2062
2097
|
description = issue.get("description", "")
|
|
2063
2098
|
location = issue.get("location", "")
|
|
2064
2099
|
|
|
2065
|
-
# Sanitize inputs for GitHub issue creation
|
|
2066
|
-
# Note: We use list args (not shell=True), so this is defense-in-depth
|
|
2067
|
-
def _sanitize_for_gh(text: str, max_len: int = 256) -> str:
|
|
2068
|
-
"""Sanitize text for GitHub issue fields."""
|
|
2069
|
-
# Remove/replace problematic characters
|
|
2070
|
-
sanitized = text.replace("\n", " ").replace("\r", " ")
|
|
2071
|
-
# Truncate to max length
|
|
2072
|
-
if len(sanitized) > max_len:
|
|
2073
|
-
sanitized = sanitized[: max_len - 3] + "..."
|
|
2074
|
-
return sanitized.strip()
|
|
2075
|
-
|
|
2076
2100
|
safe_severity = _sanitize_for_gh(str(severity), 20)
|
|
2077
2101
|
safe_rule = _sanitize_for_gh(str(rule), 200)
|
|
2078
2102
|
safe_description = _sanitize_for_gh(str(description), 1000)
|
|
@@ -2116,7 +2140,9 @@ def gauntlet_accept_risk(
|
|
|
2116
2140
|
cmd.extend(["--repo", repo])
|
|
2117
2141
|
|
|
2118
2142
|
try:
|
|
2119
|
-
result = subprocess.run(
|
|
2143
|
+
result = subprocess.run(
|
|
2144
|
+
cmd, capture_output=True, text=True, check=True, timeout=30
|
|
2145
|
+
)
|
|
2120
2146
|
# gh issue create outputs the URL
|
|
2121
2147
|
url = result.stdout.strip()
|
|
2122
2148
|
if url:
|
|
@@ -2124,6 +2150,9 @@ def gauntlet_accept_risk(
|
|
|
2124
2150
|
except subprocess.CalledProcessError as e:
|
|
2125
2151
|
# Don't fail entirely, just note the error
|
|
2126
2152
|
error = f"Failed to create some GitHub issues: {e.stderr}"
|
|
2153
|
+
except subprocess.TimeoutExpired:
|
|
2154
|
+
error = "GitHub issue creation timed out (30s limit)."
|
|
2155
|
+
break
|
|
2127
2156
|
except FileNotFoundError:
|
|
2128
2157
|
error = "gh CLI not found. Install GitHub CLI to create issues."
|
|
2129
2158
|
break
|
|
@@ -2140,3 +2169,1150 @@ def gauntlet_accept_risk(
|
|
|
2140
2169
|
),
|
|
2141
2170
|
error=error,
|
|
2142
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
|
+
)
|