rulewright 0.1.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.
Files changed (50) hide show
  1. rulewright/__init__.py +1 -0
  2. rulewright/cli.py +490 -0
  3. rulewright/cluster/__init__.py +0 -0
  4. rulewright/cluster/embed.py +66 -0
  5. rulewright/cluster/group.py +93 -0
  6. rulewright/config.py +19 -0
  7. rulewright/guard/__init__.py +0 -0
  8. rulewright/guard/emit.py +75 -0
  9. rulewright/guard/profile.py +172 -0
  10. rulewright/ingest/__init__.py +0 -0
  11. rulewright/ingest/git_history.py +185 -0
  12. rulewright/ingest/reviews.py +1 -0
  13. rulewright/ingest/sessions/__init__.py +0 -0
  14. rulewright/ingest/sessions/base.py +47 -0
  15. rulewright/ingest/sessions/claude_code.py +138 -0
  16. rulewright/ingest/sessions/codex.py +112 -0
  17. rulewright/mine/__init__.py +0 -0
  18. rulewright/mine/detectors.py +256 -0
  19. rulewright/mine/score.py +28 -0
  20. rulewright/mine/signals.py +20 -0
  21. rulewright/pipeline.py +146 -0
  22. rulewright/pr.py +123 -0
  23. rulewright/propose/__init__.py +0 -0
  24. rulewright/propose/guards.py +131 -0
  25. rulewright/propose/llm.py +141 -0
  26. rulewright/propose/redact.py +44 -0
  27. rulewright/propose/writer.py +240 -0
  28. rulewright/prove/__init__.py +0 -0
  29. rulewright/prove/agent.py +131 -0
  30. rulewright/prove/judge.py +62 -0
  31. rulewright/prove/runner.py +198 -0
  32. rulewright/prove/sandbox.py +138 -0
  33. rulewright/prove/tasks.py +85 -0
  34. rulewright/report.py +63 -0
  35. rulewright/rules/__init__.py +0 -0
  36. rulewright/rules/emit/__init__.py +55 -0
  37. rulewright/rules/emit/agents_md.py +9 -0
  38. rulewright/rules/emit/base.py +74 -0
  39. rulewright/rules/emit/claude_md.py +9 -0
  40. rulewright/rules/emit/copilot.py +9 -0
  41. rulewright/rules/emit/cursor.py +12 -0
  42. rulewright/rules/model.py +24 -0
  43. rulewright/rules/source.py +127 -0
  44. rulewright/rules/stale.py +142 -0
  45. rulewright/store.py +56 -0
  46. rulewright-0.1.0.dist-info/METADATA +144 -0
  47. rulewright-0.1.0.dist-info/RECORD +50 -0
  48. rulewright-0.1.0.dist-info/WHEEL +4 -0
  49. rulewright-0.1.0.dist-info/entry_points.txt +2 -0
  50. rulewright-0.1.0.dist-info/licenses/LICENSE +21 -0
