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/guard/emit.py
ADDED
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
"""Add allow rules to a Claude Code settings file without disturbing anything else in it."""
|
|
2
|
+
|
|
3
|
+
import difflib
|
|
4
|
+
import json
|
|
5
|
+
import os
|
|
6
|
+
import tempfile
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
from typing import Any
|
|
9
|
+
|
|
10
|
+
from rulewright.guard.profile import Pattern
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class SettingsError(Exception):
|
|
14
|
+
"""The settings file is not something we can safely edit."""
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def settings_path(repo: Path, local: bool = False) -> Path:
|
|
18
|
+
name = "settings.local.json" if local else "settings.json"
|
|
19
|
+
return repo / ".claude" / name
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def rule_for(pattern: Pattern) -> str:
|
|
23
|
+
return f"Bash({pattern.pattern}:*)"
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def load_settings(path: Path) -> dict[str, Any]:
|
|
27
|
+
if not path.exists():
|
|
28
|
+
return {}
|
|
29
|
+
try:
|
|
30
|
+
data = json.loads(path.read_text(encoding="utf-8"))
|
|
31
|
+
except (OSError, ValueError) as e:
|
|
32
|
+
msg = f"{path} is not valid JSON ({e}); fix it first, nothing was changed."
|
|
33
|
+
raise SettingsError(msg) from None
|
|
34
|
+
if not isinstance(data, dict):
|
|
35
|
+
raise SettingsError(f"{path} does not hold a JSON object; nothing was changed.")
|
|
36
|
+
perms = data.get("permissions", {})
|
|
37
|
+
allow = perms.get("allow", []) if isinstance(perms, dict) else None
|
|
38
|
+
if not isinstance(allow, list):
|
|
39
|
+
raise SettingsError(f"{path} has an unexpected `permissions.allow`; nothing was changed.")
|
|
40
|
+
return data
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def with_rules(settings: dict[str, Any], rules: list[str]) -> tuple[dict[str, Any], list[str]]:
|
|
44
|
+
"""(new settings, the rules actually added). Existing rules and other keys are untouched."""
|
|
45
|
+
new = json.loads(json.dumps(settings))
|
|
46
|
+
perms = new.setdefault("permissions", {})
|
|
47
|
+
allow: list[Any] = perms.setdefault("allow", [])
|
|
48
|
+
added = [r for r in dict.fromkeys(rules) if r not in allow]
|
|
49
|
+
allow.extend(added)
|
|
50
|
+
return new, added
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def render(settings: dict[str, Any]) -> str:
|
|
54
|
+
return json.dumps(settings, indent=2, ensure_ascii=False) + "\n"
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def unified_diff(old: str, new: str, name: str) -> str:
|
|
58
|
+
return "".join(
|
|
59
|
+
difflib.unified_diff(
|
|
60
|
+
old.splitlines(keepends=True), new.splitlines(keepends=True),
|
|
61
|
+
fromfile=f"a/{name}", tofile=f"b/{name}",
|
|
62
|
+
)
|
|
63
|
+
) # fmt: skip
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def write_atomic(path: Path, text: str) -> None:
|
|
67
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
68
|
+
fd, tmp = tempfile.mkstemp(dir=path.parent, prefix=path.name, suffix=".tmp")
|
|
69
|
+
try:
|
|
70
|
+
with os.fdopen(fd, "w", encoding="utf-8", newline="\n") as fh:
|
|
71
|
+
fh.write(text)
|
|
72
|
+
os.replace(tmp, path)
|
|
73
|
+
except BaseException:
|
|
74
|
+
Path(tmp).unlink(missing_ok=True)
|
|
75
|
+
raise
|
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
"""Turn the shell commands an agent ran into a minimal, safe permission allowlist.
|
|
2
|
+
|
|
3
|
+
No LLM is involved and no command is executed. A pattern only reaches the "safe" tier by being
|
|
4
|
+
one of our own curated prefixes (SAFE_PREFIXES), so text from a log can never become an allow
|
|
5
|
+
rule on its own. Everything else that was run often is reported as "review" (a person decides)
|
|
6
|
+
or "never" (too broad or too dangerous to offer at all).
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
import re
|
|
10
|
+
import shlex
|
|
11
|
+
from collections import defaultdict
|
|
12
|
+
from datetime import datetime
|
|
13
|
+
from typing import Literal
|
|
14
|
+
|
|
15
|
+
from pydantic import BaseModel
|
|
16
|
+
|
|
17
|
+
from rulewright.ingest.sessions.base import ToolCall
|
|
18
|
+
from rulewright.propose.redact import redact
|
|
19
|
+
|
|
20
|
+
Tier = Literal["safe", "review", "never"]
|
|
21
|
+
|
|
22
|
+
MIN_USES = 3
|
|
23
|
+
# Tools whose commands are POSIX shell, i.e. what a `Bash(...)` rule matches. Codex's
|
|
24
|
+
# `shell_command` is PowerShell on Windows, so it is left out and reported instead.
|
|
25
|
+
BASH_TOOLS = {"Bash", "shell", "exec_command", "local_shell"}
|
|
26
|
+
|
|
27
|
+
# Read-only, test, lint and build commands. Longest match wins; nothing here can delete,
|
|
28
|
+
# publish, install or reach the network on its own.
|
|
29
|
+
SAFE_PREFIXES: tuple[tuple[str, ...], ...] = (
|
|
30
|
+
("pwd",), ("ls",), ("cd",), ("wc",),
|
|
31
|
+
("git", "status"), ("git", "diff"), ("git", "log"), ("git", "show"),
|
|
32
|
+
("git", "rev-parse"), ("git", "ls-files"), ("git", "blame"),
|
|
33
|
+
("pytest",), ("python", "-m", "pytest"), ("uv", "run", "pytest"),
|
|
34
|
+
("ruff", "check"), ("uv", "run", "ruff", "check"),
|
|
35
|
+
("mypy",), ("uv", "run", "mypy"),
|
|
36
|
+
("npm", "test"), ("npm", "run", "test"), ("npm", "run", "lint"),
|
|
37
|
+
("npm", "run", "typecheck"), ("npm", "run", "build"),
|
|
38
|
+
("pnpm", "test"), ("pnpm", "run", "test"), ("pnpm", "run", "lint"),
|
|
39
|
+
("pnpm", "run", "typecheck"), ("pnpm", "run", "build"),
|
|
40
|
+
("yarn", "test"), ("yarn", "lint"), ("yarn", "build"),
|
|
41
|
+
("cargo", "test"), ("cargo", "check"), ("cargo", "clippy"), ("cargo", "build"),
|
|
42
|
+
("go", "test"), ("go", "vet"), ("go", "build"),
|
|
43
|
+
("make", "test"), ("make", "lint"),
|
|
44
|
+
) # fmt: skip
|
|
45
|
+
|
|
46
|
+
# A rule for these would allow arbitrary code or arbitrary deletion, whatever follows.
|
|
47
|
+
NEVER_PROGRAMS = {
|
|
48
|
+
"python", "python3", "py", "node", "deno", "bun", "ruby", "perl", "php", "bash", "sh", "zsh",
|
|
49
|
+
"pwsh", "powershell", "cmd", "npx", "uvx", "pipx", "eval", "exec", "xargs", "env", "find",
|
|
50
|
+
"awk", "sed", "rm", "rmdir", "sudo", "su", "chmod", "chown", "dd", "mkfs", "kill", "pkill",
|
|
51
|
+
"curl", "wget", "nc", "ncat", "ssh", "scp", "sftp", "ftp", "docker", "podman", "kubectl",
|
|
52
|
+
"terraform", "mv", "cp", "tee", "xdg-open", "open", "start",
|
|
53
|
+
# wrappers: allowing `timeout` would allow `timeout 5 rm -rf x`
|
|
54
|
+
"timeout", "nice", "nohup", "time", "command", "builtin", "stdbuf", "watch", "setsid", "doas",
|
|
55
|
+
} # fmt: skip
|
|
56
|
+
NEVER_PAIRS = {
|
|
57
|
+
("git", "push"), ("git", "reset"), ("git", "clean"), ("git", "checkout"), ("git", "rebase"),
|
|
58
|
+
("git", "restore"), ("git", "stash"), ("git", "branch"), ("git", "config"), ("git", "remote"),
|
|
59
|
+
("npm", "install"), ("npm", "publish"), ("npm", "exec"), ("pip", "install"),
|
|
60
|
+
("uv", "run"), ("uv", "add"), ("uv", "publish"), ("gh", "api"), ("gh", "auth"),
|
|
61
|
+
} # fmt: skip
|
|
62
|
+
|
|
63
|
+
# Programs whose first argument is a subcommand; for any other, an argument is a file or a value.
|
|
64
|
+
SUBCOMMAND_PROGRAMS = {
|
|
65
|
+
"git", "npm", "pnpm", "yarn", "cargo", "go", "gh", "make", "uv", "pip", "pip3", "poetry",
|
|
66
|
+
"brew", "apt", "apt-get", "systemctl", "dotnet", "mvn", "gradle", "bundle", "rake",
|
|
67
|
+
} # fmt: skip
|
|
68
|
+
# Redirects that cannot write anywhere: stderr into stdout, or output thrown away.
|
|
69
|
+
_HARMLESS_REDIRECT = re.compile(r"\s\d?>&\d(?=\s|$)|\s\d?>>?\s*/dev/null(?=\s|$)")
|
|
70
|
+
_OPERATORS = {";", "&&", "||", "|", "&", "|&"}
|
|
71
|
+
_WORD = re.compile(r"^[A-Za-z][\w.+-]*$")
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
class Pattern(BaseModel):
|
|
75
|
+
pattern: str # e.g. "git status"; the rule is `Bash(git status:*)`
|
|
76
|
+
tier: Tier
|
|
77
|
+
uses: int
|
|
78
|
+
sessions: int
|
|
79
|
+
failures: int
|
|
80
|
+
denied: int
|
|
81
|
+
last_used: datetime
|
|
82
|
+
example: str # one redacted, shortened command
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
class Profile(BaseModel):
|
|
86
|
+
commands_seen: int
|
|
87
|
+
skipped_other_shell: int # PowerShell and the like: not matched by a Bash(...) rule
|
|
88
|
+
patterns: list[Pattern]
|
|
89
|
+
|
|
90
|
+
def tier(self, tier: Tier) -> list[Pattern]:
|
|
91
|
+
return [p for p in self.patterns if p.tier == tier]
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def split_command(command: str) -> list[list[str]] | None:
|
|
95
|
+
"""Simple commands joined by &&, ||, ; or |. None when it cannot be judged safely:
|
|
96
|
+
unbalanced quotes, command substitution, redirects, here-documents, env assignments."""
|
|
97
|
+
if "$(" in command or "`" in command or "<(" in command or ">(" in command:
|
|
98
|
+
return None
|
|
99
|
+
command = _HARMLESS_REDIRECT.sub("", command)
|
|
100
|
+
try:
|
|
101
|
+
lexer = shlex.shlex(command, posix=True, punctuation_chars=True)
|
|
102
|
+
lexer.whitespace_split = True
|
|
103
|
+
tokens = list(lexer)
|
|
104
|
+
except ValueError:
|
|
105
|
+
return None
|
|
106
|
+
parts: list[list[str]] = [[]]
|
|
107
|
+
for tok in tokens:
|
|
108
|
+
if tok in _OPERATORS:
|
|
109
|
+
parts.append([])
|
|
110
|
+
elif set(tok) <= set("<>&|;()") and tok:
|
|
111
|
+
return None # a redirect, subshell or odd operator
|
|
112
|
+
else:
|
|
113
|
+
parts[-1].append(tok)
|
|
114
|
+
parts = [p for p in parts if p]
|
|
115
|
+
if not parts or any("=" in p[0] and not p[0].startswith("-") for p in parts):
|
|
116
|
+
return None # empty, or FOO=bar cmd: the environment changes what the command does
|
|
117
|
+
return parts
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def classify(tokens: list[str]) -> tuple[str, Tier]:
|
|
121
|
+
"""(pattern, tier) for one simple command."""
|
|
122
|
+
best = max(
|
|
123
|
+
(p for p in SAFE_PREFIXES if tuple(tokens[: len(p)]) == p), key=len, default=None
|
|
124
|
+
)
|
|
125
|
+
if best is not None:
|
|
126
|
+
return " ".join(best), "safe"
|
|
127
|
+
program = tokens[0]
|
|
128
|
+
if "/" in program or "\\" in program or not _WORD.match(program):
|
|
129
|
+
return program, "never" # a path or odd name: not something to allow by prefix
|
|
130
|
+
takes_sub = program in SUBCOMMAND_PROGRAMS and len(tokens) > 1 and _WORD.match(tokens[1])
|
|
131
|
+
sub = tokens[1] if takes_sub else ""
|
|
132
|
+
pattern = f"{program} {sub}".strip()
|
|
133
|
+
if program.lower() in NEVER_PROGRAMS or (program, sub) in NEVER_PAIRS:
|
|
134
|
+
return pattern, "never"
|
|
135
|
+
return pattern, "review"
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
def build_profile(calls: list[ToolCall], min_uses: int = MIN_USES) -> Profile:
|
|
139
|
+
usable = [c for c in calls if c.tool in BASH_TOOLS]
|
|
140
|
+
seen: dict[tuple[str, Tier], list[tuple[ToolCall, str]]] = defaultdict(list)
|
|
141
|
+
for call in usable:
|
|
142
|
+
parts = split_command(call.command)
|
|
143
|
+
if parts is None:
|
|
144
|
+
continue
|
|
145
|
+
for tokens in parts:
|
|
146
|
+
pattern, tier = classify(tokens)
|
|
147
|
+
seen[(pattern, tier)].append((call, " ".join(tokens)))
|
|
148
|
+
patterns = []
|
|
149
|
+
for (pattern, tier), hits in seen.items():
|
|
150
|
+
if len(hits) < min_uses:
|
|
151
|
+
continue
|
|
152
|
+
calls_for = [c for c, _ in hits]
|
|
153
|
+
patterns.append(
|
|
154
|
+
Pattern(
|
|
155
|
+
pattern=pattern, tier=tier, uses=len(hits),
|
|
156
|
+
sessions=len({c.session_id for c in calls_for}),
|
|
157
|
+
failures=sum(c.failed for c in calls_for),
|
|
158
|
+
denied=sum(c.denied for c in calls_for),
|
|
159
|
+
last_used=max(c.ts for c in calls_for),
|
|
160
|
+
example=redact(hits[-1][1])[:100],
|
|
161
|
+
)
|
|
162
|
+
) # fmt: skip
|
|
163
|
+
patterns.sort(key=lambda p: (-p.uses, p.pattern))
|
|
164
|
+
return Profile(
|
|
165
|
+
commands_seen=len(usable), skipped_other_shell=len(calls) - len(usable), patterns=patterns
|
|
166
|
+
)
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
def allowable(profile: Profile, include_review: bool = False) -> list[Pattern]:
|
|
170
|
+
"""Patterns that may be written: never one the person refused, never the 'never' tier."""
|
|
171
|
+
tiers: set[Tier] = {"safe", "review"} if include_review else {"safe"}
|
|
172
|
+
return [p for p in profile.patterns if p.tier in tiers and p.denied == 0]
|
|
File without changes
|
|
@@ -0,0 +1,185 @@
|
|
|
1
|
+
"""Turn `git log` into uniform commit events. No interpretation beyond agent attribution."""
|
|
2
|
+
|
|
3
|
+
import re
|
|
4
|
+
import subprocess
|
|
5
|
+
from datetime import datetime
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
from pydantic import BaseModel
|
|
9
|
+
|
|
10
|
+
_AGENTS = "claude|codex|copilot|cursor|devin|aider|openhands|jules|gemini|sweep"
|
|
11
|
+
_AGENT_TRAILER = re.compile(rf"^co-authored-by:.*\b({_AGENTS})\b", re.I | re.M)
|
|
12
|
+
# A bot counts only when it names a coding agent: release and dependency bots are not agents.
|
|
13
|
+
_AGENT_AUTHOR = re.compile(
|
|
14
|
+
rf"(?:{_AGENTS})[\w.+-]*\[bot\]|noreply@anthropic\.com|\+(?:copilot|claude)@users\.noreply",
|
|
15
|
+
re.I,
|
|
16
|
+
)
|
|
17
|
+
_REC = "\x1e" # starts a commit record
|
|
18
|
+
_FLD = "\x1f" # separates header fields
|
|
19
|
+
_END = "\x1d" # ends the header; numstat and patch follow
|
|
20
|
+
MAX_PATCH_CHARS = 20_000
|
|
21
|
+
MAX_LINES_PER_FILE = 1000 # per direction; keeps huge generated files from dominating memory
|
|
22
|
+
MAX_LINE_CHARS = 200
|
|
23
|
+
# Never evidence of an agent mistake and often huge, so git does not even print them.
|
|
24
|
+
_EXCLUDE = [
|
|
25
|
+
f":(exclude,glob)**/{pattern}"
|
|
26
|
+
for pattern in (
|
|
27
|
+
"package-lock.json", "pnpm-lock.yaml", "yarn.lock", "Cargo.lock", "uv.lock",
|
|
28
|
+
"poetry.lock", "Gemfile.lock", "composer.lock", "go.sum", "*.lock", "*.min.js",
|
|
29
|
+
"*.min.css", "*.snap", "*.svg", "*.map",
|
|
30
|
+
)
|
|
31
|
+
] # fmt: skip
|
|
32
|
+
|
|
33
|
+
Range = tuple[int, int] # inclusive line range
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
class FileChange(BaseModel):
|
|
37
|
+
path: str
|
|
38
|
+
added: int
|
|
39
|
+
deleted: int
|
|
40
|
+
old_ranges: list[Range] = [] # lines removed or replaced, in the parent's numbering
|
|
41
|
+
new_ranges: list[Range] = [] # lines added or replaced, in this commit's numbering
|
|
42
|
+
added_lines: list[str] = [] # text of added lines (capped), untrusted
|
|
43
|
+
removed_lines: list[str] = [] # text of removed lines (capped), untrusted
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
class Commit(BaseModel):
|
|
47
|
+
sha: str
|
|
48
|
+
parents: list[str]
|
|
49
|
+
author_name: str
|
|
50
|
+
author_email: str
|
|
51
|
+
ts: datetime
|
|
52
|
+
subject: str
|
|
53
|
+
body: str
|
|
54
|
+
files: list[FileChange]
|
|
55
|
+
is_agent: bool
|
|
56
|
+
patch: str = "" # truncated unified diff, untrusted
|
|
57
|
+
|
|
58
|
+
@property
|
|
59
|
+
def added(self) -> int:
|
|
60
|
+
return sum(f.added for f in self.files)
|
|
61
|
+
|
|
62
|
+
@property
|
|
63
|
+
def deleted(self) -> int:
|
|
64
|
+
return sum(f.deleted for f in self.files)
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def is_agent_commit(author_name: str, author_email: str, body: str) -> bool:
|
|
68
|
+
author = f"{author_name} <{author_email}>"
|
|
69
|
+
return bool(_AGENT_TRAILER.search(body) or _AGENT_AUTHOR.search(author))
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def _git(repo: Path, *args: str) -> str:
|
|
73
|
+
return subprocess.run(
|
|
74
|
+
["git", "-C", str(repo), *args],
|
|
75
|
+
check=True,
|
|
76
|
+
capture_output=True,
|
|
77
|
+
text=True,
|
|
78
|
+
encoding="utf-8",
|
|
79
|
+
).stdout
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def read_commits(repo: Path, limit: int = 500) -> list[Commit]:
|
|
83
|
+
"""Return up to `limit` recent commits, oldest first."""
|
|
84
|
+
fmt = _FLD.join(["%H", "%P", "%an", "%ae", "%aI", "%s", "%b"]) + _END
|
|
85
|
+
out = _git(
|
|
86
|
+
repo, "log", f"-n{limit}", "--no-renames", "--numstat", "-p", "-U0",
|
|
87
|
+
f"--format={_REC}{fmt}", "--", ".", *_EXCLUDE,
|
|
88
|
+
) # fmt: skip
|
|
89
|
+
commits: list[Commit] = []
|
|
90
|
+
for rec in out.split(_REC)[1:]:
|
|
91
|
+
header, _, rest = rec.partition(_END)
|
|
92
|
+
sha, parents, name, email, ts, subject, body = header.split(_FLD)
|
|
93
|
+
body = body.strip()
|
|
94
|
+
stat, patch = _split_stat_patch(rest)
|
|
95
|
+
commits.append(
|
|
96
|
+
Commit(
|
|
97
|
+
sha=sha,
|
|
98
|
+
parents=parents.split(),
|
|
99
|
+
author_name=name,
|
|
100
|
+
author_email=email,
|
|
101
|
+
ts=datetime.fromisoformat(ts),
|
|
102
|
+
subject=subject,
|
|
103
|
+
body=body,
|
|
104
|
+
files=_parse_files(stat, patch),
|
|
105
|
+
is_agent=is_agent_commit(name, email, body),
|
|
106
|
+
patch=patch[:MAX_PATCH_CHARS],
|
|
107
|
+
)
|
|
108
|
+
)
|
|
109
|
+
commits.reverse()
|
|
110
|
+
return commits
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
_NUMSTAT = re.compile(r"^(\d+|-)\t(\d+|-)\t(.+)$")
|
|
114
|
+
_DIFF_HEADER = re.compile(r"^diff --git a/.+ b/(.+)$")
|
|
115
|
+
_HUNK = re.compile(r"^@@ -(\d+)(?:,(\d+))? \+(\d+)(?:,(\d+))? @@")
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def _split_stat_patch(text: str) -> tuple[str, str]:
|
|
119
|
+
idx = text.find("diff --git ")
|
|
120
|
+
if idx == -1:
|
|
121
|
+
return text, ""
|
|
122
|
+
return text[:idx], text[idx:]
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def _span(start: str, length: str | None) -> Range | None:
|
|
126
|
+
n = 1 if length is None else int(length)
|
|
127
|
+
s = int(start)
|
|
128
|
+
return (s, s + n - 1) if n > 0 else None
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
class _Hunks:
|
|
132
|
+
def __init__(self) -> None:
|
|
133
|
+
self.old: list[Range] = []
|
|
134
|
+
self.new: list[Range] = []
|
|
135
|
+
self.added: list[str] = []
|
|
136
|
+
self.removed: list[str] = []
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
def _parse_hunks(patch: str) -> dict[str, _Hunks]:
|
|
140
|
+
hunks: dict[str, _Hunks] = {}
|
|
141
|
+
current: _Hunks | None = None
|
|
142
|
+
for line in patch.splitlines():
|
|
143
|
+
m = _DIFF_HEADER.match(line)
|
|
144
|
+
if m:
|
|
145
|
+
current = hunks[m.group(1)] = _Hunks()
|
|
146
|
+
continue
|
|
147
|
+
if current is None:
|
|
148
|
+
continue
|
|
149
|
+
h = _HUNK.match(line)
|
|
150
|
+
if h:
|
|
151
|
+
old = _span(h.group(1), h.group(2))
|
|
152
|
+
new = _span(h.group(3), h.group(4))
|
|
153
|
+
if old:
|
|
154
|
+
current.old.append(old)
|
|
155
|
+
if new:
|
|
156
|
+
current.new.append(new)
|
|
157
|
+
elif line.startswith("+") and not line.startswith("+++"):
|
|
158
|
+
if len(current.added) < MAX_LINES_PER_FILE:
|
|
159
|
+
current.added.append(line[1:].strip()[:MAX_LINE_CHARS])
|
|
160
|
+
elif line.startswith("-") and not line.startswith("---"):
|
|
161
|
+
if len(current.removed) < MAX_LINES_PER_FILE:
|
|
162
|
+
current.removed.append(line[1:].strip()[:MAX_LINE_CHARS])
|
|
163
|
+
return hunks
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
def _parse_files(stat: str, patch: str) -> list[FileChange]:
|
|
167
|
+
hunks = _parse_hunks(patch)
|
|
168
|
+
changes = []
|
|
169
|
+
for line in stat.splitlines():
|
|
170
|
+
m = _NUMSTAT.match(line)
|
|
171
|
+
if m:
|
|
172
|
+
a, d, path = m.groups()
|
|
173
|
+
h = hunks.get(path) or _Hunks()
|
|
174
|
+
changes.append(
|
|
175
|
+
FileChange(
|
|
176
|
+
path=path,
|
|
177
|
+
added=0 if a == "-" else int(a),
|
|
178
|
+
deleted=0 if d == "-" else int(d),
|
|
179
|
+
old_ranges=h.old,
|
|
180
|
+
new_ranges=h.new,
|
|
181
|
+
added_lines=h.added,
|
|
182
|
+
removed_lines=h.removed,
|
|
183
|
+
)
|
|
184
|
+
)
|
|
185
|
+
return changes
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""PR review comments via the GitHub API. Optional: needs a GitHub token. Phase 2, not started."""
|
|
File without changes
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
"""Plugin interface for agent session-log readers."""
|
|
2
|
+
|
|
3
|
+
from datetime import datetime
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
from typing import Literal, Protocol
|
|
6
|
+
|
|
7
|
+
from pydantic import BaseModel
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class SessionMessage(BaseModel):
|
|
11
|
+
"""One human or agent text message. Text is untrusted data."""
|
|
12
|
+
|
|
13
|
+
session_id: str
|
|
14
|
+
uuid: str
|
|
15
|
+
role: Literal["user", "assistant"]
|
|
16
|
+
text: str
|
|
17
|
+
ts: datetime
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class SessionSource(Protocol):
|
|
21
|
+
"""Reads the session logs one agent keeps for a repo. Add a new agent by adding a reader."""
|
|
22
|
+
|
|
23
|
+
name: str
|
|
24
|
+
|
|
25
|
+
def read(self, repo: Path) -> list[SessionMessage]:
|
|
26
|
+
"""Messages for `repo`, ordered by time. Empty when the agent has no logs there."""
|
|
27
|
+
...
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class ToolCall(BaseModel):
|
|
31
|
+
"""One shell command an agent ran. The command is untrusted data and is never executed."""
|
|
32
|
+
|
|
33
|
+
session_id: str
|
|
34
|
+
agent: str # which reader produced it, e.g. "claude_code"
|
|
35
|
+
tool: str # "Bash" for Claude Code; the shell tool name for others
|
|
36
|
+
command: str
|
|
37
|
+
ts: datetime
|
|
38
|
+
failed: bool = False # the tool reported an error
|
|
39
|
+
denied: bool = False # the person refused it when asked
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
class ToolCallSource(Protocol):
|
|
43
|
+
"""Reads the commands one agent ran for a repo. Used by `guard`, not by `scan`."""
|
|
44
|
+
|
|
45
|
+
name: str
|
|
46
|
+
|
|
47
|
+
def read_calls(self, repo: Path) -> list[ToolCall]: ...
|
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
"""Claude Code transcript reader.
|
|
2
|
+
|
|
3
|
+
Transcripts live in ~/.claude/projects/<project-slug>/<session-id>.jsonl, one JSON object per
|
|
4
|
+
line. The slug is the project path with every non-alphanumeric character replaced by "-".
|
|
5
|
+
Records of type "user" and "assistant" carry `message.content` as a string or a list of blocks;
|
|
6
|
+
only "text" blocks are kept (tool calls and tool results are skipped). Unknown or malformed
|
|
7
|
+
lines are skipped so a format change degrades to fewer messages, not a crash.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
import json
|
|
11
|
+
import re
|
|
12
|
+
from datetime import datetime
|
|
13
|
+
from pathlib import Path
|
|
14
|
+
from typing import Any
|
|
15
|
+
|
|
16
|
+
from rulewright.ingest.sessions.base import SessionMessage, ToolCall
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def project_slug(repo: Path) -> str:
|
|
20
|
+
return re.sub(r"[^A-Za-z0-9]", "-", str(repo.resolve()))
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
DENIED_MARKERS = ("doesn't want to proceed", "was rejected", "user rejected")
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def _result_text(content: Any) -> str:
|
|
27
|
+
return content if isinstance(content, str) else _text_of(content)
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def _text_of(content: Any) -> str:
|
|
31
|
+
if isinstance(content, str):
|
|
32
|
+
return content
|
|
33
|
+
if isinstance(content, list):
|
|
34
|
+
parts = [
|
|
35
|
+
b.get("text", "") for b in content if isinstance(b, dict) and b.get("type") == "text"
|
|
36
|
+
]
|
|
37
|
+
return "\n".join(p for p in parts if isinstance(p, str))
|
|
38
|
+
return ""
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
class ClaudeCodeSource:
|
|
42
|
+
name = "claude_code"
|
|
43
|
+
|
|
44
|
+
def __init__(self, projects_dir: Path | None = None) -> None:
|
|
45
|
+
self.projects_dir = projects_dir or Path.home() / ".claude" / "projects"
|
|
46
|
+
|
|
47
|
+
def find_dir(self, repo: Path) -> Path | None:
|
|
48
|
+
slug = project_slug(repo).lower() # drive letter case differs between tools on Windows
|
|
49
|
+
if not self.projects_dir.is_dir():
|
|
50
|
+
return None
|
|
51
|
+
for d in self.projects_dir.iterdir():
|
|
52
|
+
if d.is_dir() and d.name.lower() == slug:
|
|
53
|
+
return d
|
|
54
|
+
return None
|
|
55
|
+
|
|
56
|
+
def read(self, repo: Path) -> list[SessionMessage]:
|
|
57
|
+
d = self.find_dir(repo)
|
|
58
|
+
if d is None:
|
|
59
|
+
return []
|
|
60
|
+
messages: list[SessionMessage] = []
|
|
61
|
+
for path in sorted(d.glob("*.jsonl")):
|
|
62
|
+
messages.extend(self._read_file(path))
|
|
63
|
+
messages.sort(key=lambda m: m.ts)
|
|
64
|
+
return messages
|
|
65
|
+
|
|
66
|
+
def _read_file(self, path: Path) -> list[SessionMessage]:
|
|
67
|
+
out: list[SessionMessage] = []
|
|
68
|
+
with path.open(encoding="utf-8", errors="replace") as fh:
|
|
69
|
+
for line in fh:
|
|
70
|
+
msg = self._parse(line, path.stem)
|
|
71
|
+
if msg is not None:
|
|
72
|
+
out.append(msg)
|
|
73
|
+
return out
|
|
74
|
+
|
|
75
|
+
@staticmethod
|
|
76
|
+
def _parse(line: str, default_session: str) -> SessionMessage | None:
|
|
77
|
+
try:
|
|
78
|
+
rec = json.loads(line)
|
|
79
|
+
if rec.get("type") not in ("user", "assistant") or rec.get("isSidechain"):
|
|
80
|
+
return None
|
|
81
|
+
if rec.get("isMeta"):
|
|
82
|
+
return None
|
|
83
|
+
text = _text_of(rec["message"].get("content")).strip()
|
|
84
|
+
if not text:
|
|
85
|
+
return None
|
|
86
|
+
return SessionMessage(
|
|
87
|
+
session_id=rec.get("sessionId", default_session),
|
|
88
|
+
uuid=rec["uuid"],
|
|
89
|
+
role=rec["type"],
|
|
90
|
+
text=text,
|
|
91
|
+
ts=datetime.fromisoformat(rec["timestamp"].replace("Z", "+00:00")),
|
|
92
|
+
)
|
|
93
|
+
except (ValueError, KeyError, TypeError, AttributeError):
|
|
94
|
+
return None
|
|
95
|
+
|
|
96
|
+
def read_calls(self, repo: Path) -> list[ToolCall]:
|
|
97
|
+
"""Bash commands with whether each failed or was refused (matched by tool_use_id)."""
|
|
98
|
+
d = self.find_dir(repo)
|
|
99
|
+
if d is None:
|
|
100
|
+
return []
|
|
101
|
+
calls: list[ToolCall] = []
|
|
102
|
+
for path in sorted(d.glob("*.jsonl")):
|
|
103
|
+
calls.extend(self._calls_in(path))
|
|
104
|
+
calls.sort(key=lambda c: c.ts)
|
|
105
|
+
return calls
|
|
106
|
+
|
|
107
|
+
@staticmethod
|
|
108
|
+
def _calls_in(path: Path) -> list[ToolCall]:
|
|
109
|
+
pending: dict[str, ToolCall] = {}
|
|
110
|
+
with path.open(encoding="utf-8", errors="replace") as fh:
|
|
111
|
+
for line in fh:
|
|
112
|
+
try:
|
|
113
|
+
rec = json.loads(line)
|
|
114
|
+
content = rec["message"]["content"]
|
|
115
|
+
if rec.get("isSidechain") or not isinstance(content, list):
|
|
116
|
+
continue
|
|
117
|
+
when = datetime.fromisoformat(rec["timestamp"].replace("Z", "+00:00"))
|
|
118
|
+
session = rec.get("sessionId", path.stem)
|
|
119
|
+
except (ValueError, KeyError, TypeError, AttributeError):
|
|
120
|
+
continue
|
|
121
|
+
for block in content:
|
|
122
|
+
if not isinstance(block, dict):
|
|
123
|
+
continue
|
|
124
|
+
kind = block.get("type")
|
|
125
|
+
if kind == "tool_use" and block.get("name") == "Bash":
|
|
126
|
+
command = (block.get("input") or {}).get("command")
|
|
127
|
+
if isinstance(command, str) and isinstance(block.get("id"), str):
|
|
128
|
+
pending[block["id"]] = ToolCall(
|
|
129
|
+
session_id=session, agent="claude_code", tool="Bash",
|
|
130
|
+
command=command, ts=when,
|
|
131
|
+
) # fmt: skip
|
|
132
|
+
elif kind == "tool_result" and block.get("tool_use_id") in pending:
|
|
133
|
+
call = pending[block["tool_use_id"]]
|
|
134
|
+
if block.get("is_error"):
|
|
135
|
+
text = _result_text(block.get("content")).lower()
|
|
136
|
+
call.denied = any(m in text for m in DENIED_MARKERS)
|
|
137
|
+
call.failed = not call.denied
|
|
138
|
+
return list(pending.values())
|