buildlog 0.7.0__py3-none-any.whl → 0.9.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/__init__.py +1 -1
- buildlog/cli.py +659 -48
- buildlog/confidence.py +27 -0
- buildlog/core/__init__.py +2 -0
- buildlog/core/bandit.py +699 -0
- buildlog/core/operations.py +284 -24
- buildlog/distill.py +80 -1
- buildlog/engine/__init__.py +61 -0
- buildlog/engine/bandit.py +23 -0
- buildlog/engine/confidence.py +28 -0
- buildlog/engine/embeddings.py +28 -0
- buildlog/engine/experiments.py +619 -0
- buildlog/engine/types.py +31 -0
- buildlog/llm.py +508 -0
- buildlog/mcp/server.py +10 -6
- buildlog/mcp/tools.py +61 -13
- buildlog/render/__init__.py +19 -2
- buildlog/render/claude_md.py +67 -32
- buildlog/render/continue_dev.py +102 -0
- buildlog/render/copilot.py +100 -0
- buildlog/render/cursor.py +105 -0
- buildlog/render/windsurf.py +95 -0
- buildlog/seed_engine/__init__.py +2 -0
- buildlog/seed_engine/llm_extractor.py +121 -0
- buildlog/seed_engine/pipeline.py +45 -1
- buildlog/skills.py +69 -6
- {buildlog-0.7.0.data → buildlog-0.9.0.data}/data/share/buildlog/copier.yml +0 -4
- buildlog-0.9.0.data/data/share/buildlog/template/buildlog/_TEMPLATE_QUICK.md +21 -0
- buildlog-0.9.0.dist-info/METADATA +248 -0
- buildlog-0.9.0.dist-info/RECORD +55 -0
- buildlog-0.7.0.dist-info/METADATA +0 -544
- buildlog-0.7.0.dist-info/RECORD +0 -41
- {buildlog-0.7.0.data → buildlog-0.9.0.data}/data/share/buildlog/post_gen.py +0 -0
- {buildlog-0.7.0.data → buildlog-0.9.0.data}/data/share/buildlog/template/buildlog/.gitkeep +0 -0
- {buildlog-0.7.0.data → buildlog-0.9.0.data}/data/share/buildlog/template/buildlog/2026-01-01-example.md +0 -0
- {buildlog-0.7.0.data → buildlog-0.9.0.data}/data/share/buildlog/template/buildlog/BUILDLOG_SYSTEM.md +0 -0
- {buildlog-0.7.0.data → buildlog-0.9.0.data}/data/share/buildlog/template/buildlog/_TEMPLATE.md +0 -0
- {buildlog-0.7.0.data → buildlog-0.9.0.data}/data/share/buildlog/template/buildlog/assets/.gitkeep +0 -0
- {buildlog-0.7.0.dist-info → buildlog-0.9.0.dist-info}/WHEEL +0 -0
- {buildlog-0.7.0.dist-info → buildlog-0.9.0.dist-info}/entry_points.txt +0 -0
- {buildlog-0.7.0.dist-info → buildlog-0.9.0.dist-info}/licenses/LICENSE +0 -0
buildlog/cli.py
CHANGED
|
@@ -8,7 +8,8 @@ from pathlib import Path
|
|
|
8
8
|
|
|
9
9
|
import click
|
|
10
10
|
|
|
11
|
-
from buildlog.core import
|
|
11
|
+
from buildlog.core import diff as core_diff
|
|
12
|
+
from buildlog.core import get_rewards, log_reward, promote, reject, status
|
|
12
13
|
from buildlog.distill import CATEGORIES, distill_all, format_output
|
|
13
14
|
from buildlog.skills import format_skills, generate_skills
|
|
14
15
|
from buildlog.stats import calculate_stats, format_dashboard, format_json
|
|
@@ -49,11 +50,18 @@ def main():
|
|
|
49
50
|
|
|
50
51
|
@main.command()
|
|
51
52
|
@click.option("--no-claude-md", is_flag=True, help="Don't update CLAUDE.md")
|
|
52
|
-
|
|
53
|
+
@click.option(
|
|
54
|
+
"--defaults",
|
|
55
|
+
is_flag=True,
|
|
56
|
+
help="Use default values for all prompts (non-interactive)",
|
|
57
|
+
)
|
|
58
|
+
def init(no_claude_md: bool, defaults: bool):
|
|
53
59
|
"""Initialize buildlog in the current directory.
|
|
54
60
|
|
|
55
61
|
Sets up the buildlog/ directory with templates and optionally
|
|
56
62
|
adds instructions to CLAUDE.md.
|
|
63
|
+
|
|
64
|
+
Use --defaults for non-interactive environments (CI, scripts, agents).
|
|
57
65
|
"""
|
|
58
66
|
buildlog_dir = Path("buildlog")
|
|
59
67
|
|
|
@@ -66,46 +74,186 @@ def init(no_claude_md: bool):
|
|
|
66
74
|
if template_dir:
|
|
67
75
|
# Use local template
|
|
68
76
|
click.echo("Initializing buildlog from local template...")
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
)
|
|
83
|
-
except subprocess.CalledProcessError:
|
|
84
|
-
click.echo("Failed to initialize buildlog.", err=True)
|
|
85
|
-
raise SystemExit(1)
|
|
77
|
+
subprocess.run(
|
|
78
|
+
[
|
|
79
|
+
sys.executable,
|
|
80
|
+
"-m",
|
|
81
|
+
"copier",
|
|
82
|
+
"copy",
|
|
83
|
+
"--trust",
|
|
84
|
+
*(["--defaults"] if defaults else []),
|
|
85
|
+
*(["--data", "update_claude_md=false"] if no_claude_md else []),
|
|
86
|
+
str(template_dir),
|
|
87
|
+
".",
|
|
88
|
+
],
|
|
89
|
+
)
|
|
86
90
|
else:
|
|
87
91
|
# Fall back to GitHub
|
|
88
92
|
click.echo("Initializing buildlog from GitHub...")
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
93
|
+
subprocess.run(
|
|
94
|
+
[
|
|
95
|
+
sys.executable,
|
|
96
|
+
"-m",
|
|
97
|
+
"copier",
|
|
98
|
+
"copy",
|
|
99
|
+
"--trust",
|
|
100
|
+
*(["--defaults"] if defaults else []),
|
|
101
|
+
*(["--data", "update_claude_md=false"] if no_claude_md else []),
|
|
102
|
+
"gh:Peleke/buildlog-template",
|
|
103
|
+
".",
|
|
104
|
+
],
|
|
105
|
+
)
|
|
106
|
+
|
|
107
|
+
# Verify the buildlog directory was actually created
|
|
108
|
+
if not buildlog_dir.exists():
|
|
109
|
+
click.echo("Failed to initialize buildlog.", err=True)
|
|
110
|
+
raise SystemExit(1)
|
|
111
|
+
|
|
112
|
+
# Update CLAUDE.md if it exists and user didn't opt out
|
|
113
|
+
if not no_claude_md:
|
|
114
|
+
claude_md = Path("CLAUDE.md")
|
|
115
|
+
if claude_md.exists():
|
|
116
|
+
content = claude_md.read_text()
|
|
117
|
+
if "## Build Journal" not in content:
|
|
118
|
+
section = (
|
|
119
|
+
"\n## Build Journal\n\n"
|
|
120
|
+
"After completing significant work (features, debugging sessions, "
|
|
121
|
+
"deployments,\n"
|
|
122
|
+
"2+ hour focused sessions), write a build journal entry.\n\n"
|
|
123
|
+
"**Location:** `buildlog/YYYY-MM-DD-{slug}.md`\n"
|
|
124
|
+
"**Template:** `buildlog/_TEMPLATE.md`\n"
|
|
125
|
+
)
|
|
126
|
+
with open(claude_md, "a") as f:
|
|
127
|
+
f.write(section)
|
|
128
|
+
click.echo("Added Build Journal section to CLAUDE.md")
|
|
106
129
|
|
|
107
130
|
click.echo("\n✓ buildlog initialized!")
|
|
108
|
-
click.echo(
|
|
131
|
+
click.echo()
|
|
132
|
+
click.echo("How it works:")
|
|
133
|
+
click.echo(" 1. Write entries buildlog new my-feature (or --quick)")
|
|
134
|
+
click.echo(" 2. Extract rules buildlog skills")
|
|
135
|
+
click.echo(" 3. Promote to agent buildlog promote <id> --target cursor")
|
|
136
|
+
click.echo(" 4. Measure learning buildlog overview")
|
|
137
|
+
click.echo()
|
|
138
|
+
click.echo(
|
|
139
|
+
"Targets: claude_md, cursor, copilot, windsurf, continue_dev, settings_json, skill"
|
|
140
|
+
)
|
|
141
|
+
click.echo()
|
|
142
|
+
click.echo("Start now: buildlog new my-first-task --quick")
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
@main.command()
|
|
146
|
+
@click.option("--json", "output_json", is_flag=True, help="Output as JSON")
|
|
147
|
+
def overview(output_json: bool):
|
|
148
|
+
"""Show the full state of your buildlog at a glance.
|
|
149
|
+
|
|
150
|
+
Entries, skills, promoted rules, experiments — everything in one view.
|
|
151
|
+
|
|
152
|
+
Examples:
|
|
153
|
+
|
|
154
|
+
buildlog overview
|
|
155
|
+
buildlog overview --json
|
|
156
|
+
"""
|
|
157
|
+
import json as json_module
|
|
158
|
+
|
|
159
|
+
buildlog_dir = Path("buildlog")
|
|
160
|
+
|
|
161
|
+
if not buildlog_dir.exists():
|
|
162
|
+
click.echo("No buildlog/ directory found. Run 'buildlog init' first.", err=True)
|
|
163
|
+
raise SystemExit(1)
|
|
164
|
+
|
|
165
|
+
# Count entries
|
|
166
|
+
entries = sorted(buildlog_dir.glob("20??-??-??-*.md"))
|
|
167
|
+
|
|
168
|
+
# Try to get skills
|
|
169
|
+
try:
|
|
170
|
+
skill_set = generate_skills(buildlog_dir)
|
|
171
|
+
total_skills = skill_set.total_skills
|
|
172
|
+
by_confidence = {"high": 0, "medium": 0, "low": 0}
|
|
173
|
+
for cat_skills in skill_set.skills.values():
|
|
174
|
+
for s in cat_skills:
|
|
175
|
+
by_confidence[s.confidence] += 1
|
|
176
|
+
except Exception:
|
|
177
|
+
total_skills = 0
|
|
178
|
+
by_confidence = {"high": 0, "medium": 0, "low": 0}
|
|
179
|
+
|
|
180
|
+
# Promoted/rejected counts
|
|
181
|
+
promoted_path = buildlog_dir / ".buildlog" / "promoted.json"
|
|
182
|
+
rejected_path = buildlog_dir / ".buildlog" / "rejected.json"
|
|
183
|
+
promoted_count = 0
|
|
184
|
+
rejected_count = 0
|
|
185
|
+
if promoted_path.exists():
|
|
186
|
+
try:
|
|
187
|
+
data = json_module.loads(promoted_path.read_text())
|
|
188
|
+
promoted_count = len(data.get("skill_ids", []))
|
|
189
|
+
except (json_module.JSONDecodeError, OSError):
|
|
190
|
+
pass
|
|
191
|
+
if rejected_path.exists():
|
|
192
|
+
try:
|
|
193
|
+
data = json_module.loads(rejected_path.read_text())
|
|
194
|
+
rejected_count = len(data.get("skill_ids", []))
|
|
195
|
+
except (json_module.JSONDecodeError, OSError):
|
|
196
|
+
pass
|
|
197
|
+
|
|
198
|
+
# Active session?
|
|
199
|
+
active_session_path = buildlog_dir / ".buildlog" / "active_session.json"
|
|
200
|
+
active_session = None
|
|
201
|
+
if active_session_path.exists():
|
|
202
|
+
try:
|
|
203
|
+
active_session = json_module.loads(active_session_path.read_text())
|
|
204
|
+
except (json_module.JSONDecodeError, OSError):
|
|
205
|
+
pass
|
|
206
|
+
|
|
207
|
+
# Render targets with files
|
|
208
|
+
from buildlog.render import RENDERERS
|
|
209
|
+
|
|
210
|
+
result = {
|
|
211
|
+
"entries": len(entries),
|
|
212
|
+
"skills": {
|
|
213
|
+
"total": total_skills,
|
|
214
|
+
"by_confidence": by_confidence,
|
|
215
|
+
"promoted": promoted_count,
|
|
216
|
+
"rejected": rejected_count,
|
|
217
|
+
"pending": total_skills - promoted_count - rejected_count,
|
|
218
|
+
},
|
|
219
|
+
"active_session": active_session.get("id") if active_session else None,
|
|
220
|
+
"render_targets": list(RENDERERS.keys()),
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
if output_json:
|
|
224
|
+
click.echo(json_module.dumps(result, indent=2))
|
|
225
|
+
else:
|
|
226
|
+
click.echo("buildlog overview")
|
|
227
|
+
click.echo("=" * 40)
|
|
228
|
+
click.echo(f" Entries: {len(entries)}")
|
|
229
|
+
click.echo(f" Skills: {total_skills}")
|
|
230
|
+
if total_skills > 0:
|
|
231
|
+
conf_parts = [f"{k}={v}" for k, v in by_confidence.items() if v > 0]
|
|
232
|
+
click.echo(f" confidence: {', '.join(conf_parts)}")
|
|
233
|
+
click.echo(f" Promoted: {promoted_count}")
|
|
234
|
+
click.echo(f" Rejected: {rejected_count}")
|
|
235
|
+
pending = total_skills - promoted_count - rejected_count
|
|
236
|
+
if pending > 0:
|
|
237
|
+
click.echo(f" Pending: {pending}")
|
|
238
|
+
if active_session:
|
|
239
|
+
click.echo(f" Session: {active_session.get('id', '?')} (active)")
|
|
240
|
+
click.echo()
|
|
241
|
+
|
|
242
|
+
if len(entries) == 0:
|
|
243
|
+
click.echo("Get started:")
|
|
244
|
+
click.echo(" buildlog new my-first-task # Full template")
|
|
245
|
+
click.echo(" buildlog new my-first-task --quick # Short template")
|
|
246
|
+
elif total_skills == 0:
|
|
247
|
+
click.echo("Next steps:")
|
|
248
|
+
click.echo(
|
|
249
|
+
" buildlog skills # Extract rules from entries"
|
|
250
|
+
)
|
|
251
|
+
elif promoted_count == 0:
|
|
252
|
+
click.echo("Next steps:")
|
|
253
|
+
click.echo(" buildlog status # See extracted skills")
|
|
254
|
+
click.echo(" buildlog promote <id> --target cursor # Push to your agent")
|
|
255
|
+
else:
|
|
256
|
+
click.echo("Targets: " + ", ".join(RENDERERS.keys()))
|
|
109
257
|
|
|
110
258
|
|
|
111
259
|
@main.command()
|
|
@@ -113,7 +261,12 @@ def init(no_claude_md: bool):
|
|
|
113
261
|
@click.option(
|
|
114
262
|
"--date", "-d", "entry_date", default=None, help="Date for entry (YYYY-MM-DD)"
|
|
115
263
|
)
|
|
116
|
-
|
|
264
|
+
@click.option(
|
|
265
|
+
"--quick",
|
|
266
|
+
is_flag=True,
|
|
267
|
+
help="Use the short template (good for small tasks)",
|
|
268
|
+
)
|
|
269
|
+
def new(slug: str, entry_date: str | None, quick: bool):
|
|
117
270
|
"""Create a new buildlog entry.
|
|
118
271
|
|
|
119
272
|
SLUG is a short identifier for the entry (e.g., 'auth-api', 'bugfix-login').
|
|
@@ -121,10 +274,16 @@ def new(slug: str, entry_date: str | None):
|
|
|
121
274
|
Examples:
|
|
122
275
|
|
|
123
276
|
buildlog new auth-api
|
|
277
|
+
buildlog new bugfix-login --quick
|
|
124
278
|
buildlog new runpod-deploy --date 2026-01-15
|
|
125
279
|
"""
|
|
126
280
|
buildlog_dir = Path("buildlog")
|
|
127
|
-
|
|
281
|
+
template_name = "_TEMPLATE_QUICK.md" if quick else "_TEMPLATE.md"
|
|
282
|
+
template_file = buildlog_dir / template_name
|
|
283
|
+
|
|
284
|
+
# Fall back to full template if quick template doesn't exist
|
|
285
|
+
if quick and not template_file.exists():
|
|
286
|
+
template_file = buildlog_dir / "_TEMPLATE.md"
|
|
128
287
|
|
|
129
288
|
if not buildlog_dir.exists():
|
|
130
289
|
click.echo("No buildlog/ directory found. Run 'buildlog init' first.", err=True)
|
|
@@ -139,11 +298,11 @@ def new(slug: str, entry_date: str | None):
|
|
|
139
298
|
# Determine date
|
|
140
299
|
if entry_date:
|
|
141
300
|
try:
|
|
142
|
-
# Validate date format
|
|
143
|
-
|
|
144
|
-
date_str =
|
|
301
|
+
# Validate date format AND range (month 1-12, day 1-31)
|
|
302
|
+
parsed = datetime.strptime(entry_date, "%Y-%m-%d").date()
|
|
303
|
+
date_str = parsed.isoformat()
|
|
145
304
|
except ValueError:
|
|
146
|
-
click.echo("Invalid date
|
|
305
|
+
click.echo("Invalid date. Use YYYY-MM-DD with valid values.", err=True)
|
|
147
306
|
raise SystemExit(1)
|
|
148
307
|
else:
|
|
149
308
|
date_str = date.today().isoformat()
|
|
@@ -172,6 +331,161 @@ def new(slug: str, entry_date: str | None):
|
|
|
172
331
|
click.echo(f"\nOpen it: $EDITOR {entry_path}")
|
|
173
332
|
|
|
174
333
|
|
|
334
|
+
@main.command(
|
|
335
|
+
context_settings={"ignore_unknown_options": True, "allow_extra_args": True}
|
|
336
|
+
)
|
|
337
|
+
@click.option(
|
|
338
|
+
"--slug",
|
|
339
|
+
"-s",
|
|
340
|
+
default=None,
|
|
341
|
+
help="Entry slug (default: derived from branch name)",
|
|
342
|
+
)
|
|
343
|
+
@click.option(
|
|
344
|
+
"--entry",
|
|
345
|
+
"-e",
|
|
346
|
+
type=click.Path(),
|
|
347
|
+
default=None,
|
|
348
|
+
help="Explicit entry file to append to",
|
|
349
|
+
)
|
|
350
|
+
@click.option(
|
|
351
|
+
"--no-entry",
|
|
352
|
+
is_flag=True,
|
|
353
|
+
help="Skip buildlog entry update (just run git commit)",
|
|
354
|
+
)
|
|
355
|
+
@click.pass_context
|
|
356
|
+
def commit(ctx, slug: str | None, entry: str | None, no_entry: bool):
|
|
357
|
+
"""Commit code and update the buildlog entry in one step.
|
|
358
|
+
|
|
359
|
+
Wraps `git commit` and appends commit context to today's buildlog
|
|
360
|
+
entry. If no entry exists for today, creates one automatically.
|
|
361
|
+
|
|
362
|
+
All unknown options/arguments are passed through to git commit.
|
|
363
|
+
|
|
364
|
+
Examples:
|
|
365
|
+
|
|
366
|
+
buildlog commit -m "feat: add LLM extractor"
|
|
367
|
+
buildlog commit --slug llm-extractor -m "feat: add LLM extractor"
|
|
368
|
+
buildlog commit --no-entry -m "chore: formatting"
|
|
369
|
+
"""
|
|
370
|
+
buildlog_dir = Path("buildlog")
|
|
371
|
+
|
|
372
|
+
# Build git commit command — extra args are passed through from Click context
|
|
373
|
+
git_cmd = ["git", "commit", *ctx.args]
|
|
374
|
+
|
|
375
|
+
# Run git commit first
|
|
376
|
+
result = subprocess.run(git_cmd, capture_output=True, text=True)
|
|
377
|
+
sys.stdout.write(result.stdout)
|
|
378
|
+
sys.stderr.write(result.stderr)
|
|
379
|
+
|
|
380
|
+
if result.returncode != 0:
|
|
381
|
+
raise SystemExit(result.returncode)
|
|
382
|
+
|
|
383
|
+
if no_entry or not buildlog_dir.exists():
|
|
384
|
+
return
|
|
385
|
+
|
|
386
|
+
# Get commit info from what we just committed
|
|
387
|
+
try:
|
|
388
|
+
commit_hash = subprocess.run(
|
|
389
|
+
["git", "rev-parse", "--short", "HEAD"],
|
|
390
|
+
capture_output=True,
|
|
391
|
+
text=True,
|
|
392
|
+
check=True,
|
|
393
|
+
).stdout.strip()
|
|
394
|
+
commit_msg = subprocess.run(
|
|
395
|
+
["git", "log", "-1", "--format=%s"],
|
|
396
|
+
capture_output=True,
|
|
397
|
+
text=True,
|
|
398
|
+
check=True,
|
|
399
|
+
).stdout.strip()
|
|
400
|
+
# diff-tree needs special handling for root commit
|
|
401
|
+
diff_result = subprocess.run(
|
|
402
|
+
["git", "diff-tree", "--no-commit-id", "--name-only", "-r", "HEAD"],
|
|
403
|
+
capture_output=True,
|
|
404
|
+
text=True,
|
|
405
|
+
)
|
|
406
|
+
if diff_result.returncode == 0 and diff_result.stdout.strip():
|
|
407
|
+
files_changed = diff_result.stdout.strip()
|
|
408
|
+
else:
|
|
409
|
+
# Root commit fallback
|
|
410
|
+
files_changed = subprocess.run(
|
|
411
|
+
["git", "diff", "--name-only", "--cached", "HEAD~1"],
|
|
412
|
+
capture_output=True,
|
|
413
|
+
text=True,
|
|
414
|
+
).stdout.strip()
|
|
415
|
+
if not files_changed:
|
|
416
|
+
# Truly initial commit — list all tracked files
|
|
417
|
+
files_changed = subprocess.run(
|
|
418
|
+
["git", "ls-tree", "--name-only", "-r", "HEAD"],
|
|
419
|
+
capture_output=True,
|
|
420
|
+
text=True,
|
|
421
|
+
).stdout.strip()
|
|
422
|
+
except subprocess.CalledProcessError:
|
|
423
|
+
click.echo("Warning: could not read commit info", err=True)
|
|
424
|
+
return
|
|
425
|
+
|
|
426
|
+
# Resolve entry file
|
|
427
|
+
today = date.today().isoformat()
|
|
428
|
+
entry_path = _resolve_entry_path(buildlog_dir, today, slug, entry)
|
|
429
|
+
|
|
430
|
+
# Append commit block
|
|
431
|
+
commit_block = f"\n### `{commit_hash}` — {commit_msg}\n\n"
|
|
432
|
+
if files_changed:
|
|
433
|
+
file_list = files_changed.split("\n")
|
|
434
|
+
commit_block += "Files:\n"
|
|
435
|
+
for f in file_list[:20]: # cap at 20 to avoid noise
|
|
436
|
+
commit_block += f"- `{f}`\n"
|
|
437
|
+
if len(file_list) > 20:
|
|
438
|
+
commit_block += f"- ...and {len(file_list) - 20} more\n"
|
|
439
|
+
commit_block += "\n"
|
|
440
|
+
|
|
441
|
+
# Ensure commits section exists, append to it
|
|
442
|
+
if entry_path.exists():
|
|
443
|
+
content = entry_path.read_text()
|
|
444
|
+
if "## Commits" not in content:
|
|
445
|
+
content = content.rstrip() + "\n\n## Commits\n"
|
|
446
|
+
content += commit_block
|
|
447
|
+
else:
|
|
448
|
+
# Auto-create minimal entry
|
|
449
|
+
content = f"# {today}\n\n## Commits\n{commit_block}"
|
|
450
|
+
|
|
451
|
+
entry_path.write_text(content)
|
|
452
|
+
click.echo(f"buildlog: updated {entry_path}")
|
|
453
|
+
|
|
454
|
+
|
|
455
|
+
def _resolve_entry_path(
|
|
456
|
+
buildlog_dir: Path, today: str, slug: str | None, explicit: str | None
|
|
457
|
+
) -> Path:
|
|
458
|
+
"""Find or create the entry path for today."""
|
|
459
|
+
if explicit:
|
|
460
|
+
return Path(explicit)
|
|
461
|
+
|
|
462
|
+
# Check for existing entry with today's date
|
|
463
|
+
existing = list(buildlog_dir.glob(f"{today}-*.md"))
|
|
464
|
+
if existing:
|
|
465
|
+
return existing[0]
|
|
466
|
+
|
|
467
|
+
# Derive slug from branch name if not provided
|
|
468
|
+
if slug is None:
|
|
469
|
+
try:
|
|
470
|
+
branch = subprocess.run(
|
|
471
|
+
["git", "branch", "--show-current"],
|
|
472
|
+
capture_output=True,
|
|
473
|
+
text=True,
|
|
474
|
+
check=True,
|
|
475
|
+
).stdout.strip()
|
|
476
|
+
# Clean branch name into slug
|
|
477
|
+
slug = branch.split("/")[-1] # strip prefix like feat/
|
|
478
|
+
slug = slug.lower().replace("_", "-")
|
|
479
|
+
slug = "".join(c for c in slug if c.isalnum() or c == "-")
|
|
480
|
+
except subprocess.CalledProcessError:
|
|
481
|
+
slug = "session"
|
|
482
|
+
|
|
483
|
+
if not slug:
|
|
484
|
+
slug = "session"
|
|
485
|
+
|
|
486
|
+
return buildlog_dir / f"{today}-{slug}.md"
|
|
487
|
+
|
|
488
|
+
|
|
175
489
|
@main.command("list")
|
|
176
490
|
def list_entries():
|
|
177
491
|
"""List all buildlog entries."""
|
|
@@ -257,7 +571,18 @@ def update():
|
|
|
257
571
|
type=click.Choice(CATEGORIES),
|
|
258
572
|
help="Filter to a specific category",
|
|
259
573
|
)
|
|
260
|
-
|
|
574
|
+
@click.option(
|
|
575
|
+
"--llm",
|
|
576
|
+
is_flag=True,
|
|
577
|
+
help="Use LLM-backed extraction (Ollama/Anthropic, falls back to regex)",
|
|
578
|
+
)
|
|
579
|
+
def distill(
|
|
580
|
+
output: str | None,
|
|
581
|
+
fmt: str,
|
|
582
|
+
since: datetime | None,
|
|
583
|
+
category: str | None,
|
|
584
|
+
llm: bool,
|
|
585
|
+
):
|
|
261
586
|
"""Extract patterns from all buildlog entries.
|
|
262
587
|
|
|
263
588
|
Parses the Improvements section of each buildlog entry and aggregates
|
|
@@ -282,7 +607,9 @@ def distill(output: str | None, fmt: str, since: datetime | None, category: str
|
|
|
282
607
|
|
|
283
608
|
# Run distillation
|
|
284
609
|
try:
|
|
285
|
-
result = distill_all(
|
|
610
|
+
result = distill_all(
|
|
611
|
+
buildlog_dir, since=since_date, category_filter=category, llm=llm
|
|
612
|
+
)
|
|
286
613
|
except Exception as e:
|
|
287
614
|
click.echo(f"Failed to distill entries: {e}", err=True)
|
|
288
615
|
raise SystemExit(1)
|
|
@@ -383,12 +710,18 @@ def stats(output_json: bool, detailed: bool, since_date: str | None):
|
|
|
383
710
|
default=None,
|
|
384
711
|
help="Embedding backend for semantic deduplication",
|
|
385
712
|
)
|
|
713
|
+
@click.option(
|
|
714
|
+
"--llm",
|
|
715
|
+
is_flag=True,
|
|
716
|
+
help="Use LLM for extraction, canonical selection, and scoring (Ollama/Anthropic)",
|
|
717
|
+
)
|
|
386
718
|
def skills(
|
|
387
719
|
output: str | None,
|
|
388
720
|
fmt: str,
|
|
389
721
|
min_frequency: int,
|
|
390
722
|
since: datetime | None,
|
|
391
723
|
embeddings: str | None,
|
|
724
|
+
llm: bool = False,
|
|
392
725
|
):
|
|
393
726
|
"""Generate agent-consumable skills from buildlog patterns.
|
|
394
727
|
|
|
@@ -424,6 +757,7 @@ def skills(
|
|
|
424
757
|
min_frequency=min_frequency,
|
|
425
758
|
since_date=since_date,
|
|
426
759
|
embedding_backend=embeddings,
|
|
760
|
+
llm=llm,
|
|
427
761
|
)
|
|
428
762
|
except ImportError as e:
|
|
429
763
|
click.echo(f"Missing dependency: {e}", err=True)
|
|
@@ -584,6 +918,219 @@ def rewards(limit: int | None, output_json: bool):
|
|
|
584
918
|
click.echo("Log your first with: buildlog reward accepted")
|
|
585
919
|
|
|
586
920
|
|
|
921
|
+
# -----------------------------------------------------------------------------
|
|
922
|
+
# Skill Management Commands (status, promote, reject, diff)
|
|
923
|
+
# -----------------------------------------------------------------------------
|
|
924
|
+
|
|
925
|
+
|
|
926
|
+
@main.command()
|
|
927
|
+
@click.option(
|
|
928
|
+
"--min-confidence",
|
|
929
|
+
type=click.Choice(["low", "medium", "high"]),
|
|
930
|
+
default="low",
|
|
931
|
+
help="Minimum confidence level to include",
|
|
932
|
+
)
|
|
933
|
+
@click.option("--json", "output_json", is_flag=True, help="Output as JSON")
|
|
934
|
+
def status_cmd(min_confidence: str, output_json: bool):
|
|
935
|
+
"""Show extracted skills by category and confidence.
|
|
936
|
+
|
|
937
|
+
Displays all skills extracted from buildlog entries, grouped by category,
|
|
938
|
+
with confidence levels and promotion status.
|
|
939
|
+
|
|
940
|
+
Examples:
|
|
941
|
+
|
|
942
|
+
buildlog status
|
|
943
|
+
buildlog status --min-confidence medium
|
|
944
|
+
buildlog status --json
|
|
945
|
+
"""
|
|
946
|
+
import json as json_module
|
|
947
|
+
from dataclasses import asdict
|
|
948
|
+
|
|
949
|
+
buildlog_dir = Path("buildlog")
|
|
950
|
+
|
|
951
|
+
if not buildlog_dir.exists():
|
|
952
|
+
click.echo("No buildlog/ directory found. Run 'buildlog init' first.", err=True)
|
|
953
|
+
raise SystemExit(1)
|
|
954
|
+
|
|
955
|
+
result = status(buildlog_dir, min_confidence=min_confidence) # type: ignore[arg-type]
|
|
956
|
+
|
|
957
|
+
if result.error:
|
|
958
|
+
click.echo(f"Error: {result.error}", err=True)
|
|
959
|
+
raise SystemExit(1)
|
|
960
|
+
|
|
961
|
+
if output_json:
|
|
962
|
+
click.echo(json_module.dumps(asdict(result), indent=2))
|
|
963
|
+
else:
|
|
964
|
+
click.echo(
|
|
965
|
+
f"Skills: {result.total_skills} total from {result.total_entries} entries"
|
|
966
|
+
)
|
|
967
|
+
conf_str = ", ".join(
|
|
968
|
+
f"{k}={v}" for k, v in result.by_confidence.items() if v > 0
|
|
969
|
+
)
|
|
970
|
+
click.echo(f" By confidence: {conf_str}")
|
|
971
|
+
click.echo()
|
|
972
|
+
for category, skills in result.skills.items():
|
|
973
|
+
if not skills:
|
|
974
|
+
continue
|
|
975
|
+
click.echo(f" {category} ({len(skills)})")
|
|
976
|
+
for s in skills:
|
|
977
|
+
conf = s.get("confidence", "?")
|
|
978
|
+
click.echo(f" [{conf}] {s['id']}: {s['rule'][:70]}")
|
|
979
|
+
if result.promotable_ids:
|
|
980
|
+
click.echo(f"\nPromotable: {', '.join(result.promotable_ids)}")
|
|
981
|
+
|
|
982
|
+
|
|
983
|
+
# Register with the name "status" (avoiding collision with Python builtin)
|
|
984
|
+
status_cmd.name = "status"
|
|
985
|
+
|
|
986
|
+
|
|
987
|
+
@main.command()
|
|
988
|
+
@click.argument("skill_ids", nargs=-1, required=True)
|
|
989
|
+
@click.option(
|
|
990
|
+
"--target",
|
|
991
|
+
type=click.Choice(
|
|
992
|
+
[
|
|
993
|
+
"claude_md",
|
|
994
|
+
"settings_json",
|
|
995
|
+
"skill",
|
|
996
|
+
"cursor",
|
|
997
|
+
"copilot",
|
|
998
|
+
"windsurf",
|
|
999
|
+
"continue_dev",
|
|
1000
|
+
]
|
|
1001
|
+
),
|
|
1002
|
+
default="claude_md",
|
|
1003
|
+
help="Where to write promoted rules",
|
|
1004
|
+
)
|
|
1005
|
+
@click.option(
|
|
1006
|
+
"--target-path", type=click.Path(), help="Custom path for the target file"
|
|
1007
|
+
)
|
|
1008
|
+
@click.option("--json", "output_json", is_flag=True, help="Output as JSON")
|
|
1009
|
+
def promote_cmd(
|
|
1010
|
+
skill_ids: tuple[str, ...], target: str, target_path: str | None, output_json: bool
|
|
1011
|
+
):
|
|
1012
|
+
"""Promote skills to agent rules.
|
|
1013
|
+
|
|
1014
|
+
Surface high-confidence skills to your agent via CLAUDE.md, settings.json,
|
|
1015
|
+
or Agent Skills.
|
|
1016
|
+
|
|
1017
|
+
Examples:
|
|
1018
|
+
|
|
1019
|
+
buildlog promote arch-b0fcb62a1e
|
|
1020
|
+
buildlog promote arch-123 wf-456 --target skill
|
|
1021
|
+
buildlog promote arch-123 --target settings_json --target-path .claude/settings.json
|
|
1022
|
+
"""
|
|
1023
|
+
import json as json_module
|
|
1024
|
+
from dataclasses import asdict
|
|
1025
|
+
|
|
1026
|
+
buildlog_dir = Path("buildlog")
|
|
1027
|
+
|
|
1028
|
+
if not buildlog_dir.exists():
|
|
1029
|
+
click.echo("No buildlog/ directory found. Run 'buildlog init' first.", err=True)
|
|
1030
|
+
raise SystemExit(1)
|
|
1031
|
+
|
|
1032
|
+
result = promote(
|
|
1033
|
+
buildlog_dir,
|
|
1034
|
+
skill_ids=list(skill_ids),
|
|
1035
|
+
target=target, # type: ignore[arg-type]
|
|
1036
|
+
target_path=Path(target_path) if target_path else None,
|
|
1037
|
+
)
|
|
1038
|
+
|
|
1039
|
+
if result.error:
|
|
1040
|
+
click.echo(f"Error: {result.error}", err=True)
|
|
1041
|
+
raise SystemExit(1)
|
|
1042
|
+
|
|
1043
|
+
if output_json:
|
|
1044
|
+
click.echo(json_module.dumps(asdict(result), indent=2))
|
|
1045
|
+
else:
|
|
1046
|
+
click.echo(f"✓ {result.message}")
|
|
1047
|
+
if result.not_found_ids:
|
|
1048
|
+
click.echo(f" Not found: {', '.join(result.not_found_ids)}")
|
|
1049
|
+
|
|
1050
|
+
|
|
1051
|
+
promote_cmd.name = "promote"
|
|
1052
|
+
|
|
1053
|
+
|
|
1054
|
+
@main.command("reject")
|
|
1055
|
+
@click.argument("skill_ids", nargs=-1, required=True)
|
|
1056
|
+
@click.option("--json", "output_json", is_flag=True, help="Output as JSON")
|
|
1057
|
+
def reject_cmd(skill_ids: tuple[str, ...], output_json: bool):
|
|
1058
|
+
"""Mark skills as rejected (false positives).
|
|
1059
|
+
|
|
1060
|
+
Rejected skills won't be suggested for promotion again.
|
|
1061
|
+
|
|
1062
|
+
Examples:
|
|
1063
|
+
|
|
1064
|
+
buildlog reject arch-b0fcb62a1e
|
|
1065
|
+
buildlog reject dk-123 wf-456
|
|
1066
|
+
"""
|
|
1067
|
+
import json as json_module
|
|
1068
|
+
from dataclasses import asdict
|
|
1069
|
+
|
|
1070
|
+
buildlog_dir = Path("buildlog")
|
|
1071
|
+
|
|
1072
|
+
if not buildlog_dir.exists():
|
|
1073
|
+
click.echo("No buildlog/ directory found. Run 'buildlog init' first.", err=True)
|
|
1074
|
+
raise SystemExit(1)
|
|
1075
|
+
|
|
1076
|
+
result = reject(buildlog_dir, skill_ids=list(skill_ids))
|
|
1077
|
+
|
|
1078
|
+
if result.error:
|
|
1079
|
+
click.echo(f"Error: {result.error}", err=True)
|
|
1080
|
+
raise SystemExit(1)
|
|
1081
|
+
|
|
1082
|
+
if output_json:
|
|
1083
|
+
click.echo(json_module.dumps(asdict(result), indent=2))
|
|
1084
|
+
else:
|
|
1085
|
+
click.echo(f"✓ Rejected {len(result.rejected_ids)} skills")
|
|
1086
|
+
click.echo(f" Total rejected: {result.total_rejected}")
|
|
1087
|
+
|
|
1088
|
+
|
|
1089
|
+
@main.command("diff")
|
|
1090
|
+
@click.option("--json", "output_json", is_flag=True, help="Output as JSON")
|
|
1091
|
+
def diff_cmd(output_json: bool):
|
|
1092
|
+
"""Show skills pending review (not yet promoted or rejected).
|
|
1093
|
+
|
|
1094
|
+
Useful for seeing what's new since the last time you reviewed skills.
|
|
1095
|
+
|
|
1096
|
+
Examples:
|
|
1097
|
+
|
|
1098
|
+
buildlog diff
|
|
1099
|
+
buildlog diff --json
|
|
1100
|
+
"""
|
|
1101
|
+
import json as json_module
|
|
1102
|
+
from dataclasses import asdict
|
|
1103
|
+
|
|
1104
|
+
buildlog_dir = Path("buildlog")
|
|
1105
|
+
|
|
1106
|
+
if not buildlog_dir.exists():
|
|
1107
|
+
click.echo("No buildlog/ directory found. Run 'buildlog init' first.", err=True)
|
|
1108
|
+
raise SystemExit(1)
|
|
1109
|
+
|
|
1110
|
+
result = core_diff(buildlog_dir)
|
|
1111
|
+
|
|
1112
|
+
if result.error:
|
|
1113
|
+
click.echo(f"Error: {result.error}", err=True)
|
|
1114
|
+
raise SystemExit(1)
|
|
1115
|
+
|
|
1116
|
+
if output_json:
|
|
1117
|
+
click.echo(json_module.dumps(asdict(result), indent=2))
|
|
1118
|
+
else:
|
|
1119
|
+
click.echo(
|
|
1120
|
+
f"Pending: {result.total_pending} | "
|
|
1121
|
+
f"Promoted: {result.already_promoted} | "
|
|
1122
|
+
f"Rejected: {result.already_rejected}"
|
|
1123
|
+
)
|
|
1124
|
+
click.echo()
|
|
1125
|
+
for category, skills in result.pending.items():
|
|
1126
|
+
if not skills:
|
|
1127
|
+
continue
|
|
1128
|
+
click.echo(f" {category} ({len(skills)})")
|
|
1129
|
+
for s in skills:
|
|
1130
|
+
conf = s.get("confidence", "?")
|
|
1131
|
+
click.echo(f" [{conf}] {s['id']}: {s['rule'][:70]}")
|
|
1132
|
+
|
|
1133
|
+
|
|
587
1134
|
# -----------------------------------------------------------------------------
|
|
588
1135
|
# Experiment Commands (Session Tracking for Issue #21)
|
|
589
1136
|
# -----------------------------------------------------------------------------
|
|
@@ -708,7 +1255,7 @@ def experiment_end(
|
|
|
708
1255
|
|
|
709
1256
|
@experiment.command("log-mistake")
|
|
710
1257
|
@click.option(
|
|
711
|
-
"--class",
|
|
1258
|
+
"--error-class",
|
|
712
1259
|
"error_class",
|
|
713
1260
|
required=True,
|
|
714
1261
|
help="Error class (e.g., 'missing_test', 'validation_boundary')",
|
|
@@ -739,8 +1286,8 @@ def experiment_log_mistake(
|
|
|
739
1286
|
|
|
740
1287
|
Examples:
|
|
741
1288
|
|
|
742
|
-
buildlog experiment log-mistake --class missing_test -d "Forgot tests"
|
|
743
|
-
buildlog experiment log-mistake --class validation -d "No max length" -r val-123
|
|
1289
|
+
buildlog experiment log-mistake --error-class missing_test -d "Forgot tests"
|
|
1290
|
+
buildlog experiment log-mistake --error-class validation -d "No max length" -r val-123
|
|
744
1291
|
"""
|
|
745
1292
|
import json as json_module
|
|
746
1293
|
from dataclasses import asdict
|
|
@@ -885,6 +1432,7 @@ PERSONAS = {
|
|
|
885
1432
|
"security_karen": "OWASP Top 10 security review",
|
|
886
1433
|
"test_terrorist": "Comprehensive testing coverage audit",
|
|
887
1434
|
"ruthless_reviewer": "Code quality and functional principles",
|
|
1435
|
+
"bragi": "Detect and flag LLM-ish prose patterns in markdown",
|
|
888
1436
|
}
|
|
889
1437
|
|
|
890
1438
|
|
|
@@ -1264,6 +1812,69 @@ def gauntlet_learn(issues_file: str, source: str | None, output_json: bool):
|
|
|
1264
1812
|
click.echo(f" Total processed: {result.total_issues_processed}")
|
|
1265
1813
|
|
|
1266
1814
|
|
|
1815
|
+
@gauntlet.command("generate")
|
|
1816
|
+
@click.argument("source_text", type=click.Path(exists=True))
|
|
1817
|
+
@click.option("--persona", "-p", required=True, help="Persona name for the seed file")
|
|
1818
|
+
@click.option(
|
|
1819
|
+
"--output-dir",
|
|
1820
|
+
"-o",
|
|
1821
|
+
type=click.Path(),
|
|
1822
|
+
default=".buildlog/seeds",
|
|
1823
|
+
help="Output directory for seed YAML",
|
|
1824
|
+
)
|
|
1825
|
+
@click.option("--dry-run", is_flag=True, help="Preview without writing")
|
|
1826
|
+
def gauntlet_generate(source_text: str, persona: str, output_dir: str, dry_run: bool):
|
|
1827
|
+
"""Generate seed rules from source text using LLM extraction.
|
|
1828
|
+
|
|
1829
|
+
Runs the seed engine pipeline with LLMExtractor to produce
|
|
1830
|
+
a YAML seed file from arbitrary source content.
|
|
1831
|
+
|
|
1832
|
+
Examples:
|
|
1833
|
+
|
|
1834
|
+
buildlog gauntlet generate docs/security.md --persona security_karen
|
|
1835
|
+
buildlog gauntlet generate notes.txt -p test_terrorist --dry-run
|
|
1836
|
+
"""
|
|
1837
|
+
import json as json_module
|
|
1838
|
+
|
|
1839
|
+
from buildlog.llm import get_llm_backend
|
|
1840
|
+
from buildlog.seed_engine import Pipeline, Source, SourceType
|
|
1841
|
+
|
|
1842
|
+
backend = get_llm_backend()
|
|
1843
|
+
if backend is None:
|
|
1844
|
+
click.echo(
|
|
1845
|
+
"No LLM backend available. Install ollama or set ANTHROPIC_API_KEY.",
|
|
1846
|
+
err=True,
|
|
1847
|
+
)
|
|
1848
|
+
raise SystemExit(1)
|
|
1849
|
+
|
|
1850
|
+
content = Path(source_text).read_text()
|
|
1851
|
+
source = Source(
|
|
1852
|
+
name=Path(source_text).stem,
|
|
1853
|
+
url=f"file://{Path(source_text).resolve()}",
|
|
1854
|
+
source_type=SourceType.REFERENCE_DOC,
|
|
1855
|
+
domain=persona.split("_")[0] if "_" in persona else "general",
|
|
1856
|
+
description=content,
|
|
1857
|
+
)
|
|
1858
|
+
|
|
1859
|
+
pipeline = Pipeline.with_llm(
|
|
1860
|
+
persona=persona,
|
|
1861
|
+
backend=backend,
|
|
1862
|
+
source_content={source.url: content},
|
|
1863
|
+
)
|
|
1864
|
+
|
|
1865
|
+
if dry_run:
|
|
1866
|
+
preview = pipeline.dry_run([source])
|
|
1867
|
+
click.echo(json_module.dumps(preview, indent=2))
|
|
1868
|
+
return
|
|
1869
|
+
|
|
1870
|
+
out = Path(output_dir)
|
|
1871
|
+
out.mkdir(parents=True, exist_ok=True)
|
|
1872
|
+
result = pipeline.run([source], output_dir=out)
|
|
1873
|
+
click.echo(f"Generated {result.rule_count} rules for {persona}")
|
|
1874
|
+
if result.output_path:
|
|
1875
|
+
click.echo(f"Seed file: {result.output_path}")
|
|
1876
|
+
|
|
1877
|
+
|
|
1267
1878
|
@gauntlet.command("loop")
|
|
1268
1879
|
@click.argument("target", type=click.Path(exists=True))
|
|
1269
1880
|
@click.option(
|