rulewright/__init__.py ADDED
@@ -0,0 +1 @@
1
+ __version__ = "0.1.0"
rulewright/cli.py ADDED
@@ -0,0 +1,490 @@
1
+ import os
2
+ from collections.abc import Callable
3
+ from datetime import UTC, datetime
4
+ from pathlib import Path
5
+ from typing import Annotated
6
+
7
+ import typer
8
+ from rich.console import Console
9
+ from rich.table import Table
10
+
11
+ from rulewright.cluster.embed import Embedder, SentenceTransformerEmbedder
12
+ from rulewright.config import load_config
13
+ from rulewright.guard.emit import (
14
+ SettingsError,
15
+ load_settings,
16
+ render,
17
+ rule_for,
18
+ settings_path,
19
+ unified_diff,
20
+ with_rules,
21
+ write_atomic,
22
+ )
23
+ from rulewright.guard.profile import MIN_USES, Pattern, allowable, build_profile
24
+ from rulewright.ingest.git_history import read_commits
25
+ from rulewright.ingest.sessions.base import ToolCall, ToolCallSource
26
+ from rulewright.ingest.sessions.claude_code import ClaudeCodeSource
27
+ from rulewright.ingest.sessions.codex import CodexSource
28
+ from rulewright.mine.detectors import (
29
+ dedupe_signals,
30
+ detect_corrections,
31
+ detect_refixes,
32
+ detect_reverts,
33
+ )
34
+ from rulewright.pipeline import run_scan
35
+ from rulewright.pr import PRError, create_pr
36
+ from rulewright.propose.llm import (
37
+ CostEstimate,
38
+ LiteLLMClient,
39
+ ollama_models,
40
+ pick_model,
41
+ )
42
+ from rulewright.prove.agent import BuiltinAgent
43
+ from rulewright.prove.runner import ProveReport, estimate_runs, prove_rule
44
+ from rulewright.prove.sandbox import DEFAULT_IMAGE, DockerSandbox, Sandbox
45
+ from rulewright.prove.tasks import MAX_TASKS, build_tasks
46
+ from rulewright.report import REPORT_NAME, render_report
47
+ from rulewright.rules.emit import MarkerError, plan_emit, write_changes
48
+ from rulewright.rules.model import Rule
49
+ from rulewright.rules.source import (
50
+ FILENAME,
51
+ RulesFile,
52
+ RulesFileError,
53
+ load_pending,
54
+ load_rules,
55
+ save_pending,
56
+ save_rules,
57
+ )
58
+ from rulewright.rules.stale import stale_findings
59
+
60
+ app = typer.Typer(help="Learn rules for your coding agent from its past mistakes.")
61
+ console = Console()
62
+
63
+ RepoOption = Annotated[Path, typer.Option("--repo", help="Repository root.")]
64
+
65
+
66
+ def _fail(message: str, code: int = 1) -> typer.Exit:
67
+ console.print(f"[red]{message}[/red]")
68
+ return typer.Exit(code)
69
+
70
+
71
+ def _load(repo: Path) -> RulesFile:
72
+ try:
73
+ return load_rules(repo)
74
+ except RulesFileError as e:
75
+ raise _fail(str(e)) from e
76
+
77
+
78
+ def _emit(repo: Path, rules_file: RulesFile, dry_run: bool) -> None:
79
+ try:
80
+ changes = plan_emit(repo, rules_file)
81
+ except (MarkerError, ValueError) as e:
82
+ raise _fail(f"Nothing was written: {e}") from e
83
+ if not changes:
84
+ console.print("Target files are already up to date.")
85
+ return
86
+ for c in changes:
87
+ if dry_run:
88
+ console.print(f"[bold]{c.path}[/bold]")
89
+ console.print(c.diff(), markup=False, highlight=False)
90
+ else:
91
+ console.print(f"{'created' if c.old is None else 'updated'} {c.path}")
92
+ if not dry_run:
93
+ write_changes(changes)
94
+
95
+
96
+ @app.callback()
97
+ def main() -> None:
98
+ """rulewright command line."""
99
+
100
+
101
+ @app.command()
102
+ def init(repo: RepoOption = Path(".")) -> None:
103
+ """Create an empty rulewright.yaml."""
104
+ path = repo / FILENAME
105
+ if path.exists():
106
+ raise _fail(f"{FILENAME} already exists; nothing changed.")
107
+ save_rules(repo, RulesFile())
108
+ console.print(f"Created {path}. Add `.rulewright/` to .gitignore; commit {FILENAME}.")
109
+
110
+
111
+ def _make_embedder() -> Embedder:
112
+ return SentenceTransformerEmbedder()
113
+
114
+
115
+ def _make_client(model: str | None) -> LiteLLMClient | None:
116
+ chosen = model or pick_model(os.environ, ollama_models())
117
+ return LiteLLMClient(chosen) if chosen else None
118
+
119
+
120
+ def _short(rule: Rule) -> str:
121
+ refs = [(e.commit_sha or e.signal_id)[:7] for e in rule.evidence[:3]]
122
+ more = len(rule.evidence) - len(refs)
123
+ return ", ".join(refs) + (f" +{more}" if more > 0 else "")
124
+
125
+
126
+ @app.command()
127
+ def scan(
128
+ repo: RepoOption = Path("."),
129
+ commits: Annotated[int | None, typer.Option(help="How many recent commits to read.")] = None,
130
+ model: Annotated[str | None, typer.Option(help="LiteLLM model, e.g. ollama/llama3.1.")] = None,
131
+ yes: Annotated[bool, typer.Option("--yes", help="Do not ask before a paid LLM call.")] = False,
132
+ judge: Annotated[
133
+ bool, typer.Option("--judge/--no-judge", help="Second LLM call that drops generic rules.")
134
+ ] = True,
135
+ pr: Annotated[
136
+ bool, typer.Option("--pr", help="Open a pull request with the guard-approved rules.")
137
+ ] = False,
138
+ ) -> None:
139
+ """Find repeated agent mistakes and propose rules. Changes no agent file."""
140
+ limit = commits or load_config(repo).commits
141
+ existing = _load(repo)
142
+ now = datetime.now(UTC)
143
+
144
+ with console.status("Starting...") as status:
145
+
146
+ def confirm(estimate: CostEstimate) -> bool:
147
+ price = "unknown price" if estimate.usd is None else f"about ${estimate.usd:.4f}"
148
+ status.stop() # a live spinner hides the prompt, so the scan looks stuck
149
+ try:
150
+ console.print(
151
+ f"LLM: {estimate.model}, {estimate.calls} call(s), ~{estimate.tokens_in} "
152
+ f"tokens in, ~{estimate.tokens_out} out: {price}"
153
+ )
154
+ if estimate.usd == 0.0 or yes:
155
+ return True
156
+ return typer.confirm("Proceed with the paid call?")
157
+ finally:
158
+ status.start()
159
+
160
+ def progress(i: int, n: int) -> None:
161
+ status.update(f"Writing rule {i} of {n} with the LLM (a local model can be slow)...")
162
+
163
+ result = run_scan(
164
+ repo, limit, _make_embedder(), _make_client(model), existing, now, confirm,
165
+ sessions=[ClaudeCodeSource()], progress=progress, judge=judge,
166
+ stage=lambda name: status.update(name + "..."),
167
+ ) # fmt: skip
168
+
169
+ kinds = ", ".join(sorted({s.kind for s in result.signals}))
170
+ console.print(
171
+ f"Read {result.commits_read} commits ({result.agent_commits} agent-authored); "
172
+ f"{len(result.signals)} signal(s)" + (f" [{kinds}]" if kinds else "")
173
+ + f" in {len(result.clusters)} cluster(s)."
174
+ )
175
+ if result.cold_start:
176
+ console.print(f"[yellow]{result.cold_start}[/yellow]")
177
+ for note in result.notes:
178
+ console.print(f"[yellow]{note}[/yellow]")
179
+
180
+ rules = result.proposals.rules if result.proposals else []
181
+ if rules:
182
+ table = Table("id", "proposed rule", "scope", "evidence", "risk")
183
+ for r in rules:
184
+ table.add_row(
185
+ r.id, r.text, ", ".join(r.scope), _short(r), ", ".join(r.risk_flags) or "-",
186
+ style="red" if r.risk_flags else "",
187
+ ) # fmt: skip
188
+ console.print(table)
189
+ for d in result.proposals.dropped if result.proposals else []:
190
+ console.print(f"[dim]dropped {d.cluster_id}: {d.reason}[/dim]")
191
+ if result.stale:
192
+ table = Table("stale rule", "reason", "detail")
193
+ for f in result.stale:
194
+ table.add_row(f.rule_id, f.reason, f.detail)
195
+ console.print(table)
196
+
197
+ report = repo / REPORT_NAME
198
+ report.write_text(render_report(result, repo, now), encoding="utf-8")
199
+ console.print(f"Report: {report}")
200
+ if result.proposals is not None:
201
+ save_pending(repo, rules)
202
+ if rules:
203
+ console.print("Nothing has been applied. Accept rules with `rulewright apply <id> ...`.")
204
+ if pr:
205
+ safe = [r for r in rules if not r.risk_flags]
206
+ if not safe:
207
+ console.print("[yellow]No rules passed the guards, so no pull request.[/yellow]")
208
+ return
209
+ try:
210
+ outcome = create_pr(repo, safe, existing, now)
211
+ except (PRError, MarkerError) as e:
212
+ raise _fail(f"Pull request not created: {e}") from e
213
+ console.print(f"Branch {outcome.branch}" + (f"; PR {outcome.url}" if outcome.url else ""))
214
+ for note in outcome.notes:
215
+ console.print(f"[yellow]{note}[/yellow]")
216
+
217
+
218
+ @app.command()
219
+ def emit(
220
+ repo: RepoOption = Path("."),
221
+ dry_run: Annotated[bool, typer.Option("--dry-run", help="Show a diff, write nothing.")] = False,
222
+ ) -> None:
223
+ """Re-render every target file from the accepted rules in rulewright.yaml."""
224
+ _emit(repo, _load(repo), dry_run)
225
+
226
+
227
+ @app.command()
228
+ def apply(
229
+ ids: Annotated[list[str] | None, typer.Argument(help="Rule ids to accept.")] = None,
230
+ all_safe: Annotated[
231
+ bool, typer.Option("--all-safe", help="Accept every pending rule without risk flags.")
232
+ ] = False,
233
+ allow_risky: Annotated[
234
+ bool, typer.Option("--allow-risky", help="Allow accepting rules that carry risk flags.")
235
+ ] = False,
236
+ repo: RepoOption = Path("."),
237
+ ) -> None:
238
+ """Accept pending proposals into rulewright.yaml and write the target files."""
239
+ pending = load_pending(repo)
240
+ if not pending:
241
+ raise _fail("No pending proposals. Run `rulewright scan` first.")
242
+ if not ids and not all_safe:
243
+ table = Table("id", "rule", "risk")
244
+ for r in pending:
245
+ flags = ", ".join(r.risk_flags) or "-"
246
+ table.add_row(r.id, r.text, flags, style="red" if r.risk_flags else "")
247
+ console.print(table)
248
+ console.print("Choose rules: `rulewright apply <id> ...` or `--all-safe`.")
249
+ return
250
+ by_id = {r.id: r for r in pending}
251
+ unknown = [i for i in ids or [] if i not in by_id]
252
+ if unknown:
253
+ raise _fail(f"Unknown rule ids: {', '.join(unknown)}", 2)
254
+ chosen = [by_id[i] for i in ids or []]
255
+ if all_safe:
256
+ chosen += [r for r in pending if not r.risk_flags and r.id not in {c.id for c in chosen}]
257
+ risky = [r.id for r in chosen if r.risk_flags]
258
+ if risky and not allow_risky:
259
+ raise _fail(f"Refusing risk-flagged rules without --allow-risky: {', '.join(risky)}")
260
+ rules_file = _load(repo)
261
+ try: # check the targets first so a broken marker leaves everything untouched
262
+ changes = plan_emit(repo, _with(rules_file, chosen))
263
+ except (MarkerError, ValueError) as e:
264
+ raise _fail(f"Nothing was written: {e}") from e
265
+ save_rules(repo, _with(rules_file, chosen))
266
+ write_changes(changes)
267
+ chosen_ids = {r.id for r in chosen}
268
+ save_pending(repo, [r for r in pending if r.id not in chosen_ids])
269
+ console.print(f"Accepted {len(chosen)} rule(s); updated {len(changes)} file(s).")
270
+ for c in changes:
271
+ console.print(f" {c.path}")
272
+
273
+
274
+ def _with(rules_file: RulesFile, chosen: list[Rule]) -> RulesFile:
275
+ updated = rules_file.model_copy(deep=True)
276
+ for rule in chosen:
277
+ updated.add(rule, "accepted")
278
+ return updated
279
+
280
+
281
+ @app.command()
282
+ def stale(
283
+ repo: RepoOption = Path("."),
284
+ commits: Annotated[int | None, typer.Option(help="Commits to read for activity.")] = None,
285
+ mark: Annotated[
286
+ bool, typer.Option("--mark", help="Set stale rules to status 'stale' in rulewright.yaml.")
287
+ ] = False,
288
+ ) -> None:
289
+ """List accepted rules that mention missing paths or commands, or that went silent."""
290
+ rules_file = _load(repo)
291
+ limit = commits or load_config(repo).commits
292
+ signals = detect_reverts(read_commits(repo, limit)) + detect_refixes(read_commits(repo, limit))
293
+ signals += detect_corrections(ClaudeCodeSource().read(repo))
294
+ findings = stale_findings(rules_file.rules, repo, signals, datetime.now(UTC))
295
+ if not findings:
296
+ console.print("No stale rules.")
297
+ return
298
+ table = Table("rule", "reason", "detail")
299
+ for f in findings:
300
+ table.add_row(f.rule_id, f.reason, f.detail)
301
+ console.print(table)
302
+ if mark:
303
+ for rule_id in {f.rule_id for f in findings}:
304
+ rule = rules_file.get(rule_id)
305
+ if rule:
306
+ rules_file.add(rule, "stale")
307
+ save_rules(repo, rules_file)
308
+ marked = len({f.rule_id for f in findings})
309
+ console.print(f"Marked {marked} rule(s) stale; run `rulewright emit`.")
310
+
311
+
312
+ def _docker_problem() -> str | None:
313
+ """Why the sandbox cannot be used, or None when Docker is reachable."""
314
+ try:
315
+ import docker
316
+
317
+ docker.from_env().ping()
318
+ except Exception as e: # the SDK raises several types; all mean the same to the user
319
+ return f"Docker is not available ({type(e).__name__}). Start Docker Desktop or the daemon."
320
+ return None
321
+
322
+
323
+ def _make_sandbox_factory(image: str) -> Callable[[Path], Sandbox]:
324
+ return lambda work: DockerSandbox(work, image)
325
+
326
+
327
+ def _rate(report: ProveReport, arm: str) -> str:
328
+ rate = report.rate(arm)
329
+ if rate is None:
330
+ return "no judged runs"
331
+ return f"{report.recurrences(arm)}/{len(report.judged(arm))} ({rate:.0%})"
332
+
333
+
334
+ @app.command()
335
+ def prove(
336
+ rule_id: Annotated[str, typer.Argument(help="Id of an accepted rule in rulewright.yaml.")],
337
+ repo: RepoOption = Path("."),
338
+ tasks: Annotated[int, typer.Option(help="Past tasks to replay (at most 10).")] = 10,
339
+ trials: Annotated[int, typer.Option(help="Repeat each task this many times.")] = 1,
340
+ max_cost: Annotated[float | None, typer.Option(help="Stop before spending more (USD).")] = None,
341
+ model: Annotated[str | None, typer.Option(help="LiteLLM model for the agent.")] = None,
342
+ image: Annotated[str, typer.Option(help="Docker image the agent works in.")] = DEFAULT_IMAGE,
343
+ commits: Annotated[int, typer.Option(help="History to search for the evidence.")] = 2000,
344
+ yes: Annotated[bool, typer.Option("--yes", help="Do not ask before a paid run.")] = False,
345
+ ) -> None:
346
+ """Replay past tasks with and without a rule and report how often the mistake recurs."""
347
+ rule = _load(repo).get(rule_id)
348
+ if rule is None:
349
+ raise _fail(f"No rule {rule_id} in {FILENAME}.", 2)
350
+ history = read_commits(repo, commits)
351
+ signals = dedupe_signals(detect_reverts(history) + detect_refixes(history))
352
+ chosen = build_tasks(rule, history, signals, min(tasks, MAX_TASKS))
353
+ if not chosen:
354
+ raise _fail(
355
+ f"None of the evidence commits for {rule_id} is in the last {commits} commits; "
356
+ "try a larger --commits."
357
+ )
358
+ client = _make_client(model)
359
+ if client is None:
360
+ raise _fail("No LLM available: start Ollama, set GROQ_API_KEY or similar, or use --model.")
361
+ if (problem := _docker_problem()) is not None:
362
+ raise _fail(problem)
363
+
364
+ runs = len(chosen) * trials * 2
365
+ tokens_in, tokens_out, usd = estimate_runs(client.model, runs)
366
+ price = "unknown price" if usd is None else f"about ${usd:.2f}"
367
+ console.print(
368
+ f"{len(chosen)} task(s) x 2 arms x {trials} trial(s) = {runs} agent runs with "
369
+ f"{client.model}: ~{tokens_in:,} tokens in, ~{tokens_out:,} out, {price}."
370
+ )
371
+ if max_cost is not None and usd is not None and usd > max_cost:
372
+ console.print(f"[yellow]The estimate is above --max-cost ${max_cost:.2f}; "
373
+ "the run will stop at the ceiling.[/yellow]") # fmt: skip
374
+ if usd != 0.0 and not yes and not typer.confirm("Proceed with the paid runs?"):
375
+ raise _fail("Cancelled before any run.", 0)
376
+
377
+ with console.status("Replaying tasks in the sandbox...") as status:
378
+
379
+ def progress(i: int, n: int) -> None:
380
+ status.update(f"Agent run {i} of {n} (each in a fresh container)...")
381
+
382
+ report = prove_rule(
383
+ rule, chosen, repo, BuiltinAgent(client), _make_sandbox_factory(image), trials,
384
+ max_cost, judge_client=client, progress=progress,
385
+ ) # fmt: skip
386
+
387
+ table = Table("", "without the rule", "with the rule", title=f"{rule.id}: {rule.text}")
388
+ table.add_row("mistake recurred", _rate(report, "without"), _rate(report, "with"))
389
+ table.add_row(
390
+ "runs that changed files", str(report.changed("without")), str(report.changed("with"))
391
+ )
392
+ console.print(table)
393
+ cost = "unknown" if report.cost_usd is None else f"${report.cost_usd:.4f}"
394
+ console.print(f"{len(report.records)} run(s), approximate cost {cost}.")
395
+ if report.aborted:
396
+ console.print(f"[yellow]{report.aborted}; results are partial.[/yellow]")
397
+ errors = [r for r in report.records if r.error]
398
+ if errors:
399
+ first = errors[0].error
400
+ console.print(f"[yellow]{len(errors)} run(s) failed and are not counted: {first}[/yellow]")
401
+ console.print(
402
+ "[dim]Evidence, not proof: a handful of tasks, nondeterministic agents and "
403
+ "prompts rebuilt from commit messages.[/dim]"
404
+ )
405
+ out = repo / ".rulewright" / "prove"
406
+ out.mkdir(parents=True, exist_ok=True)
407
+ (out / f"{rule.id}.json").write_text(report.model_dump_json(indent=2), encoding="utf-8")
408
+
409
+
410
+ def _tool_call_sources() -> list[ToolCallSource]:
411
+ return [ClaudeCodeSource(), CodexSource()]
412
+
413
+
414
+ def _guard_table(title: str, patterns: list[Pattern], style: str = "") -> None:
415
+ table = Table("pattern", "uses", "sessions", "failed", "example", title=title)
416
+ for p in patterns:
417
+ table.add_row(p.pattern, str(p.uses), str(p.sessions), str(p.failures), p.example,
418
+ style=style) # fmt: skip
419
+ console.print(table)
420
+
421
+
422
+ @app.command()
423
+ def guard(
424
+ repo: RepoOption = Path("."),
425
+ min_uses: Annotated[int, typer.Option(help="Times a command must have run.")] = MIN_USES,
426
+ include_review: Annotated[
427
+ bool, typer.Option("--include-review", help="Also offer commands outside the safe list.")
428
+ ] = False,
429
+ local: Annotated[
430
+ bool, typer.Option("--local", help="Use .claude/settings.local.json, not settings.json.")
431
+ ] = False,
432
+ diff: Annotated[bool, typer.Option("--diff", help="Show the change; write nothing.")] = False,
433
+ apply: Annotated[bool, typer.Option("--apply", help="Write the change after asking.")] = False,
434
+ yes: Annotated[bool, typer.Option("--yes", help="Do not ask before writing.")] = False,
435
+ ) -> None:
436
+ """Propose a Claude Code permission allowlist from the commands agents ran here."""
437
+ calls: list[ToolCall] = []
438
+ for source in _tool_call_sources():
439
+ calls += source.read_calls(repo)
440
+ profile = build_profile(calls, min_uses)
441
+ if profile.commands_seen == 0:
442
+ console.print(
443
+ "No shell commands found in your Claude Code or Codex session logs for this repo."
444
+ )
445
+ raise typer.Exit(0)
446
+ console.print(
447
+ f"Read {profile.commands_seen} shell command(s) from agent sessions"
448
+ + (f"; ignored {profile.skipped_other_shell} PowerShell command(s)"
449
+ if profile.skipped_other_shell else "")
450
+ + "."
451
+ ) # fmt: skip
452
+ safe, review = profile.tier("safe"), profile.tier("review")
453
+ if safe:
454
+ _guard_table("Safe to allow (read-only, test, lint, build)", safe)
455
+ if review:
456
+ note = "included" if include_review else "not written unless --include-review"
457
+ _guard_table(f"Run often, but your call ({note})", review, "yellow")
458
+ if never := profile.tier("never"):
459
+ names = ", ".join(f"{p.pattern} ({p.uses}x)" for p in never[:8])
460
+ console.print(f"[dim]Never offered, Claude Code keeps asking: {names}[/dim]")
461
+ refused = [p.pattern for p in profile.patterns if p.denied]
462
+ if refused:
463
+ console.print(f"[dim]You refused these at least once, so they are left out: "
464
+ f"{', '.join(refused)}[/dim]") # fmt: skip
465
+
466
+ chosen = allowable(profile, include_review)
467
+ if not chosen:
468
+ console.print("Nothing to allow yet. Try a lower --min-uses or more sessions.")
469
+ raise typer.Exit(0)
470
+ path = settings_path(repo, local)
471
+ try:
472
+ old = load_settings(path)
473
+ except SettingsError as e:
474
+ raise _fail(str(e)) from None
475
+ new, added = with_rules(old, [rule_for(p) for p in chosen])
476
+ if not added:
477
+ console.print(f"Every rule is already in {path.name}. Nothing to change.")
478
+ raise typer.Exit(0)
479
+ before = path.read_text(encoding="utf-8") if path.exists() else ""
480
+ text = unified_diff(before, render(new), f".claude/{path.name}")
481
+ if diff or apply:
482
+ console.print(text, markup=False, highlight=False)
483
+ if not apply:
484
+ console.print(f"{len(added)} rule(s) would be added. Nothing was written. "
485
+ "Use --diff to see the change and --apply to write it.") # fmt: skip
486
+ raise typer.Exit(0)
487
+ if not yes and not typer.confirm(f"Write {len(added)} rule(s) to {path}?"):
488
+ raise _fail("Cancelled; nothing was written.", 0)
489
+ write_atomic(path, render(new))
490
+ console.print(f"Wrote {len(added)} rule(s) to {path}.")
File without changes
@@ -0,0 +1,66 @@
1
+ """Local embeddings of signal summaries, cached in SQLite so a rescan only embeds new text."""
2
+
3
+ import hashlib
4
+ import sqlite3
5
+ from typing import Protocol
6
+
7
+ import numpy as np
8
+
9
+ from rulewright.mine.signals import Signal
10
+ from rulewright.store import Vector, get_embeddings, put_embeddings
11
+
12
+ SUMMARY_CHANGE_CHARS = 200
13
+ DEFAULT_MODEL ="sentence-transformers/all-MiniLM-L6-v2"
14
+
15
+
16
+ class Embedder(Protocol):
17
+ name: str
18
+
19
+ def embed(self, texts: list[str]) -> list[Vector]: ...
20
+
21
+
22
+ class SentenceTransformerEmbedder:
23
+ """Small offline model; downloaded on first use, no API key."""
24
+
25
+ def __init__(self, model: str = DEFAULT_MODEL) -> None:
26
+ self.name = model
27
+ self._model: object | None = None
28
+
29
+ def embed(self, texts: list[str]) -> list[Vector]:
30
+ if self._model is None:
31
+ from sentence_transformers import SentenceTransformer # slow import, load lazily
32
+
33
+ self._model = SentenceTransformer(self.name)
34
+ vecs = self._model.encode(texts, normalize_embeddings=True) # type: ignore[attr-defined]
35
+ return [np.asarray(v, dtype=np.float32) for v in vecs]
36
+
37
+
38
+ def signal_summary(signal: Signal) -> str:
39
+ """Short text describing what went wrong; this is what gets embedded.
40
+
41
+ The human's fix (removed and added lines) comes first: it carries the mistake itself,
42
+ where the commit subjects of a busy repository are all different.
43
+ """
44
+ change = " ".join(signal.diff_excerpt.split())[:SUMMARY_CHANGE_CHARS]
45
+ files = ", ".join(signal.files[:5])
46
+ return (
47
+ (f"change: {change} | " if change else "")
48
+ + f"{signal.kind}: {signal.text}"
49
+ + (f" (files: {files})" if files else "")
50
+ )
51
+
52
+
53
+ def embed_signals(
54
+ signals: list[Signal], embedder: Embedder, conn: sqlite3.Connection | None = None
55
+ ) -> list[Vector]:
56
+ """One vector per signal, in order. Cached vectors are reused when `conn` is given."""
57
+ texts = [signal_summary(s) for s in signals]
58
+ keys = [hashlib.sha256(t.encode("utf-8")).hexdigest() for t in texts]
59
+ cached = get_embeddings(conn, embedder.name, keys) if conn is not None else {}
60
+ missing = {k: t for k, t in zip(keys, texts, strict=True) if k not in cached}
61
+ if missing:
62
+ fresh = dict(zip(missing, embedder.embed(list(missing.values())), strict=True))
63
+ if conn is not None:
64
+ put_embeddings(conn, embedder.name, fresh)
65
+ cached.update(fresh)
66
+ return [cached[k] for k in keys]
@@ -0,0 +1,93 @@
1
+ """HDBSCAN clustering of signal embeddings: one cluster is one candidate rule."""
2
+
3
+ import hashlib
4
+ import itertools
5
+ import re
6
+
7
+ import numpy as np
8
+ from numpy.typing import NDArray
9
+ from pydantic import BaseModel
10
+ from sklearn.cluster import HDBSCAN
11
+
12
+ from rulewright.mine.signals import Signal
13
+ from rulewright.store import Vector
14
+
15
+ MIN_CLUSTER_SIZE = 3 # spec: only clusters of 3+ signals become rules
16
+ MIN_CLUSTERS = 3 # spec: fewer than this triggers the cold-start message
17
+ # A cluster whose changes share fewer names than this is unrelated edits that merely look alike
18
+ # (import fixes after files move). On litellm it kept all 3 clusters judged good by hand and
19
+ # dropped 6 of 7 judged weak; 10 clusters is a small sample, so the floor is kept low.
20
+ COHERENCE_FLOOR = 0.10
21
+ _IDENTIFIER = re.compile(r"[A-Za-z_][A-Za-z0-9_]{2,}")
22
+
23
+
24
+ class Cluster(BaseModel):
25
+ id: str
26
+ signal_ids: list[str]
27
+ centroid: list[float]
28
+ summary: str # text of the signal closest to the centroid
29
+ size: int
30
+
31
+
32
+ def coherence(signals: list[Signal]) -> float:
33
+ """Mean pairwise overlap (Jaccard) of the identifiers in what each signal changed, 0 to 1.
34
+
35
+ A repeated mistake keeps touching the same names; unrelated churn does not.
36
+ """
37
+ names = [set(_IDENTIFIER.findall(s.diff_excerpt or s.text)) for s in signals]
38
+ pairs = [len(a & b) / len(a | b) for a, b in itertools.combinations(names, 2) if a | b]
39
+ return sum(pairs) / len(pairs) if pairs else 0.0
40
+
41
+
42
+ def cluster_signals(
43
+ signals: list[Signal], vectors: list[Vector], min_size: int = MIN_CLUSTER_SIZE
44
+ ) -> list[Cluster]:
45
+ """Group similar signals. Noise and groups smaller than `min_size` are dropped."""
46
+ if len(signals) != len(vectors):
47
+ raise ValueError("one vector per signal is required")
48
+ if len(signals) < min_size:
49
+ return []
50
+ matrix = np.vstack(vectors)
51
+ labels = _labels(matrix, min_size, allow_single=False)
52
+ if not any(label != -1 for label in labels.tolist()):
53
+ # HDBSCAN cannot return one cluster unless allowed to, but allowing it everywhere
54
+ # merged unrelated mistakes into one blob on real repositories; use it only as a retry
55
+ labels = _labels(matrix, min_size, allow_single=True)
56
+ clusters = []
57
+ for label in sorted(set(labels.tolist()) - {-1}):
58
+ idx = [i for i, lab in enumerate(labels) if lab == label]
59
+ if len(idx) < min_size:
60
+ continue
61
+ # a repeated mistake is repeated across commits: one commit followed by three fixes
62
+ # is one occurrence
63
+ if len({signals[i].commit_sha or signals[i].id for i in idx}) < min_size:
64
+ continue
65
+ centroid = matrix[idx].mean(axis=0)
66
+ nearest = idx[int(np.argmin(np.linalg.norm(matrix[idx] - centroid, axis=1)))]
67
+ ids = sorted(signals[i].id for i in idx)
68
+ clusters.append(
69
+ Cluster(
70
+ id="c-" + hashlib.sha1("|".join(ids).encode()).hexdigest()[:8],
71
+ signal_ids=ids,
72
+ centroid=[float(x) for x in centroid],
73
+ summary=signals[nearest].text,
74
+ size=len(idx),
75
+ )
76
+ )
77
+ return sorted(clusters, key=lambda c: (-c.size, c.id))
78
+
79
+
80
+ def _labels(matrix: NDArray[np.float32], min_size: int, allow_single: bool) -> NDArray[np.intp]:
81
+ model = HDBSCAN(min_cluster_size=min_size, allow_single_cluster=allow_single, copy=True)
82
+ return model.fit_predict(matrix) # type: ignore[no-any-return]
83
+
84
+
85
+ def cold_start_message(n_clusters: int, commits: int) -> str | None:
86
+ """Honest message when there is too little to propose, instead of inventing weak rules."""
87
+ if n_clusters >= MIN_CLUSTERS:
88
+ return None
89
+ return (
90
+ f"Found {n_clusters} repeated mistake pattern(s); at least {MIN_CLUSTERS} are needed "
91
+ f"for useful rules. Try a deeper scan (--commits {max(commits * 4, 2000)}) or add "
92
+ "agent session logs."
93
+ )
rulewright/config.py ADDED
@@ -0,0 +1,19 @@
1
+ """Load .rulewright/config.toml. Every key is optional; defaults match the spec."""
2
+
3
+ import tomllib
4
+ from pathlib import Path
5
+
6
+ from pydantic import BaseModel
7
+
8
+
9
+ class Config(BaseModel):
10
+ commits: int = 500
11
+ targets: list[str] = ["agents_md", "claude_md", "cursor"]
12
+ min_cluster_size: int = 3
13
+
14
+
15
+ def load_config(repo: Path) -> Config:
16
+ path = repo / ".rulewright" / "config.toml"
17
+ if not path.is_file():
18
+ return Config()
19
+ return Config.model_validate(tomllib.loads(path.read_text(encoding="utf-8")))
File without changes