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.
- rulewright/__init__.py +1 -0
- rulewright/cli.py +490 -0
- rulewright/cluster/__init__.py +0 -0
- rulewright/cluster/embed.py +66 -0
- rulewright/cluster/group.py +93 -0
- rulewright/config.py +19 -0
- rulewright/guard/__init__.py +0 -0
- rulewright/guard/emit.py +75 -0
- rulewright/guard/profile.py +172 -0
- rulewright/ingest/__init__.py +0 -0
- rulewright/ingest/git_history.py +185 -0
- rulewright/ingest/reviews.py +1 -0
- rulewright/ingest/sessions/__init__.py +0 -0
- rulewright/ingest/sessions/base.py +47 -0
- rulewright/ingest/sessions/claude_code.py +138 -0
- rulewright/ingest/sessions/codex.py +112 -0
- rulewright/mine/__init__.py +0 -0
- rulewright/mine/detectors.py +256 -0
- rulewright/mine/score.py +28 -0
- rulewright/mine/signals.py +20 -0
- rulewright/pipeline.py +146 -0
- rulewright/pr.py +123 -0
- rulewright/propose/__init__.py +0 -0
- rulewright/propose/guards.py +131 -0
- rulewright/propose/llm.py +141 -0
- rulewright/propose/redact.py +44 -0
- rulewright/propose/writer.py +240 -0
- rulewright/prove/__init__.py +0 -0
- rulewright/prove/agent.py +131 -0
- rulewright/prove/judge.py +62 -0
- rulewright/prove/runner.py +198 -0
- rulewright/prove/sandbox.py +138 -0
- rulewright/prove/tasks.py +85 -0
- rulewright/report.py +63 -0
- rulewright/rules/__init__.py +0 -0
- rulewright/rules/emit/__init__.py +55 -0
- rulewright/rules/emit/agents_md.py +9 -0
- rulewright/rules/emit/base.py +74 -0
- rulewright/rules/emit/claude_md.py +9 -0
- rulewright/rules/emit/copilot.py +9 -0
- rulewright/rules/emit/cursor.py +12 -0
- rulewright/rules/model.py +24 -0
- rulewright/rules/source.py +127 -0
- rulewright/rules/stale.py +142 -0
- rulewright/store.py +56 -0
- rulewright-0.1.0.dist-info/METADATA +144 -0
- rulewright-0.1.0.dist-info/RECORD +50 -0
- rulewright-0.1.0.dist-info/WHEEL +4 -0
- rulewright-0.1.0.dist-info/entry_points.txt +2 -0
- 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
|