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/cli.py
CHANGED
|
@@ -50,12 +50,13 @@ def main():
|
|
|
50
50
|
|
|
51
51
|
@main.command()
|
|
52
52
|
@click.option("--no-claude-md", is_flag=True, help="Don't update CLAUDE.md")
|
|
53
|
+
@click.option("--no-mcp", is_flag=True, help="Don't register MCP server")
|
|
53
54
|
@click.option(
|
|
54
55
|
"--defaults",
|
|
55
56
|
is_flag=True,
|
|
56
57
|
help="Use default values for all prompts (non-interactive)",
|
|
57
58
|
)
|
|
58
|
-
def init(no_claude_md: bool, defaults: bool):
|
|
59
|
+
def init(no_claude_md: bool, no_mcp: bool, defaults: bool):
|
|
59
60
|
"""Initialize buildlog in the current directory.
|
|
60
61
|
|
|
61
62
|
Sets up the buildlog/ directory with templates and optionally
|
|
@@ -109,23 +110,40 @@ def init(no_claude_md: bool, defaults: bool):
|
|
|
109
110
|
click.echo("Failed to initialize buildlog.", err=True)
|
|
110
111
|
raise SystemExit(1)
|
|
111
112
|
|
|
113
|
+
# Ensure .buildlog/ directory exists (copier skips dot-prefixed paths)
|
|
114
|
+
dot_buildlog = buildlog_dir / ".buildlog"
|
|
115
|
+
dot_buildlog.mkdir(exist_ok=True)
|
|
116
|
+
(dot_buildlog / "seeds").mkdir(exist_ok=True)
|
|
117
|
+
|
|
112
118
|
# Update CLAUDE.md if it exists and user didn't opt out
|
|
113
119
|
if not no_claude_md:
|
|
114
120
|
claude_md = Path("CLAUDE.md")
|
|
115
121
|
if claude_md.exists():
|
|
116
122
|
content = claude_md.read_text()
|
|
117
|
-
if
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
123
|
+
if (
|
|
124
|
+
"## buildlog Integration" not in content
|
|
125
|
+
and "## Build Journal" not in content
|
|
126
|
+
):
|
|
127
|
+
try:
|
|
128
|
+
from buildlog.constants import CLAUDE_MD_BUILDLOG_SECTION
|
|
129
|
+
|
|
130
|
+
section = CLAUDE_MD_BUILDLOG_SECTION
|
|
131
|
+
except ImportError:
|
|
132
|
+
section = (
|
|
133
|
+
"\n## Build Journal\n\n"
|
|
134
|
+
"After completing significant work (features, debugging "
|
|
135
|
+
"sessions, deployments,\n"
|
|
136
|
+
"2+ hour focused sessions), write a build journal entry.\n\n"
|
|
137
|
+
"**Location:** `buildlog/YYYY-MM-DD-{slug}.md`\n"
|
|
138
|
+
"**Template:** `buildlog/_TEMPLATE.md`\n"
|
|
139
|
+
)
|
|
126
140
|
with open(claude_md, "a") as f:
|
|
127
141
|
f.write(section)
|
|
128
|
-
click.echo("Added
|
|
142
|
+
click.echo("Added buildlog Integration section to CLAUDE.md")
|
|
143
|
+
|
|
144
|
+
# Register MCP server unless opted out
|
|
145
|
+
if not no_mcp:
|
|
146
|
+
_init_mcp()
|
|
129
147
|
|
|
130
148
|
click.echo("\n✓ buildlog initialized!")
|
|
131
149
|
click.echo()
|
|
@@ -142,12 +160,125 @@ def init(no_claude_md: bool, defaults: bool):
|
|
|
142
160
|
click.echo("Start now: buildlog new my-first-task --quick")
|
|
143
161
|
|
|
144
162
|
|
|
163
|
+
def _init_mcp(settings_path: Path | None = None, global_mode: bool = False) -> None:
|
|
164
|
+
"""Register buildlog as an MCP server in settings.json.
|
|
165
|
+
|
|
166
|
+
Args:
|
|
167
|
+
settings_path: Path to settings.json. Defaults to .claude/settings.json
|
|
168
|
+
global_mode: If True, display global-specific messaging
|
|
169
|
+
"""
|
|
170
|
+
import json as json_module
|
|
171
|
+
|
|
172
|
+
if settings_path is None:
|
|
173
|
+
settings_path = Path(".claude") / "settings.json"
|
|
174
|
+
|
|
175
|
+
location = "~/.claude/settings.json" if global_mode else ".claude/settings.json"
|
|
176
|
+
|
|
177
|
+
try:
|
|
178
|
+
if settings_path.exists():
|
|
179
|
+
try:
|
|
180
|
+
data = json_module.loads(settings_path.read_text())
|
|
181
|
+
except json_module.JSONDecodeError:
|
|
182
|
+
click.echo(
|
|
183
|
+
f"Warning: {location} is malformed, skipping MCP registration",
|
|
184
|
+
err=True,
|
|
185
|
+
)
|
|
186
|
+
return
|
|
187
|
+
else:
|
|
188
|
+
data = {}
|
|
189
|
+
|
|
190
|
+
if "mcpServers" not in data:
|
|
191
|
+
data["mcpServers"] = {}
|
|
192
|
+
|
|
193
|
+
if "buildlog" in data["mcpServers"]:
|
|
194
|
+
click.echo(f"buildlog MCP server already registered in {location}")
|
|
195
|
+
return
|
|
196
|
+
|
|
197
|
+
data["mcpServers"]["buildlog"] = {"command": "buildlog-mcp", "args": []}
|
|
198
|
+
|
|
199
|
+
settings_path.parent.mkdir(parents=True, exist_ok=True)
|
|
200
|
+
settings_path.write_text(json_module.dumps(data, indent=2) + "\n")
|
|
201
|
+
click.echo(f"Registered buildlog MCP server in {location}")
|
|
202
|
+
if global_mode:
|
|
203
|
+
click.echo("Claude Code now has access to buildlog tools in all projects.")
|
|
204
|
+
except Exception as e:
|
|
205
|
+
click.echo(f"Warning: could not register MCP server: {e}", err=True)
|
|
206
|
+
|
|
207
|
+
|
|
208
|
+
@main.command("init-mcp")
|
|
209
|
+
@click.option(
|
|
210
|
+
"--global",
|
|
211
|
+
"global_",
|
|
212
|
+
is_flag=True,
|
|
213
|
+
help="Register globally in ~/.claude/settings.json (works in any project)",
|
|
214
|
+
)
|
|
215
|
+
def init_mcp(global_: bool):
|
|
216
|
+
"""Register buildlog as an MCP server for Claude Code.
|
|
217
|
+
|
|
218
|
+
Creates or updates .claude/settings.json with the buildlog MCP
|
|
219
|
+
server configuration. Idempotent — safe to run multiple times.
|
|
220
|
+
|
|
221
|
+
Use --global to register in ~/.claude/settings.json so buildlog
|
|
222
|
+
tools are available in every project without per-project init.
|
|
223
|
+
|
|
224
|
+
Examples:
|
|
225
|
+
|
|
226
|
+
buildlog init-mcp # local (current project)
|
|
227
|
+
buildlog init-mcp --global # global (all projects)
|
|
228
|
+
"""
|
|
229
|
+
if global_:
|
|
230
|
+
settings_path = Path.home() / ".claude" / "settings.json"
|
|
231
|
+
_init_mcp(settings_path=settings_path, global_mode=True)
|
|
232
|
+
else:
|
|
233
|
+
_init_mcp()
|
|
234
|
+
|
|
235
|
+
|
|
236
|
+
@main.command("mcp-test")
|
|
237
|
+
def mcp_test():
|
|
238
|
+
"""Verify the MCP server starts and all tools are registered.
|
|
239
|
+
|
|
240
|
+
Checks that the buildlog-mcp server can be imported and lists
|
|
241
|
+
all registered tools. Exits 0 if all 29 tools are found, 1 otherwise.
|
|
242
|
+
|
|
243
|
+
Examples:
|
|
244
|
+
|
|
245
|
+
buildlog mcp-test
|
|
246
|
+
"""
|
|
247
|
+
try:
|
|
248
|
+
from buildlog.mcp.server import mcp as mcp_server
|
|
249
|
+
except ImportError:
|
|
250
|
+
click.echo("MCP not installed. Run: pip install buildlog", err=True)
|
|
251
|
+
raise SystemExit(1)
|
|
252
|
+
|
|
253
|
+
try:
|
|
254
|
+
# FastMCP stores tools internally
|
|
255
|
+
tools = mcp_server._tool_manager._tools
|
|
256
|
+
tool_names = sorted(tools.keys())
|
|
257
|
+
except AttributeError:
|
|
258
|
+
# Fallback: try to count via the public API pattern
|
|
259
|
+
click.echo("Warning: could not inspect tools via internal API", err=True)
|
|
260
|
+
tool_names = []
|
|
261
|
+
|
|
262
|
+
expected = 29
|
|
263
|
+
click.echo(f"buildlog MCP server: {len(tool_names)} tools registered")
|
|
264
|
+
for name in tool_names:
|
|
265
|
+
click.echo(f" {name}")
|
|
266
|
+
|
|
267
|
+
if len(tool_names) >= expected:
|
|
268
|
+
click.echo(f"\nAll {expected} tools registered.")
|
|
269
|
+
raise SystemExit(0)
|
|
270
|
+
else:
|
|
271
|
+
click.echo(f"\nExpected {expected} tools, found {len(tool_names)}.", err=True)
|
|
272
|
+
raise SystemExit(1)
|
|
273
|
+
|
|
274
|
+
|
|
145
275
|
@main.command()
|
|
146
276
|
@click.option("--json", "output_json", is_flag=True, help="Output as JSON")
|
|
147
277
|
def overview(output_json: bool):
|
|
148
278
|
"""Show the full state of your buildlog at a glance.
|
|
149
279
|
|
|
150
280
|
Entries, skills, promoted rules, experiments — everything in one view.
|
|
281
|
+
Works even without buildlog init (shows uninitialized state).
|
|
151
282
|
|
|
152
283
|
Examples:
|
|
153
284
|
|
|
@@ -158,9 +289,38 @@ def overview(output_json: bool):
|
|
|
158
289
|
|
|
159
290
|
buildlog_dir = Path("buildlog")
|
|
160
291
|
|
|
292
|
+
# Handle uninitialized state gracefully
|
|
161
293
|
if not buildlog_dir.exists():
|
|
162
|
-
|
|
163
|
-
|
|
294
|
+
result = {
|
|
295
|
+
"initialized": False,
|
|
296
|
+
"entries": 0,
|
|
297
|
+
"skills": {
|
|
298
|
+
"total": 0,
|
|
299
|
+
"by_confidence": {},
|
|
300
|
+
"promoted": 0,
|
|
301
|
+
"rejected": 0,
|
|
302
|
+
"pending": 0,
|
|
303
|
+
},
|
|
304
|
+
"active_session": None,
|
|
305
|
+
"render_targets": [],
|
|
306
|
+
"message": "buildlog not initialized. Run 'buildlog init' to enable full features.",
|
|
307
|
+
}
|
|
308
|
+
if output_json:
|
|
309
|
+
click.echo(json_module.dumps(result, indent=2))
|
|
310
|
+
else:
|
|
311
|
+
click.echo("buildlog overview")
|
|
312
|
+
click.echo("=" * 40)
|
|
313
|
+
click.echo(" Status: Not initialized")
|
|
314
|
+
click.echo()
|
|
315
|
+
click.echo("Get started:")
|
|
316
|
+
click.echo(" buildlog init --defaults # Initialize buildlog")
|
|
317
|
+
click.echo()
|
|
318
|
+
click.echo("Or use globally without init:")
|
|
319
|
+
click.echo(" buildlog init-mcp --global # Register MCP server globally")
|
|
320
|
+
click.echo(
|
|
321
|
+
" buildlog gauntlet list # Review personas work without init"
|
|
322
|
+
)
|
|
323
|
+
return
|
|
164
324
|
|
|
165
325
|
# Count entries
|
|
166
326
|
entries = sorted(buildlog_dir.glob("20??-??-??-*.md"))
|
|
@@ -208,6 +368,7 @@ def overview(output_json: bool):
|
|
|
208
368
|
from buildlog.render import RENDERERS
|
|
209
369
|
|
|
210
370
|
result = {
|
|
371
|
+
"initialized": True,
|
|
211
372
|
"entries": len(entries),
|
|
212
373
|
"skills": {
|
|
213
374
|
"total": total_skills,
|
|
@@ -298,11 +459,11 @@ def new(slug: str, entry_date: str | None, quick: bool):
|
|
|
298
459
|
# Determine date
|
|
299
460
|
if entry_date:
|
|
300
461
|
try:
|
|
301
|
-
# Validate date format
|
|
302
|
-
|
|
303
|
-
date_str =
|
|
462
|
+
# Validate date format AND range (month 1-12, day 1-31)
|
|
463
|
+
parsed = datetime.strptime(entry_date, "%Y-%m-%d").date()
|
|
464
|
+
date_str = parsed.isoformat()
|
|
304
465
|
except ValueError:
|
|
305
|
-
click.echo("Invalid date
|
|
466
|
+
click.echo("Invalid date. Use YYYY-MM-DD with valid values.", err=True)
|
|
306
467
|
raise SystemExit(1)
|
|
307
468
|
else:
|
|
308
469
|
date_str = date.today().isoformat()
|
|
@@ -331,6 +492,161 @@ def new(slug: str, entry_date: str | None, quick: bool):
|
|
|
331
492
|
click.echo(f"\nOpen it: $EDITOR {entry_path}")
|
|
332
493
|
|
|
333
494
|
|
|
495
|
+
@main.command(
|
|
496
|
+
context_settings={"ignore_unknown_options": True, "allow_extra_args": True}
|
|
497
|
+
)
|
|
498
|
+
@click.option(
|
|
499
|
+
"--slug",
|
|
500
|
+
"-s",
|
|
501
|
+
default=None,
|
|
502
|
+
help="Entry slug (default: derived from branch name)",
|
|
503
|
+
)
|
|
504
|
+
@click.option(
|
|
505
|
+
"--entry",
|
|
506
|
+
"-e",
|
|
507
|
+
type=click.Path(),
|
|
508
|
+
default=None,
|
|
509
|
+
help="Explicit entry file to append to",
|
|
510
|
+
)
|
|
511
|
+
@click.option(
|
|
512
|
+
"--no-entry",
|
|
513
|
+
is_flag=True,
|
|
514
|
+
help="Skip buildlog entry update (just run git commit)",
|
|
515
|
+
)
|
|
516
|
+
@click.pass_context
|
|
517
|
+
def commit(ctx, slug: str | None, entry: str | None, no_entry: bool):
|
|
518
|
+
"""Commit code and update the buildlog entry in one step.
|
|
519
|
+
|
|
520
|
+
Wraps `git commit` and appends commit context to today's buildlog
|
|
521
|
+
entry. If no entry exists for today, creates one automatically.
|
|
522
|
+
|
|
523
|
+
All unknown options/arguments are passed through to git commit.
|
|
524
|
+
|
|
525
|
+
Examples:
|
|
526
|
+
|
|
527
|
+
buildlog commit -m "feat: add LLM extractor"
|
|
528
|
+
buildlog commit --slug llm-extractor -m "feat: add LLM extractor"
|
|
529
|
+
buildlog commit --no-entry -m "chore: formatting"
|
|
530
|
+
"""
|
|
531
|
+
buildlog_dir = Path("buildlog")
|
|
532
|
+
|
|
533
|
+
# Build git commit command — extra args are passed through from Click context
|
|
534
|
+
git_cmd = ["git", "commit", *ctx.args]
|
|
535
|
+
|
|
536
|
+
# Run git commit first
|
|
537
|
+
result = subprocess.run(git_cmd, capture_output=True, text=True)
|
|
538
|
+
sys.stdout.write(result.stdout)
|
|
539
|
+
sys.stderr.write(result.stderr)
|
|
540
|
+
|
|
541
|
+
if result.returncode != 0:
|
|
542
|
+
raise SystemExit(result.returncode)
|
|
543
|
+
|
|
544
|
+
if no_entry or not buildlog_dir.exists():
|
|
545
|
+
return
|
|
546
|
+
|
|
547
|
+
# Get commit info from what we just committed
|
|
548
|
+
try:
|
|
549
|
+
commit_hash = subprocess.run(
|
|
550
|
+
["git", "rev-parse", "--short", "HEAD"],
|
|
551
|
+
capture_output=True,
|
|
552
|
+
text=True,
|
|
553
|
+
check=True,
|
|
554
|
+
).stdout.strip()
|
|
555
|
+
commit_msg = subprocess.run(
|
|
556
|
+
["git", "log", "-1", "--format=%s"],
|
|
557
|
+
capture_output=True,
|
|
558
|
+
text=True,
|
|
559
|
+
check=True,
|
|
560
|
+
).stdout.strip()
|
|
561
|
+
# diff-tree needs special handling for root commit
|
|
562
|
+
diff_result = subprocess.run(
|
|
563
|
+
["git", "diff-tree", "--no-commit-id", "--name-only", "-r", "HEAD"],
|
|
564
|
+
capture_output=True,
|
|
565
|
+
text=True,
|
|
566
|
+
)
|
|
567
|
+
if diff_result.returncode == 0 and diff_result.stdout.strip():
|
|
568
|
+
files_changed = diff_result.stdout.strip()
|
|
569
|
+
else:
|
|
570
|
+
# Root commit fallback
|
|
571
|
+
files_changed = subprocess.run(
|
|
572
|
+
["git", "diff", "--name-only", "--cached", "HEAD~1"],
|
|
573
|
+
capture_output=True,
|
|
574
|
+
text=True,
|
|
575
|
+
).stdout.strip()
|
|
576
|
+
if not files_changed:
|
|
577
|
+
# Truly initial commit — list all tracked files
|
|
578
|
+
files_changed = subprocess.run(
|
|
579
|
+
["git", "ls-tree", "--name-only", "-r", "HEAD"],
|
|
580
|
+
capture_output=True,
|
|
581
|
+
text=True,
|
|
582
|
+
).stdout.strip()
|
|
583
|
+
except subprocess.CalledProcessError:
|
|
584
|
+
click.echo("Warning: could not read commit info", err=True)
|
|
585
|
+
return
|
|
586
|
+
|
|
587
|
+
# Resolve entry file
|
|
588
|
+
today = date.today().isoformat()
|
|
589
|
+
entry_path = _resolve_entry_path(buildlog_dir, today, slug, entry)
|
|
590
|
+
|
|
591
|
+
# Append commit block
|
|
592
|
+
commit_block = f"\n### `{commit_hash}` — {commit_msg}\n\n"
|
|
593
|
+
if files_changed:
|
|
594
|
+
file_list = files_changed.split("\n")
|
|
595
|
+
commit_block += "Files:\n"
|
|
596
|
+
for f in file_list[:20]: # cap at 20 to avoid noise
|
|
597
|
+
commit_block += f"- `{f}`\n"
|
|
598
|
+
if len(file_list) > 20:
|
|
599
|
+
commit_block += f"- ...and {len(file_list) - 20} more\n"
|
|
600
|
+
commit_block += "\n"
|
|
601
|
+
|
|
602
|
+
# Ensure commits section exists, append to it
|
|
603
|
+
if entry_path.exists():
|
|
604
|
+
content = entry_path.read_text()
|
|
605
|
+
if "## Commits" not in content:
|
|
606
|
+
content = content.rstrip() + "\n\n## Commits\n"
|
|
607
|
+
content += commit_block
|
|
608
|
+
else:
|
|
609
|
+
# Auto-create minimal entry
|
|
610
|
+
content = f"# {today}\n\n## Commits\n{commit_block}"
|
|
611
|
+
|
|
612
|
+
entry_path.write_text(content)
|
|
613
|
+
click.echo(f"buildlog: updated {entry_path}")
|
|
614
|
+
|
|
615
|
+
|
|
616
|
+
def _resolve_entry_path(
|
|
617
|
+
buildlog_dir: Path, today: str, slug: str | None, explicit: str | None
|
|
618
|
+
) -> Path:
|
|
619
|
+
"""Find or create the entry path for today."""
|
|
620
|
+
if explicit:
|
|
621
|
+
return Path(explicit)
|
|
622
|
+
|
|
623
|
+
# Check for existing entry with today's date
|
|
624
|
+
existing = list(buildlog_dir.glob(f"{today}-*.md"))
|
|
625
|
+
if existing:
|
|
626
|
+
return existing[0]
|
|
627
|
+
|
|
628
|
+
# Derive slug from branch name if not provided
|
|
629
|
+
if slug is None:
|
|
630
|
+
try:
|
|
631
|
+
branch = subprocess.run(
|
|
632
|
+
["git", "branch", "--show-current"],
|
|
633
|
+
capture_output=True,
|
|
634
|
+
text=True,
|
|
635
|
+
check=True,
|
|
636
|
+
).stdout.strip()
|
|
637
|
+
# Clean branch name into slug
|
|
638
|
+
slug = branch.split("/")[-1] # strip prefix like feat/
|
|
639
|
+
slug = slug.lower().replace("_", "-")
|
|
640
|
+
slug = "".join(c for c in slug if c.isalnum() or c == "-")
|
|
641
|
+
except subprocess.CalledProcessError:
|
|
642
|
+
slug = "session"
|
|
643
|
+
|
|
644
|
+
if not slug:
|
|
645
|
+
slug = "session"
|
|
646
|
+
|
|
647
|
+
return buildlog_dir / f"{today}-{slug}.md"
|
|
648
|
+
|
|
649
|
+
|
|
334
650
|
@main.command("list")
|
|
335
651
|
def list_entries():
|
|
336
652
|
"""List all buildlog entries."""
|
|
@@ -431,7 +747,7 @@ def distill(
|
|
|
431
747
|
"""Extract patterns from all buildlog entries.
|
|
432
748
|
|
|
433
749
|
Parses the Improvements section of each buildlog entry and aggregates
|
|
434
|
-
insights into structured output (JSON or YAML).
|
|
750
|
+
insights into structured output (JSON or YAML). Returns empty result if not initialized.
|
|
435
751
|
|
|
436
752
|
Examples:
|
|
437
753
|
|
|
@@ -441,11 +757,25 @@ def distill(
|
|
|
441
757
|
buildlog distill --since 2026-01-01 # Filter by date
|
|
442
758
|
buildlog distill --category workflow # Filter by category
|
|
443
759
|
"""
|
|
760
|
+
import json as json_module
|
|
761
|
+
|
|
444
762
|
buildlog_dir = Path("buildlog")
|
|
445
763
|
|
|
764
|
+
# Handle uninitialized state gracefully
|
|
446
765
|
if not buildlog_dir.exists():
|
|
447
|
-
|
|
448
|
-
|
|
766
|
+
empty_result = {
|
|
767
|
+
"initialized": False,
|
|
768
|
+
"patterns": [],
|
|
769
|
+
"statistics": {"total_patterns": 0, "total_entries": 0},
|
|
770
|
+
"message": "buildlog not initialized",
|
|
771
|
+
}
|
|
772
|
+
if fmt == "json":
|
|
773
|
+
click.echo(json_module.dumps(empty_result, indent=2))
|
|
774
|
+
else:
|
|
775
|
+
click.echo(
|
|
776
|
+
"# buildlog not initialized - run 'buildlog init' first\npatterns: []"
|
|
777
|
+
)
|
|
778
|
+
return
|
|
449
779
|
|
|
450
780
|
# Convert datetime to date if provided
|
|
451
781
|
since_date = since.date() if since else None
|
|
@@ -496,6 +826,7 @@ def stats(output_json: bool, detailed: bool, since_date: str | None):
|
|
|
496
826
|
"""Show buildlog statistics and analytics.
|
|
497
827
|
|
|
498
828
|
Provides insights on buildlog usage, coverage, and quality.
|
|
829
|
+
Returns empty stats if not initialized.
|
|
499
830
|
|
|
500
831
|
Examples:
|
|
501
832
|
|
|
@@ -504,11 +835,27 @@ def stats(output_json: bool, detailed: bool, since_date: str | None):
|
|
|
504
835
|
buildlog stats --detailed # Include top sources
|
|
505
836
|
buildlog stats --since 2026-01-01
|
|
506
837
|
"""
|
|
838
|
+
import json as json_module
|
|
839
|
+
|
|
507
840
|
buildlog_dir = Path("buildlog")
|
|
508
841
|
|
|
842
|
+
# Handle uninitialized state gracefully
|
|
509
843
|
if not buildlog_dir.exists():
|
|
510
|
-
|
|
511
|
-
|
|
844
|
+
empty_stats = {
|
|
845
|
+
"initialized": False,
|
|
846
|
+
"total_entries": 0,
|
|
847
|
+
"total_patterns": 0,
|
|
848
|
+
"categories": {},
|
|
849
|
+
"date_range": None,
|
|
850
|
+
"message": "buildlog not initialized",
|
|
851
|
+
}
|
|
852
|
+
if output_json:
|
|
853
|
+
click.echo(json_module.dumps(empty_stats, indent=2))
|
|
854
|
+
else:
|
|
855
|
+
click.echo("buildlog stats")
|
|
856
|
+
click.echo("=" * 40)
|
|
857
|
+
click.echo(" Not initialized. Run 'buildlog init' first.")
|
|
858
|
+
return
|
|
512
859
|
|
|
513
860
|
# Parse since date if provided
|
|
514
861
|
parsed_since = None
|
|
@@ -571,7 +918,7 @@ def skills(
|
|
|
571
918
|
"""Generate agent-consumable skills from buildlog patterns.
|
|
572
919
|
|
|
573
920
|
Transforms distilled patterns into actionable rules with deduplication,
|
|
574
|
-
confidence scoring, and stable IDs.
|
|
921
|
+
confidence scoring, and stable IDs. Returns empty set if not initialized.
|
|
575
922
|
|
|
576
923
|
Examples:
|
|
577
924
|
|
|
@@ -588,9 +935,31 @@ def skills(
|
|
|
588
935
|
"""
|
|
589
936
|
buildlog_dir = Path("buildlog")
|
|
590
937
|
|
|
938
|
+
# Handle uninitialized state gracefully - return empty skill set
|
|
591
939
|
if not buildlog_dir.exists():
|
|
592
|
-
|
|
593
|
-
|
|
940
|
+
if fmt == "json":
|
|
941
|
+
import json as json_module
|
|
942
|
+
|
|
943
|
+
click.echo(
|
|
944
|
+
json_module.dumps(
|
|
945
|
+
{
|
|
946
|
+
"initialized": False,
|
|
947
|
+
"skills": {},
|
|
948
|
+
"total_skills": 0,
|
|
949
|
+
"message": "buildlog not initialized",
|
|
950
|
+
},
|
|
951
|
+
indent=2,
|
|
952
|
+
)
|
|
953
|
+
)
|
|
954
|
+
elif fmt == "yaml":
|
|
955
|
+
click.echo(
|
|
956
|
+
"# buildlog not initialized - run 'buildlog init' first\nskills: {}\ntotal_skills: 0"
|
|
957
|
+
)
|
|
958
|
+
else:
|
|
959
|
+
click.echo(
|
|
960
|
+
"No buildlog/ directory found. Run 'buildlog init' to extract skills."
|
|
961
|
+
)
|
|
962
|
+
return
|
|
594
963
|
|
|
595
964
|
# Convert datetime to date if provided
|
|
596
965
|
since_date = since.date() if since else None
|
|
@@ -780,7 +1149,7 @@ def status_cmd(min_confidence: str, output_json: bool):
|
|
|
780
1149
|
"""Show extracted skills by category and confidence.
|
|
781
1150
|
|
|
782
1151
|
Displays all skills extracted from buildlog entries, grouped by category,
|
|
783
|
-
with confidence levels and promotion status.
|
|
1152
|
+
with confidence levels and promotion status. Returns empty state if not initialized.
|
|
784
1153
|
|
|
785
1154
|
Examples:
|
|
786
1155
|
|
|
@@ -793,9 +1162,23 @@ def status_cmd(min_confidence: str, output_json: bool):
|
|
|
793
1162
|
|
|
794
1163
|
buildlog_dir = Path("buildlog")
|
|
795
1164
|
|
|
1165
|
+
# Handle uninitialized state gracefully
|
|
796
1166
|
if not buildlog_dir.exists():
|
|
797
|
-
|
|
798
|
-
|
|
1167
|
+
empty_result = {
|
|
1168
|
+
"initialized": False,
|
|
1169
|
+
"total_skills": 0,
|
|
1170
|
+
"total_entries": 0,
|
|
1171
|
+
"skills": {},
|
|
1172
|
+
"by_confidence": {"high": 0, "medium": 0, "low": 0},
|
|
1173
|
+
"promotable_ids": [],
|
|
1174
|
+
"error": None,
|
|
1175
|
+
}
|
|
1176
|
+
if output_json:
|
|
1177
|
+
click.echo(json_module.dumps(empty_result, indent=2))
|
|
1178
|
+
else:
|
|
1179
|
+
click.echo("Skills: 0 total from 0 entries")
|
|
1180
|
+
click.echo(" buildlog not initialized. Run 'buildlog init' first.")
|
|
1181
|
+
return
|
|
799
1182
|
|
|
800
1183
|
result = status(buildlog_dir, min_confidence=min_confidence) # type: ignore[arg-type]
|
|
801
1184
|
|
|
@@ -937,6 +1320,7 @@ def diff_cmd(output_json: bool):
|
|
|
937
1320
|
"""Show skills pending review (not yet promoted or rejected).
|
|
938
1321
|
|
|
939
1322
|
Useful for seeing what's new since the last time you reviewed skills.
|
|
1323
|
+
Returns empty diff if not initialized.
|
|
940
1324
|
|
|
941
1325
|
Examples:
|
|
942
1326
|
|
|
@@ -948,9 +1332,22 @@ def diff_cmd(output_json: bool):
|
|
|
948
1332
|
|
|
949
1333
|
buildlog_dir = Path("buildlog")
|
|
950
1334
|
|
|
1335
|
+
# Handle uninitialized state gracefully
|
|
951
1336
|
if not buildlog_dir.exists():
|
|
952
|
-
|
|
953
|
-
|
|
1337
|
+
empty_result = {
|
|
1338
|
+
"initialized": False,
|
|
1339
|
+
"pending": {},
|
|
1340
|
+
"total_pending": 0,
|
|
1341
|
+
"already_promoted": 0,
|
|
1342
|
+
"already_rejected": 0,
|
|
1343
|
+
"error": None,
|
|
1344
|
+
}
|
|
1345
|
+
if output_json:
|
|
1346
|
+
click.echo(json_module.dumps(empty_result, indent=2))
|
|
1347
|
+
else:
|
|
1348
|
+
click.echo("Pending: 0 | Promoted: 0 | Rejected: 0")
|
|
1349
|
+
click.echo(" buildlog not initialized. Run 'buildlog init' first.")
|
|
1350
|
+
return
|
|
954
1351
|
|
|
955
1352
|
result = core_diff(buildlog_dir)
|
|
956
1353
|
|
|
@@ -1277,6 +1674,7 @@ PERSONAS = {
|
|
|
1277
1674
|
"security_karen": "OWASP Top 10 security review",
|
|
1278
1675
|
"test_terrorist": "Comprehensive testing coverage audit",
|
|
1279
1676
|
"ruthless_reviewer": "Code quality and functional principles",
|
|
1677
|
+
"bragi": "Detect and flag LLM-ish prose patterns in markdown",
|
|
1280
1678
|
}
|
|
1281
1679
|
|
|
1282
1680
|
|
|
@@ -1656,6 +2054,69 @@ def gauntlet_learn(issues_file: str, source: str | None, output_json: bool):
|
|
|
1656
2054
|
click.echo(f" Total processed: {result.total_issues_processed}")
|
|
1657
2055
|
|
|
1658
2056
|
|
|
2057
|
+
@gauntlet.command("generate")
|
|
2058
|
+
@click.argument("source_text", type=click.Path(exists=True))
|
|
2059
|
+
@click.option("--persona", "-p", required=True, help="Persona name for the seed file")
|
|
2060
|
+
@click.option(
|
|
2061
|
+
"--output-dir",
|
|
2062
|
+
"-o",
|
|
2063
|
+
type=click.Path(),
|
|
2064
|
+
default=".buildlog/seeds",
|
|
2065
|
+
help="Output directory for seed YAML",
|
|
2066
|
+
)
|
|
2067
|
+
@click.option("--dry-run", is_flag=True, help="Preview without writing")
|
|
2068
|
+
def gauntlet_generate(source_text: str, persona: str, output_dir: str, dry_run: bool):
|
|
2069
|
+
"""Generate seed rules from source text using LLM extraction.
|
|
2070
|
+
|
|
2071
|
+
Runs the seed engine pipeline with LLMExtractor to produce
|
|
2072
|
+
a YAML seed file from arbitrary source content.
|
|
2073
|
+
|
|
2074
|
+
Examples:
|
|
2075
|
+
|
|
2076
|
+
buildlog gauntlet generate docs/security.md --persona security_karen
|
|
2077
|
+
buildlog gauntlet generate notes.txt -p test_terrorist --dry-run
|
|
2078
|
+
"""
|
|
2079
|
+
import json as json_module
|
|
2080
|
+
|
|
2081
|
+
from buildlog.llm import get_llm_backend
|
|
2082
|
+
from buildlog.seed_engine import Pipeline, Source, SourceType
|
|
2083
|
+
|
|
2084
|
+
backend = get_llm_backend()
|
|
2085
|
+
if backend is None:
|
|
2086
|
+
click.echo(
|
|
2087
|
+
"No LLM backend available. Install ollama or set ANTHROPIC_API_KEY.",
|
|
2088
|
+
err=True,
|
|
2089
|
+
)
|
|
2090
|
+
raise SystemExit(1)
|
|
2091
|
+
|
|
2092
|
+
content = Path(source_text).read_text()
|
|
2093
|
+
source = Source(
|
|
2094
|
+
name=Path(source_text).stem,
|
|
2095
|
+
url=f"file://{Path(source_text).resolve()}",
|
|
2096
|
+
source_type=SourceType.REFERENCE_DOC,
|
|
2097
|
+
domain=persona.split("_")[0] if "_" in persona else "general",
|
|
2098
|
+
description=content,
|
|
2099
|
+
)
|
|
2100
|
+
|
|
2101
|
+
pipeline = Pipeline.with_llm(
|
|
2102
|
+
persona=persona,
|
|
2103
|
+
backend=backend,
|
|
2104
|
+
source_content={source.url: content},
|
|
2105
|
+
)
|
|
2106
|
+
|
|
2107
|
+
if dry_run:
|
|
2108
|
+
preview = pipeline.dry_run([source])
|
|
2109
|
+
click.echo(json_module.dumps(preview, indent=2))
|
|
2110
|
+
return
|
|
2111
|
+
|
|
2112
|
+
out = Path(output_dir)
|
|
2113
|
+
out.mkdir(parents=True, exist_ok=True)
|
|
2114
|
+
result = pipeline.run([source], output_dir=out)
|
|
2115
|
+
click.echo(f"Generated {result.rule_count} rules for {persona}")
|
|
2116
|
+
if result.output_path:
|
|
2117
|
+
click.echo(f"Seed file: {result.output_path}")
|
|
2118
|
+
|
|
2119
|
+
|
|
1659
2120
|
@gauntlet.command("loop")
|
|
1660
2121
|
@click.argument("target", type=click.Path(exists=True))
|
|
1661
2122
|
@click.option(
|