agentvow 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.
agentvow/__init__.py ADDED
@@ -0,0 +1,2 @@
1
+ """Agentvow: check an agent's claims against independently observed repo state."""
2
+ __version__ = "0.1.0"
agentvow/__main__.py ADDED
@@ -0,0 +1,3 @@
1
+ from .cli import run
2
+
3
+ raise SystemExit(run())
agentvow/adapters.py ADDED
@@ -0,0 +1,184 @@
1
+ """Agent-agnostic input: hook payloads and transcripts from different agents.
2
+
3
+ Verified against: Claude Code session files (used in this project). Everything else is built from vendor documentation or
4
+ tolerant format sniffing and is NOT yet verified against real Copilot/Codex/Cursor/Gemini transcripts: when a transcript
5
+ cannot be understood the caller gets None and reports "nothing was verified" (never a guess).
6
+ """
7
+ import json
8
+ from pathlib import Path
9
+
10
+ _TEXT_KEYS = ("text", "output_text", "content", "message", "last_assistant_message", "response")
11
+ _ASSIST = {"assistant", "model", "agent", "ai", "bot", "copilot"}
12
+ _ASSIST_TYPES = {"assistant", "assistant_message", "agent_message", "model", "model_response", "response", "agent_response"}
13
+
14
+
15
+ def parse_hook_payload(payload: dict) -> dict:
16
+ """Normalise camelCase (Copilot CLI/cloud agent), snake_case (VS Code, Claude Code, Codex) and Cursor-style hook inputs."""
17
+ g = lambda *ks: next((payload[k] for k in ks if payload.get(k)), None)
18
+ return {
19
+ "cwd": g("cwd", "workingDirectory", "workspace_root", "workspaceRoot") or (g("workspace_roots") or [None])[0]
20
+ if isinstance(g("workspace_roots"), list) or g("cwd", "workingDirectory", "workspace_root", "workspaceRoot") else None,
21
+ "transcript": g("transcript_path", "transcriptPath"),
22
+ "message": g("last_assistant_message", "lastAssistantMessage", "final_message", "text"),
23
+ "session": g("session_id", "sessionId", "conversation_id"),
24
+ "loop": bool(g("stop_hook_active", "stopHookActive")),
25
+ }
26
+
27
+
28
+ def _is_assistant(node: dict) -> bool:
29
+ role = str(node.get("role", "")).lower()
30
+ typ = str(node.get("type", "")).lower()
31
+ return role in _ASSIST or typ in _ASSIST_TYPES
32
+
33
+
34
+ MAX_DEPTH = 40
35
+
36
+
37
+ def _texts(node, assistant: bool, out: list, depth: int = 0):
38
+ """Collect text strings under assistant nodes (recursive; tolerant of unknown nesting; depth-bounded)."""
39
+ if depth > MAX_DEPTH:
40
+ return
41
+ if isinstance(node, dict):
42
+ a = assistant or _is_assistant(node)
43
+ for k, v in node.items():
44
+ if isinstance(v, str) and a and k in _TEXT_KEYS and v.strip() and not v.lstrip().startswith(("{", "[")):
45
+ if str(node.get("type", "")).lower() not in ("tool_use", "tool_result", "function_call", "tool_call", "thinking", "reasoning"):
46
+ out.append(v.strip())
47
+ elif isinstance(v, (dict, list)):
48
+ _texts(v, a, out, depth + 1)
49
+ elif isinstance(node, list):
50
+ for v in node:
51
+ _texts(v, assistant, out, depth + 1)
52
+
53
+
54
+ def _copilot_event_text(obj):
55
+ """GitHub Copilot CLI session events (~/.copilot/session-state/<id>/events.jsonl): {"type": "assistant.message", "data": {"content": "...",
56
+ "phase": "final_answer"|"commentary"|None, "toolRequests": [...]}}. Only data.content is the message; tool arguments (which can hold whole
57
+ files under a `content` key) must never be mistaken for it. -> (text, is_final) or None when this record is not an assistant message."""
58
+ if isinstance(obj, dict) and obj.get("type") == "assistant.message" and isinstance(obj.get("data"), dict):
59
+ d = obj["data"]
60
+ text = d.get("content")
61
+ return (text.strip() if isinstance(text, str) else ""), d.get("phase") == "final_answer"
62
+ return None
63
+
64
+
65
+ def final_message_info(raw: str):
66
+ """-> (text or None, is_final, is_copilot). Copilot CLI transcripts are scoped to the CURRENT turn (everything after the latest
67
+ user.message): when the final answer has not been flushed to the file yet this returns no text, never the previous turn's answer."""
68
+ last, last_final, parsed_any, copilot = None, None, False, False
69
+ for line in raw.splitlines():
70
+ line = line.strip()
71
+ if not line or line[0] not in "{[" or len(line) > 2_000_000:
72
+ continue
73
+ try:
74
+ obj = json.loads(line)
75
+ except (ValueError, RecursionError):
76
+ continue
77
+ parsed_any = True
78
+ if isinstance(obj, dict) and obj.get("type") == "user.message":
79
+ last = last_final = None # a new turn starts: nothing before this belongs to the answer we are looking for
80
+ copilot = True
81
+ continue
82
+ cop = _copilot_event_text(obj)
83
+ if cop is not None:
84
+ copilot = True
85
+ if cop[0]:
86
+ last = cop[0]
87
+ if cop[1]:
88
+ last_final = cop[0]
89
+ continue
90
+ found: list = []
91
+ _texts(obj, False, found)
92
+ if found:
93
+ last = "\n".join(found).strip() or last
94
+ if last_final:
95
+ return last_final, True, copilot
96
+ if not parsed_any:
97
+ try: # a single pretty-printed JSON document
98
+ obj = json.loads(raw[:5_000_000])
99
+ found = []
100
+ _texts(obj, False, found)
101
+ return (found[-1] if found else None), False, False
102
+ except (ValueError, RecursionError):
103
+ return (raw.strip()[-6000:] or None), False, False # plain text transcript (bounded)
104
+ return last, False, copilot
105
+
106
+
107
+ def final_message_from_text(raw: str):
108
+ return final_message_info(raw)[0]
109
+
110
+
111
+ MAX_TRANSCRIPT_BYTES = 8_000_000 # transcripts can be hundreds of MB; the final answer is at the end, so only the tail is read
112
+
113
+
114
+ def read_tail(path: Path, limit: int = MAX_TRANSCRIPT_BYTES) -> str:
115
+ """The last `limit` bytes of a regular file as text (first partial line dropped). Non-regular files (fifos, devices) are refused."""
116
+ import os, stat
117
+ fd = os.open(path, os.O_RDONLY | getattr(os, "O_NONBLOCK", 0))
118
+ try:
119
+ st = os.fstat(fd)
120
+ if not stat.S_ISREG(st.st_mode):
121
+ raise OSError("not a regular file")
122
+ cut = st.st_size > limit
123
+ if cut:
124
+ os.lseek(fd, st.st_size - limit, os.SEEK_SET)
125
+ data = b""
126
+ while len(data) < limit:
127
+ chunk = os.read(fd, min(1_000_000, limit - len(data)))
128
+ if not chunk:
129
+ break
130
+ data += chunk
131
+ finally:
132
+ os.close(fd)
133
+ text = data.decode("utf-8", errors="replace")
134
+ return text.split("\n", 1)[1] if cut and "\n" in text else text
135
+
136
+
137
+ def final_message_info_file(path: Path):
138
+ try:
139
+ return final_message_info(read_tail(path))
140
+ except OSError:
141
+ return None, False, False
142
+
143
+
144
+ def final_message(path: Path):
145
+ try:
146
+ return final_message_from_text(read_tail(path))
147
+ except OSError:
148
+ return None
149
+
150
+
151
+ def shape_of(node, depth: int = 0):
152
+ """The STRUCTURE of a JSON value (key names, types, string lengths), never its content: safe to share when debugging an unknown format."""
153
+ if depth > 6:
154
+ return "..."
155
+ if isinstance(node, dict):
156
+ return {str(k): shape_of(v, depth + 1) for k, v in list(node.items())[:40]}
157
+ if isinstance(node, list):
158
+ return [shape_of(node[0], depth + 1), f"... {len(node)} items"] if node else []
159
+ if isinstance(node, str):
160
+ return f"str[{len(node)}]"
161
+ return type(node).__name__
162
+
163
+
164
+ def transcript_shapes(path: Path, n: int = 4) -> dict:
165
+ """Structure of the first and last few records of a JSONL transcript (no content)."""
166
+ from collections import deque
167
+ head, tail = [], deque(maxlen=n)
168
+ try:
169
+ with path.open(encoding="utf-8", errors="replace") as fh:
170
+ for i, line in enumerate(fh):
171
+ line = line[:200_000]
172
+ if i < n:
173
+ head.append(line)
174
+ else:
175
+ tail.append(line)
176
+ except OSError as e:
177
+ return {"error": type(e).__name__}
178
+ shapes = []
179
+ for line in [*head, *tail]:
180
+ try:
181
+ shapes.append(shape_of(json.loads(line)))
182
+ except (ValueError, RecursionError):
183
+ shapes.append(f"non-json line, {len(line)} chars")
184
+ return {"records_sampled": len(shapes), "shapes": shapes}
agentvow/api_diff.py ADDED
@@ -0,0 +1,85 @@
1
+ """Public-API diff between two commits (Python, static): removed names and incompatible signature changes."""
2
+ import ast
3
+ import subprocess
4
+
5
+ from .reality import safe_prefix
6
+ from pathlib import Path
7
+
8
+
9
+ def _show(repo: Path, ref: str, path: str):
10
+ r = subprocess.run(["git", *safe_prefix(repo), "-C", str(repo), "show", f"{ref}:{path}"], capture_output=True, text=True)
11
+ return r.stdout if r.returncode == 0 else None
12
+
13
+
14
+ def _sig(fn):
15
+ a = fn.args
16
+ pos = [x.arg for x in a.posonlyargs + a.args]
17
+ req = pos[: len(pos) - len(a.defaults)]
18
+ kw = [x.arg for x in a.kwonlyargs]
19
+ kwreq = [x.arg for x, d in zip(a.kwonlyargs, a.kw_defaults) if d is None]
20
+ return {"pos": pos, "req": req, "kw": kw, "kwreq": kwreq, "var": a.vararg is not None, "varkw": a.kwarg is not None}
21
+
22
+
23
+ def public_api(source: str, is_init: bool = False) -> dict:
24
+ api = {}
25
+ for n in ast.parse(source).body:
26
+ if isinstance(n, (ast.FunctionDef, ast.AsyncFunctionDef)) and not n.name.startswith("_"):
27
+ api[n.name] = _sig(n)
28
+ elif isinstance(n, ast.ClassDef) and not n.name.startswith("_"):
29
+ api[n.name] = {"class": True}
30
+ for m in n.body:
31
+ if isinstance(m, (ast.FunctionDef, ast.AsyncFunctionDef)) and (not m.name.startswith("_") or m.name == "__init__"):
32
+ api[f"{n.name}.{m.name}"] = _sig(m)
33
+ elif isinstance(n, ast.AnnAssign) and isinstance(n.target, ast.Name) and not n.target.id.startswith("_"):
34
+ api[n.target.id] = {"const": True}
35
+ elif is_init and isinstance(n, ast.ImportFrom):
36
+ for a in n.names: # names re-exported from a package __init__ are part of its public API
37
+ nm = a.asname or a.name
38
+ if not nm.startswith("_") and nm != "*":
39
+ api.setdefault(nm, {"const": True})
40
+ elif isinstance(n, ast.Assign):
41
+ for t in n.targets:
42
+ if isinstance(t, ast.Name) and not t.id.startswith("_"):
43
+ api[t.id] = {"const": True}
44
+ return api
45
+
46
+
47
+ def breaking(old: dict, new: dict) -> list:
48
+ out = []
49
+ for name, o in old.items():
50
+ n = new.get(name)
51
+ if n is None:
52
+ out.append(f"removed: {name}")
53
+ elif "pos" in o and "pos" in n:
54
+ gone = [p for p in o["pos"] + o["kw"] if p not in n["pos"] + n["kw"] and not n["varkw"]]
55
+ added = [p for p in n["req"] if p not in o["req"]] + [p for p in n["kwreq"] if p not in o["kwreq"]]
56
+ order = o["pos"] != n["pos"][: len(o["pos"])] and not gone
57
+ if gone:
58
+ out.append(f"{name}: parameter(s) removed or renamed: {', '.join(gone)}")
59
+ if added:
60
+ out.append(f"{name}: new required parameter(s): {', '.join(added)}")
61
+ if order:
62
+ out.append(f"{name}: positional parameter order changed")
63
+ elif ("pos" in o) != ("pos" in n):
64
+ out.append(f"{name}: changed kind (function/class/constant)")
65
+ return out
66
+
67
+
68
+ def diff(repo: Path, base: str, files: list):
69
+ """Return (breaking_changes, gaps). Files that fail to parse are gaps, never silently OK."""
70
+ found, gaps = [], []
71
+ for f in files:
72
+ if not f.endswith(".py"):
73
+ continue
74
+ old = _show(repo, base, f)
75
+ if old is None:
76
+ continue # new file: nothing to break
77
+ try:
78
+ new_src = (repo / f).read_text(encoding="utf-8") if (repo / f).exists() else None
79
+ if new_src is None:
80
+ found.append(f"removed file: {f}")
81
+ continue
82
+ found += [f"{f}: {x}" for x in breaking(public_api(old, f.endswith('__init__.py')), public_api(new_src, f.endswith('__init__.py')))]
83
+ except (SyntaxError, UnicodeDecodeError, RecursionError, MemoryError, ValueError):
84
+ gaps.append(f"could not parse {f} for API comparison")
85
+ return found, gaps
agentvow/ci.py ADDED
@@ -0,0 +1,59 @@
1
+ """CI evidence: read the project's own check results for the exact commit through the GitHub CLI (read-only GETs).
2
+
3
+ Only jobs whose names look like test jobs count towards a "tests pass" claim; lint, security scans, title checks, release
4
+ automation and similar are reported but ignored. A commit with no CI yields no evidence (never a pass).
5
+ """
6
+ import json
7
+ import re
8
+ import time
9
+ import subprocess
10
+ from pathlib import Path
11
+
12
+ from .reality import safe_prefix, Evidence, Snapshot
13
+
14
+ # v2 (2026-10-07, post-hoc after the RQ4 audit: v1 had 91% precision, 59% recall on 60 blind-judged job names).
15
+ # Token-aware (underscores and digits do not hide a word), recognises python-version matrix names, excludes test.pypi.
16
+ TEST_JOB = re.compile(r"(?<![a-z])(?:tests?(?!\.pypi)|pytest|pytester|unit|unittests?|integration|tox|nox|specs?)(?![a-z])|^(?:python|py)[ -]?3\.\d+|^\U0001F40D", re.I)
17
+ TEST_JOB_V1 = re.compile(r"\b(?:tests?|pytest|unit|unittests?|integration|tox|nox|specs?)\b", re.I)
18
+ _IND = {"framing": "separate", "evidence": "separate", "mechanism": "separate", "authority": "unknown"} # the PR can change the CI configuration
19
+
20
+
21
+ def _gh(path: str):
22
+ r = subprocess.run(["gh", "api", path], capture_output=True, text=True)
23
+ return json.loads(r.stdout) if r.returncode == 0 and r.stdout.strip() else None
24
+
25
+
26
+ def _slug(repo: Path):
27
+ r = subprocess.run(["git", *safe_prefix(repo), "-C", str(repo), "remote", "get-url", "origin"], capture_output=True, text=True)
28
+ m = re.search(r"github\.com[:/]([\w.-]+/[\w.-]+?)(?:\.git)?$", r.stdout.strip())
29
+ return m.group(1) if m else None
30
+
31
+
32
+ def ci_evidence(repo: Path, snap: Snapshot, fetch=_gh, slug=None, sha=None, wait: int = 0, sleep=time.sleep) -> list:
33
+ """Evidence from CI for HEAD. Not available for uncommitted work (CI never saw it)."""
34
+ if snap.dirty and not sha:
35
+ return []
36
+ sha = sha or snap.commit
37
+ slug = slug or _slug(repo)
38
+ if not slug:
39
+ return []
40
+ deadline = time.time() + wait
41
+ while True:
42
+ data = fetch(f"repos/{slug}/commits/{sha}/check-runs?per_page=100") or {}
43
+ pending = [r for r in data.get("check_runs", []) if TEST_JOB.search(r.get("name", "")) and r.get("status") != "completed"]
44
+ if not pending or time.time() >= deadline: # test jobs still running right after a push: wait (bounded) instead of reporting "no result"
45
+ break
46
+ sleep(15)
47
+ if data.get("total_count", 0) > len(data.get("check_runs", [])):
48
+ return [] # truncated listing: refuse to draw conclusions from a partial view
49
+ out = []
50
+ for run in data.get("check_runs", []):
51
+ name, status, concl = run.get("name", ""), run.get("status"), run.get("conclusion")
52
+ if not TEST_JOB.search(name) or status != "completed":
53
+ continue
54
+ if concl in ("neutral", "skipped"):
55
+ continue # a skipped job proves nothing
56
+ ok = concl == "success"
57
+ out.append(Evidence(f"ci_{len(out)}", "ci_check", "github-checks", snap.commit, "", "VALID", _IND,
58
+ f"ci:{name} ({concl})", f"{'pass' if ok else 'fail'}: {name} {concl}"))
59
+ return out
agentvow/claims.py ADDED
@@ -0,0 +1,64 @@
1
+ """Rule-based claim extraction. No LLM: anything not matched is reported as unchecked."""
2
+ import re
3
+ from dataclasses import dataclass
4
+
5
+ NO_IMPACT = "no_downstream_impact"
6
+ TESTS_PASS = "tests_pass"
7
+ BACKCOMPAT = "backward_compatible"
8
+
9
+ _PATTERNS = [
10
+ (BACKCOMPAT, re.compile(r"\b(?:no|without|non)[- ]breaking (?:changes?|api)\b|\bbackward[s]?[- ]compatib|\bno api changes?\b|\bpublic api (?:is )?unchanged\b", re.I)),
11
+ (NO_IMPACT, re.compile(r"\bno (?:other |further )?(?:downstream |side )?(?:impact|effects?|consumers?|dependents?)\b|\bnothing else (?:depends|is affected)\b|\bdoes(?:n't| not) affect (?:any )?(?:other|downstream)", re.I)),
12
+ (TESTS_PASS, re.compile(r"\b(?:all )?tests? (?:are )?(?:pass(?:ed|ing)?|green)\b|\b\d+ passed\b|\b(\d[\d,]*)\s*/\s*\1\s+(?:tests?\s+)?(?:pass\w*|green)\b|\b\d[\d,]*\s+(?:[\w-]+\s+){0,2}tests?\b[^.\n]{0,80}\b(?:all )?(?:green|pass\w*|succe\w+)\b|\b(?:ran|run|running)\b[^.\n]{0,40}\b(?:tests?|pytest|test suite|suite)\b[^.\n]{0,60}\b(?:pass\w*|green|succe\w+)\b|\b(?:tests?|pytest|test suite|suite)\b[^.\n]{0,25}[:—-]\s*(?:all )?(?:pass\w*|green|ok|succe\w+)\b|\b(?:verified|confirmed)\b[^.\n]{0,60}\b(?:tests?|pytest|suite)\b[^.\n]{0,40}\b(?:pass\w*|green|succe\w+)\b", re.I)),
13
+ ]
14
+ _GENERIC = re.compile(r"\b(?:safe to merge|looks good|fully (?:tested|verified)|ready to ship|backward[- ]compatible)\b", re.I)
15
+
16
+
17
+ @dataclass
18
+ class Claim:
19
+ kind: str # NO_IMPACT, TESTS_PASS, or "unchecked"
20
+ text: str
21
+ count: int | None = None # number of tests the agent says passed, if stated
22
+
23
+
24
+ _RATIO = re.compile(r"\b(\d[\d,]*)\s*/\s*(\d[\d,]*)\s+(?:tests?\s+)?(?:pass\w*|green)\b", re.I)
25
+ _COUNT = re.compile(r"\b(\d[\d,]*)\s+(?:[\w-]+\s+){0,3}?tests?\s+(?:are\s+|were\s+|all\s+)?(?:pass(?:ed|ing)?|green)\b|\b(\d[\d,]*)\s+passed\b|\b(\d[\d,]*)\s+(?:[\w-]+\s+){0,2}tests?\b[^.\n]{0,80}\b(?:all )?(?:green|passing)\b", re.I)
26
+
27
+
28
+ _NEGATED = re.compile(r"\b(?:not|never|no longer|n't|unable to|failed to|couldn't|cannot|can't)\b[^.\n]{0,40}\b(?:confirm|verify|verified|run|ran|pass\w*|green)\b|\b(?:could|can) ?not\b", re.I)
29
+
30
+
31
+ def extract(transcript: str) -> list[Claim]:
32
+ claims: list[Claim] = []
33
+ transcript = transcript[-20000:] # bound the work done on agent-controlled text (the regexes are not linear-time on adversarial input)
34
+ for sentence in re.split(r"(?<=[.!?])\s+|\n+", transcript):
35
+ s = sentence.strip()[:600]
36
+ if not s:
37
+ continue
38
+ if _NEGATED.search(s):
39
+ claims.append(Claim("unchecked", s)) # a negated statement is not a claim of success; surface it as unchecked
40
+ continue
41
+ if re.search(r"\b[1-9]\d*\s+(?:failed|failing|failures?|errors?)\b", s, re.I):
42
+ claims.append(Claim("unchecked", s)) # an honest failure report is not a claim that tests pass
43
+ continue
44
+ matched = False
45
+ for kind, pat in _PATTERNS:
46
+ if pat.search(s):
47
+ r = _RATIO.search(s) if kind == TESTS_PASS else None
48
+ m = _COUNT.search(s) if kind == TESTS_PASS and not r else None
49
+ n = int(r.group(2).replace(",", "")) if r else (int(next(g for g in m.groups() if g).replace(",", "")) if m else None)
50
+ claims.append(Claim(kind, s, n))
51
+ matched = True
52
+ if not matched and _GENERIC.search(s):
53
+ claims.append(Claim("unchecked", s))
54
+ return claims
55
+
56
+
57
+ def sentences(text: str) -> list[str]:
58
+ return [x.strip() for x in re.split(r"(?<=[.!?])\s+|\n+", text) if x.strip()]
59
+
60
+
61
+ def count_unexamined(text: str, found: list) -> int:
62
+ """Substantive sentences (5+ words) that no extractor pattern looked at."""
63
+ seen = {c.text for c in found}
64
+ return sum(1 for x in sentences(text) if len(x.split()) >= 5 and x not in seen)