honestly 0.3.0__tar.gz

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.
honestly-0.3.0/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Jingheng Wu
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,128 @@
1
+ Metadata-Version: 2.4
2
+ Name: honestly
3
+ Version: 0.3.0
4
+ Summary: Audit how often your coding agent claims work it never verified. Reads Claude Code and Codex session logs. Deterministic rules, no LLM.
5
+ License-Expression: MIT
6
+ Project-URL: Homepage, https://github.com/kennywu411-dev/honestly
7
+ Project-URL: Repository, https://github.com/kennywu411-dev/honestly
8
+ Project-URL: Issues, https://github.com/kennywu411-dev/honestly/issues
9
+ Keywords: claude-code,codex,ai-agents,hooks,verification,developer-tools
10
+ Classifier: Development Status :: 4 - Beta
11
+ Classifier: Environment :: Console
12
+ Classifier: Intended Audience :: Developers
13
+ Classifier: Programming Language :: Python :: 3
14
+ Classifier: Topic :: Software Development :: Quality Assurance
15
+ Requires-Python: >=3.9
16
+ Description-Content-Type: text/markdown
17
+ License-File: LICENSE
18
+ Dynamic: license-file
19
+
20
+ # honestly
21
+
22
+ [![tests](https://github.com/kennywu411-dev/honestly/actions/workflows/tests.yml/badge.svg)](https://github.com/kennywu411-dev/honestly/actions/workflows/tests.yml) [![PyPI](https://img.shields.io/pypi/v/honestly)](https://pypi.org/project/honestly/) ![python](https://img.shields.io/badge/python-3.9%2B-blue) ![deps](https://img.shields.io/badge/dependencies-none-success)
23
+
24
+ **Coding agents report "tests pass" without always running them. `honestly` checks whether a matching test or build actually ran after the last code edit, and blocks the claim when it did not.**
25
+
26
+ ![honestly guard stopping a stale "tests pass" claim](assets/demo.gif)
27
+
28
+ Claude Code and Codex edit code, then report *tests pass*, *fixed*, *build ok*, *pushed*. Some of those reports were never run: the agent remembers an earlier pass, edits again, and repeats the old result as current. The reply looks the same either way, so the error surfaces later, in CI, on a colleague's machine or in production, where it costs hours instead of seconds. `honestly` moves the check back to the cheapest moment: before the sentence is sent.
29
+
30
+ ## Install the guard
31
+
32
+ ```bash
33
+ pip install honestly # Python 3.9+, standard library only, nothing leaves your machine
34
+ honestly guard install # adds two hooks to ~/.claude/settings.json (--dry-run shows them first)
35
+ ```
36
+
37
+ From then on, when the agent is about to end a turn with an unverified claim, this appears in the chat, in the language of the conversation:
38
+
39
+ ```
40
+ honestly stepped in: Claude Code (claude-opus-5-5) in project docpilot, answering "fix the login bug"
41
+ said "Fixed the login bug, all 12 tests pass" but ran no check after editing login.py.
42
+ It has been told to run one first.
43
+ ```
44
+
45
+ The agent then runs the check and starts its next reply by owning up: *"honestly caught me: I said tests pass before running them; I have now run them and the result is 12 passed."* If the check fails, it sees the failure and fixes the code. If no check can be run, it must rewrite the sentence as unverified. The second hook refuses a test or build command whose result is cut off by `| tail` or `| head`, since a failure would look like a pass.
46
+
47
+ | Hook | When | What it does |
48
+ |---|---|---|
49
+ | `Stop` | the agent is about to end its turn | The reply claims *tests pass / fixed / pushed …* but this turn shows no matching check after the last code edit: the turn is blocked once, with the sentence and the missing step. |
50
+ | `PreToolUse` (Bash) | before a command runs | A test or build piped through `tail`/`head` without `set -o pipefail` loses its exit code. The call is denied with the one-line fix. |
51
+
52
+ ```bash
53
+ honestly catches # what guard stopped in the last 7 days, by project and model
54
+ honestly guard status # or: uninstall (removes exactly the entries it added; a backup is kept)
55
+ HONESTLY_NOTIFY=1 # optional: also show each catch as a desktop notification
56
+ ```
57
+
58
+ If your project already has a `verify` skill or script, the Stop hook recognises its run as evidence; it does not fight your existing workflow.
59
+
60
+ ## Audit your history
61
+
62
+ ```bash
63
+ honestly audit # reads ~/.claude/projects (Claude Code) and ~/.codex/sessions (Codex)
64
+ honestly audit --since 30d # last 30 days only (also 8w, 3m)
65
+ honestly explain 3 # the full timeline behind the 3rd flagged claim: edit, tools after it, why
66
+ honestly share # writes a card to ~/.honestly/ (SVG, plus PNG when Chrome, Edge or rsvg-convert is present)
67
+ ```
68
+
69
+ ![honestly audit on the author's sessions](assets/hero.png)
70
+
71
+ ```
72
+ Verification rate: 91.6% (141 verified / 154 decided claims)
73
+ Sessions: 96 Claims about this turn's work: 176
74
+
75
+ verified 141 a matching check ran after the last code edit and its output agrees
76
+ unverified 13 the agent edited code, then claimed success without running a check
77
+ contradicted 0 the check it ran says otherwise
78
+ could not check 16 evidence is off-transcript (subagents, background, piped exit codes, remote runs)
79
+ weak 7 a visual fix judged by looking at a rendered image; not counted either way
80
+ ```
81
+
82
+ Those are the author's own numbers from 96 sessions, with every flag confirmed by a second, independent rater. A team that hand-labelled 357 "tests pass" claims from 150 Claude Code sessions found [80 with no run behind them](https://github.com/anthropics/claude-code/issues/104504). Benchmarks report the same on ML tasks ([57% of frontier-agent runs take a planted shortcut](https://arxiv.org/abs/2608.30724), and telling the agent not to barely helps). Light users will see few claims at first; the guard works from the first session regardless.
83
+
84
+ - **Deterministic.** Regex and transcript structure only. No LLM, no API key, nothing leaves your machine.
85
+ - **Precision first.** Anything it cannot see is `could not check`, never `unverified`. The flagged set was tuned over six rounds of double-blind labelling until two independent raters agreed with every flag ([`gate/REPORT.md`](gate/REPORT.md)).
86
+ - **Reads the output, not just the exit code.** `pytest -q | tail -3` returns tail's exit code; `honestly` reads the last `N passed / N failed` summary and reports `contradicted` when it disagrees with the sentence.
87
+
88
+ ## How a claim is judged
89
+
90
+ 1. **Scope: one turn.** Everything the agent did between your prompt and its reply.
91
+ 2. **Last code edit.** `Edit`/`Write` tools, or `sed -i`, `cat > file`, heredocs that write files. Edits to docs, logs, notes, memory, `.env`, temp dirs or `.agent/` state do not count.
92
+ 3. **Matching check after it.** Tests for *tests pass*; lint/type/build/check tools for *check passed*; render tools for *rendered*; `git push` / `kaggle … push` for *pushed*; tests, checks or renders for *fixed* (a generic script run is not evidence that a fix works).
93
+ 4. **Its output.** The last summary line decides: `12 passed` backs the claim, `2 failed` contradicts it, `fail 0` is a pass. Understands pytest, unittest, Jest/Vitest/Mocha, Go, Cargo, Maven/Gradle, .NET, tsc, ESLint, Ruff, Clippy, go vet and more. A narrowed run (`pytest -k`, one file) backs a claim only when the claim names the same count ("13 tests pass" ↔ `13 passed`).
94
+ 5. **Visual fixes.** "Fixed the overlap" followed by a render and a look at the screenshot is `weak`: shown separately, counted neither as verified nor unverified.
95
+ 6. **Everything invisible → `could not check`**: subagents, background tasks, your own terminal, piped commands with no summary in the output, remote runs (CI, Kaggle), syntax-only checks.
96
+ 7. **Not a claim:** plans ("I'll run the tests"), questions, quotes, code blocks, sentences reporting a previous turn.
97
+
98
+ Forked sessions are de-duplicated so the same turn is counted once.
99
+
100
+ ## Precision
101
+
102
+ Every rule change was followed by a labelling round: the author and an independent Claude agent (fresh context, no access to the author's labels) each judged every flagged claim.
103
+
104
+ | Round | Flagged `unverified` | Author precision | Independent precision | What was fixed next |
105
+ |---|---|---|---|---|
106
+ | 1 | 38 | 56% | 69% | doc/log/memory edits counted as code edits; versioned `hyperframes@x check` missed |
107
+ | 2 | 22 | 53% | 52% | variable paths, `.agent/state.json`, heredoc bodies; judge output by the last summary line |
108
+ | 6 | 12 | 12/12 | 11/11 (+1 unsure) | quote-aware multi-line splitting; writing heredocs never count as verification |
109
+
110
+ Rounds 3–5 were intermediate fixes (heredoc parsing, variable paths, remote-run claims) and are listed in the report. The independent rater is a Claude agent in a fresh context with no access to the author's labels; it is still a model, not a human. A human second rater is the obvious next step, and the labelling sheets are available on request.
111
+
112
+ **Recall.** Two raters (author + independent agent) then labelled 66 claims the audit had *not* flagged. They found 2 and 3 misses respectively, 5 distinct: an experiment runner mistaken for a test, a "background" sentence mistaken for a remote run, a commit claim with no commit after the last edit, a test claim backed only by a background job, and a progress list whose check predated the edit. Four of the five are fixed in 0.2.0. Before the fixes the recall estimate on that sample was roughly 55–75% depending on the rater; the remaining known miss is the progress-list case. `honestly` still prefers `could not check` over a wrong flag, and the full labelling material is described in [`gate/REPORT.md`](gate/REPORT.md).
113
+
114
+ ## What it is not
115
+
116
+ - Not a judge of *correctness*. A verified claim means the check ran after the edit and its output agreed with the sentence; it does not mean the code is right.
117
+ - Not a leaderboard of models. The per-model table is your sessions only.
118
+ - Not Anthropic's or OpenAI's. Independent, MIT, unaffiliated.
119
+ - The guard is Claude Code only: Codex has no equivalent stop-before-reply hook, so Codex sessions can be audited but not intercepted.
120
+ - Not a judge of test *adequacy*. It checks that a matching check ran after the edit and what its output said, not whether that check covers the lines that changed.
121
+ - Subagent and background runs are reported as `could not check`; they are not read yet.
122
+ - Tested on Linux, macOS and Windows (CI), Python 3.9–3.13. Rules were tuned on one developer's sessions (Python, JS, video tooling, Kaggle) and then against two external testers' logs, which is where the first false flags came from (an English bibliography title containing "fixed bed reactor"). Other stacks are covered by synthetic tests only; expect to report a false flag or two, and they are fixed the same day. Previously published as `sober-cli`.
123
+
124
+ ## Related work
125
+
126
+ [claude-guard-hooks](https://github.com/jefftanlearn-eng/claude-guard-hooks) and [groundtruth](https://github.com/vnmoorthy/groundtruth) block unbacked "done" claims with keyword matching; [superpowers](https://github.com/obra/superpowers) asks for verification in a skill (prompt-level). `honestly` differs in reading command *output*, scoring your history across agents and models, and refusing to count what it cannot see.
127
+
128
+ 中文说明见 [README.zh-CN.md](README.zh-CN.md)。
@@ -0,0 +1,109 @@
1
+ # honestly
2
+
3
+ [![tests](https://github.com/kennywu411-dev/honestly/actions/workflows/tests.yml/badge.svg)](https://github.com/kennywu411-dev/honestly/actions/workflows/tests.yml) [![PyPI](https://img.shields.io/pypi/v/honestly)](https://pypi.org/project/honestly/) ![python](https://img.shields.io/badge/python-3.9%2B-blue) ![deps](https://img.shields.io/badge/dependencies-none-success)
4
+
5
+ **Coding agents report "tests pass" without always running them. `honestly` checks whether a matching test or build actually ran after the last code edit, and blocks the claim when it did not.**
6
+
7
+ ![honestly guard stopping a stale "tests pass" claim](assets/demo.gif)
8
+
9
+ Claude Code and Codex edit code, then report *tests pass*, *fixed*, *build ok*, *pushed*. Some of those reports were never run: the agent remembers an earlier pass, edits again, and repeats the old result as current. The reply looks the same either way, so the error surfaces later, in CI, on a colleague's machine or in production, where it costs hours instead of seconds. `honestly` moves the check back to the cheapest moment: before the sentence is sent.
10
+
11
+ ## Install the guard
12
+
13
+ ```bash
14
+ pip install honestly # Python 3.9+, standard library only, nothing leaves your machine
15
+ honestly guard install # adds two hooks to ~/.claude/settings.json (--dry-run shows them first)
16
+ ```
17
+
18
+ From then on, when the agent is about to end a turn with an unverified claim, this appears in the chat, in the language of the conversation:
19
+
20
+ ```
21
+ honestly stepped in: Claude Code (claude-opus-5-5) in project docpilot, answering "fix the login bug"
22
+ said "Fixed the login bug, all 12 tests pass" but ran no check after editing login.py.
23
+ It has been told to run one first.
24
+ ```
25
+
26
+ The agent then runs the check and starts its next reply by owning up: *"honestly caught me: I said tests pass before running them; I have now run them and the result is 12 passed."* If the check fails, it sees the failure and fixes the code. If no check can be run, it must rewrite the sentence as unverified. The second hook refuses a test or build command whose result is cut off by `| tail` or `| head`, since a failure would look like a pass.
27
+
28
+ | Hook | When | What it does |
29
+ |---|---|---|
30
+ | `Stop` | the agent is about to end its turn | The reply claims *tests pass / fixed / pushed …* but this turn shows no matching check after the last code edit: the turn is blocked once, with the sentence and the missing step. |
31
+ | `PreToolUse` (Bash) | before a command runs | A test or build piped through `tail`/`head` without `set -o pipefail` loses its exit code. The call is denied with the one-line fix. |
32
+
33
+ ```bash
34
+ honestly catches # what guard stopped in the last 7 days, by project and model
35
+ honestly guard status # or: uninstall (removes exactly the entries it added; a backup is kept)
36
+ HONESTLY_NOTIFY=1 # optional: also show each catch as a desktop notification
37
+ ```
38
+
39
+ If your project already has a `verify` skill or script, the Stop hook recognises its run as evidence; it does not fight your existing workflow.
40
+
41
+ ## Audit your history
42
+
43
+ ```bash
44
+ honestly audit # reads ~/.claude/projects (Claude Code) and ~/.codex/sessions (Codex)
45
+ honestly audit --since 30d # last 30 days only (also 8w, 3m)
46
+ honestly explain 3 # the full timeline behind the 3rd flagged claim: edit, tools after it, why
47
+ honestly share # writes a card to ~/.honestly/ (SVG, plus PNG when Chrome, Edge or rsvg-convert is present)
48
+ ```
49
+
50
+ ![honestly audit on the author's sessions](assets/hero.png)
51
+
52
+ ```
53
+ Verification rate: 91.6% (141 verified / 154 decided claims)
54
+ Sessions: 96 Claims about this turn's work: 176
55
+
56
+ verified 141 a matching check ran after the last code edit and its output agrees
57
+ unverified 13 the agent edited code, then claimed success without running a check
58
+ contradicted 0 the check it ran says otherwise
59
+ could not check 16 evidence is off-transcript (subagents, background, piped exit codes, remote runs)
60
+ weak 7 a visual fix judged by looking at a rendered image; not counted either way
61
+ ```
62
+
63
+ Those are the author's own numbers from 96 sessions, with every flag confirmed by a second, independent rater. A team that hand-labelled 357 "tests pass" claims from 150 Claude Code sessions found [80 with no run behind them](https://github.com/anthropics/claude-code/issues/104504). Benchmarks report the same on ML tasks ([57% of frontier-agent runs take a planted shortcut](https://arxiv.org/abs/2608.30724), and telling the agent not to barely helps). Light users will see few claims at first; the guard works from the first session regardless.
64
+
65
+ - **Deterministic.** Regex and transcript structure only. No LLM, no API key, nothing leaves your machine.
66
+ - **Precision first.** Anything it cannot see is `could not check`, never `unverified`. The flagged set was tuned over six rounds of double-blind labelling until two independent raters agreed with every flag ([`gate/REPORT.md`](gate/REPORT.md)).
67
+ - **Reads the output, not just the exit code.** `pytest -q | tail -3` returns tail's exit code; `honestly` reads the last `N passed / N failed` summary and reports `contradicted` when it disagrees with the sentence.
68
+
69
+ ## How a claim is judged
70
+
71
+ 1. **Scope: one turn.** Everything the agent did between your prompt and its reply.
72
+ 2. **Last code edit.** `Edit`/`Write` tools, or `sed -i`, `cat > file`, heredocs that write files. Edits to docs, logs, notes, memory, `.env`, temp dirs or `.agent/` state do not count.
73
+ 3. **Matching check after it.** Tests for *tests pass*; lint/type/build/check tools for *check passed*; render tools for *rendered*; `git push` / `kaggle … push` for *pushed*; tests, checks or renders for *fixed* (a generic script run is not evidence that a fix works).
74
+ 4. **Its output.** The last summary line decides: `12 passed` backs the claim, `2 failed` contradicts it, `fail 0` is a pass. Understands pytest, unittest, Jest/Vitest/Mocha, Go, Cargo, Maven/Gradle, .NET, tsc, ESLint, Ruff, Clippy, go vet and more. A narrowed run (`pytest -k`, one file) backs a claim only when the claim names the same count ("13 tests pass" ↔ `13 passed`).
75
+ 5. **Visual fixes.** "Fixed the overlap" followed by a render and a look at the screenshot is `weak`: shown separately, counted neither as verified nor unverified.
76
+ 6. **Everything invisible → `could not check`**: subagents, background tasks, your own terminal, piped commands with no summary in the output, remote runs (CI, Kaggle), syntax-only checks.
77
+ 7. **Not a claim:** plans ("I'll run the tests"), questions, quotes, code blocks, sentences reporting a previous turn.
78
+
79
+ Forked sessions are de-duplicated so the same turn is counted once.
80
+
81
+ ## Precision
82
+
83
+ Every rule change was followed by a labelling round: the author and an independent Claude agent (fresh context, no access to the author's labels) each judged every flagged claim.
84
+
85
+ | Round | Flagged `unverified` | Author precision | Independent precision | What was fixed next |
86
+ |---|---|---|---|---|
87
+ | 1 | 38 | 56% | 69% | doc/log/memory edits counted as code edits; versioned `hyperframes@x check` missed |
88
+ | 2 | 22 | 53% | 52% | variable paths, `.agent/state.json`, heredoc bodies; judge output by the last summary line |
89
+ | 6 | 12 | 12/12 | 11/11 (+1 unsure) | quote-aware multi-line splitting; writing heredocs never count as verification |
90
+
91
+ Rounds 3–5 were intermediate fixes (heredoc parsing, variable paths, remote-run claims) and are listed in the report. The independent rater is a Claude agent in a fresh context with no access to the author's labels; it is still a model, not a human. A human second rater is the obvious next step, and the labelling sheets are available on request.
92
+
93
+ **Recall.** Two raters (author + independent agent) then labelled 66 claims the audit had *not* flagged. They found 2 and 3 misses respectively, 5 distinct: an experiment runner mistaken for a test, a "background" sentence mistaken for a remote run, a commit claim with no commit after the last edit, a test claim backed only by a background job, and a progress list whose check predated the edit. Four of the five are fixed in 0.2.0. Before the fixes the recall estimate on that sample was roughly 55–75% depending on the rater; the remaining known miss is the progress-list case. `honestly` still prefers `could not check` over a wrong flag, and the full labelling material is described in [`gate/REPORT.md`](gate/REPORT.md).
94
+
95
+ ## What it is not
96
+
97
+ - Not a judge of *correctness*. A verified claim means the check ran after the edit and its output agreed with the sentence; it does not mean the code is right.
98
+ - Not a leaderboard of models. The per-model table is your sessions only.
99
+ - Not Anthropic's or OpenAI's. Independent, MIT, unaffiliated.
100
+ - The guard is Claude Code only: Codex has no equivalent stop-before-reply hook, so Codex sessions can be audited but not intercepted.
101
+ - Not a judge of test *adequacy*. It checks that a matching check ran after the edit and what its output said, not whether that check covers the lines that changed.
102
+ - Subagent and background runs are reported as `could not check`; they are not read yet.
103
+ - Tested on Linux, macOS and Windows (CI), Python 3.9–3.13. Rules were tuned on one developer's sessions (Python, JS, video tooling, Kaggle) and then against two external testers' logs, which is where the first false flags came from (an English bibliography title containing "fixed bed reactor"). Other stacks are covered by synthetic tests only; expect to report a false flag or two, and they are fixed the same day. Previously published as `sober-cli`.
104
+
105
+ ## Related work
106
+
107
+ [claude-guard-hooks](https://github.com/jefftanlearn-eng/claude-guard-hooks) and [groundtruth](https://github.com/vnmoorthy/groundtruth) block unbacked "done" claims with keyword matching; [superpowers](https://github.com/obra/superpowers) asks for verification in a skill (prompt-level). `honestly` differs in reading command *output*, scoring your history across agents and models, and refusing to count what it cannot see.
108
+
109
+ 中文说明见 [README.zh-CN.md](README.zh-CN.md)。
@@ -0,0 +1,32 @@
1
+ [build-system]
2
+ requires = ["setuptools>=68"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ name = "honestly"
7
+ version = "0.3.0"
8
+ description = "Audit how often your coding agent claims work it never verified. Reads Claude Code and Codex session logs. Deterministic rules, no LLM."
9
+ readme = "README.md"
10
+ requires-python = ">=3.9"
11
+ license = "MIT"
12
+ license-files = ["LICENSE"]
13
+ dependencies = []
14
+ keywords = ["claude-code", "codex", "ai-agents", "hooks", "verification", "developer-tools"]
15
+ classifiers = [
16
+ "Development Status :: 4 - Beta",
17
+ "Environment :: Console",
18
+ "Intended Audience :: Developers",
19
+ "Programming Language :: Python :: 3",
20
+ "Topic :: Software Development :: Quality Assurance",
21
+ ]
22
+
23
+ [project.urls]
24
+ Homepage = "https://github.com/kennywu411-dev/honestly"
25
+ Repository = "https://github.com/kennywu411-dev/honestly"
26
+ Issues = "https://github.com/kennywu411-dev/honestly/issues"
27
+
28
+ [project.scripts]
29
+ honestly = "honestly.cli:main"
30
+
31
+ [tool.setuptools.packages.find]
32
+ where = ["src"]
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -0,0 +1,2 @@
1
+ """honestly: audit how often your coding agent claims work it never verified."""
2
+ __version__ = "0.3.0"
@@ -0,0 +1,162 @@
1
+ """Core audit: for each claim, decide backed / unbacked / unverifiable."""
2
+ from __future__ import annotations
3
+ import re
4
+ from dataclasses import dataclass, asdict
5
+ from . import rules
6
+ from .claude_code import Turn, Event
7
+
8
+
9
+ @dataclass
10
+ class Finding:
11
+ session: str
12
+ turn: int
13
+ version: str
14
+ agent: str
15
+ model: str
16
+ ts: str
17
+ category: str
18
+ verdict: str # backed | unbacked | weak | unverifiable | contradicted | not_this_turn | out_of_scope
19
+ reason: str
20
+ claim: str
21
+ last_edit: str
22
+ tools_after_edit: list
23
+ context: str
24
+ verified_by: str = ""
25
+ output: str = ""
26
+ project: str = ""
27
+
28
+ @property
29
+ def label(self) -> str:
30
+ return LABELS.get(self.verdict, self.verdict)
31
+
32
+
33
+ LABELS = {"backed": "verified", "unbacked": "unverified", "unverifiable": "could not check", "contradicted": "contradicted",
34
+ "weak": "weak", "not_this_turn": "no edit this turn", "out_of_scope": "not audited"}
35
+
36
+
37
+ def _is_edit(e: Event) -> bool:
38
+ if e.kind != "tool":
39
+ return False
40
+ if e.name in rules.EDIT_TOOLS:
41
+ return bool(e.text.strip()) and not rules.path_is_doc(e.text) # an Edit with no path is not evidence of a code edit
42
+ return e.name == "Bash" and rules.bash_is_edit(e.text)
43
+
44
+
45
+ TEST_PASS = re.compile(r"\b(\d+) passed\b|\bOK\b(?! to)|tests? passed|\bPASS(ED)?\b|✓|全部通过|fail(ed|ures)?[: ]+0\b|\b0 failed|\bpass \d+"
46
+ r"|test result: ok|\b\d+ passing\b|Tests run: \d+, Failures: 0, Errors: 0|Passed!|^ok\s+\S+|All checks passed|No issues found|\b0 errors?\b", re.I | re.M)
47
+ TEST_FAIL = re.compile(r"\b[1-9]\d* failed\b|\bFAILED\b|^ERROR:|\bERROR\b(?! 0)|Traceback|AssertionError|\berror\[|errors? occurred|npm ERR|\bFAIL\b(?![: ]+0)|fail(ed|ures)?[: ]+[1-9]"
48
+ r"|test result: FAILED|\b[1-9]\d* failing\b|Failures: [1-9]|Errors: [1-9]|Failed: [1-9]|^FAIL\s|panic:|build failed|compilation failed|error TS\d+|\[error\]", re.I | re.M)
49
+ SUMMARY_LINE = re.compile(r"(\b\d+ (passed|failed|error)s?\b[^\n]*|\bRan \d+ tests?[^\n]*\n?\s*(OK|FAILED)[^\n]*|ℹ (pass|fail) \d+[^\n]*|Tests:\s+[^\n]*|test result: [^\n]*|\b\d+ (passing|failing)\b[^\n]*|Tests run: [^\n]*|Passed!|Failed![^\n]*)", re.I)
50
+ IMAGE_READ = re.compile(r"\.(png|jpe?g|gif|webp)$", re.I)
51
+ VISUAL_CMD = re.compile(r"(snapshot|screenshot|contact-sheet|ffmpeg|ffprobe|hyperframes(@[\w.]+)? (render|build)|blender )", re.I)
52
+
53
+
54
+ def _last_summary(out: str) -> str:
55
+ """The last pass/fail summary in the output decides; earlier runs in the same command are history."""
56
+ hits = [m.group(0) for m in SUMMARY_LINE.finditer(out)]
57
+ if not hits:
58
+ return out
59
+ # a pytest summary like "2 failed, 17 passed" spans one line: take everything from the last summary start
60
+ tail = out[out.rfind(hits[-1]):]
61
+ return tail[:400]
62
+ PUSH_FAIL = re.compile(r"(rejected|fatal:|error:|denied|failed to push)", re.I)
63
+
64
+
65
+ def judge_output(category: str, seg: str, ev: Event, claim_text: str = ""):
66
+ """Decide from the verifying command's own output; fall back to exit status / pipes."""
67
+ out = ev.result or ""
68
+ if re.search(r"running in background|Output is being written to", out, re.I):
69
+ return "unverifiable", "verification was started in the background; its result is off-transcript"
70
+ if category == "fixed" and re.search(r"(node --check|py_compile|python3? -c ['\"]import )", seg):
71
+ return "unverifiable", "only a syntax check ran after the fix"
72
+ if category in ("tests_pass", "checks_pass") or (category == "fixed" and rules.VERIFY["test"].search(seg)):
73
+ summ = _last_summary(out)
74
+ if TEST_FAIL.search(summ) and not TEST_PASS.search(summ):
75
+ return "contradicted", "last test summary shows failures"
76
+ if TEST_FAIL.search(summ) and TEST_PASS.search(summ) and re.search(r"\b[1-9]\d* failed", summ):
77
+ return "contradicted", "last test summary shows failures alongside passes"
78
+ if TEST_PASS.search(summ) or (category == "checks_pass" and not TEST_FAIL.search(out) and not ev.is_error):
79
+ if rules.NARROWED_TEST.search(seg) and category == "tests_pass":
80
+ claimed = re.search(r"(\d+)\s*(个|项)?\s*(测试|用例|tests?)", claim_text or "")
81
+ passed = re.search(r"\b(\d+) passed\b", summ)
82
+ if claimed and passed and claimed.group(1) == passed.group(1):
83
+ return "backed", f"narrowed run matches the claimed count ({passed.group(1)} passed): {seg[:60]}"
84
+ return "unverifiable", "narrowed test run reported as passing"
85
+ return "backed", f"test output confirms: {seg[:80]}"
86
+ if rules.PIPE_SWALLOW.search(seg) and "pipefail" not in seg:
87
+ return "unverifiable", "test piped through tail/head and output shows no pass/fail line"
88
+ if ev.is_error:
89
+ return "unbacked", "test command errored"
90
+ return "unverifiable", "test ran but output shows no pass/fail line"
91
+ if category == "pushed":
92
+ if PUSH_FAIL.search(out):
93
+ return "contradicted", "push output shows an error"
94
+ return ("unbacked", "push command errored") if ev.is_error else ("backed", f"push confirmed: {seg[:80]}")
95
+ if ev.is_error:
96
+ return "unbacked", f"verification command errored: {seg[:60]}"
97
+ if category in ("build_ok", "rendered") and re.search(r"(Traceback|\berror\b|fatal)", out, re.I) and not re.search(r"0 errors?|no errors?", out, re.I):
98
+ return "contradicted", "verification output contains errors"
99
+ if rules.PIPE_SWALLOW.search(seg) and "pipefail" not in seg and not out.strip():
100
+ return "unverifiable", "piped verification with empty output"
101
+ return "backed", f"verified by: {seg[:80]}"
102
+
103
+
104
+ def audit_turn(turn: Turn):
105
+ out = []
106
+ ev = turn.events
107
+ for i, e in enumerate(ev):
108
+ if e.kind != "text":
109
+ continue
110
+ for hit in rules.find_claims(e.text):
111
+ before = ev[:i]
112
+ edit_idx = max((j for j, x in enumerate(before) if _is_edit(x)), default=-1)
113
+ after = [x for x in before[edit_idx + 1:] if x.kind == "tool"] if edit_idx >= 0 else []
114
+ tools_after = [f"{x.name}: {x.text[:240]}" + (" [ERR]" if x.is_error else "") for x in after]
115
+ last_edit = f"{ev[edit_idx].name}: {ev[edit_idx].text[:400]}" if edit_idx >= 0 else ""
116
+ ctx_start = max(0, e.text.find(hit.sentence) - 150)
117
+ context = e.text[ctx_start: ctx_start + 150 + len(hit.sentence) + 150].replace("\n", " ")
118
+ if not hit.evidence_kinds:
119
+ verdict, reason = "out_of_scope", "fuzzy category not audited in v1"
120
+ elif edit_idx < 0:
121
+ verdict, reason = "not_this_turn", "no code edit in this turn before the claim"
122
+ elif any(x.name in rules.OPAQUE_TOOLS for x in after):
123
+ verdict, reason = "unverifiable", "a subagent/background/terminal tool ran after the last edit"
124
+ elif rules.REMOTE_RUN.search(hit.sentence):
125
+ verdict, reason = "unverifiable", "claim is about a remote or background run; local edits do not invalidate it and its evidence is off-transcript"
126
+ else:
127
+ verifying = [(x, rules.verifying_segments(x.text, hit.evidence_kinds)) for x in after if x.name == "Bash"]
128
+ verifying = [(x, segs) for x, segs in verifying if segs]
129
+ edit_ev = ev[edit_idx]
130
+ if not verifying and edit_ev.name == "Bash":
131
+ segs = rules.verifying_segments(edit_ev.text, hit.evidence_kinds, after_edit_only=True)
132
+ if segs:
133
+ verifying = [(edit_ev, segs)]
134
+ if not verifying and hit.category in ("fixed", "rendered", "checks_pass") and any(
135
+ (x.name == "Read" and IMAGE_READ.search(x.text)) or (x.name == "Bash" and VISUAL_CMD.search(x.text)) for x in after):
136
+ verdict, reason = "weak", "judged by looking at a rendered image or snapshot after the edit; not checkable mechanically"
137
+ elif not verifying:
138
+ verdict, reason = "unbacked", "no verification command after the last edit"
139
+ else:
140
+ last, segs = verifying[-1]
141
+ seg = segs[-1].text
142
+ verdict, reason = judge_output(hit.category, seg, last, hit.sentence)
143
+ vb = locals().get("seg", "") if verdict in ("backed", "unverifiable", "contradicted") and edit_idx >= 0 else ""
144
+ out_snip = (locals().get("last").result[:300].replace("\n", " ") if vb and locals().get("last") else "")
145
+ out.append(Finding(turn.session, turn.index, turn.version, turn.agent, turn.model, e.ts, hit.category, verdict, reason,
146
+ hit.sentence[:300], last_edit, tools_after[-8:], context[:600], vb[:200], out_snip, getattr(turn, "project", "")))
147
+ seg = ""; last = None
148
+ return out
149
+
150
+
151
+ def audit_turns(turns):
152
+ findings = []
153
+ for t in turns:
154
+ findings.extend(audit_turn(t))
155
+ return findings
156
+
157
+
158
+ def summarize(findings):
159
+ from collections import Counter
160
+ c = Counter(f.verdict for f in findings)
161
+ cat = Counter((f.category, f.verdict) for f in findings)
162
+ return {"total_claims": len(findings), "by_verdict": dict(c), "by_category": {f"{k[0]}/{k[1]}": v for k, v in cat.items()}}
@@ -0,0 +1,116 @@
1
+ """Parse Claude Code session logs (~/.claude/projects/**/*.jsonl) into turns.
2
+
3
+ A turn = everything the assistant did between two human prompts.
4
+ Events are kept in order: text blocks, tool calls (with result status),
5
+ so rules can ask "what happened after the last edit?".
6
+ """
7
+ from __future__ import annotations
8
+ import glob
9
+ import json
10
+ import os
11
+ from dataclasses import dataclass, field
12
+
13
+
14
+ @dataclass
15
+ class Event:
16
+ kind: str # text | tool
17
+ name: str = "" # tool name for kind=tool
18
+ text: str = "" # text block, or command/path summary for tools
19
+ tool_use_id: str = ""
20
+ is_error: bool | None = None
21
+ ts: str = ""
22
+ result: str = ""
23
+
24
+
25
+ @dataclass
26
+ class Turn:
27
+ session: str
28
+ index: int
29
+ version: str
30
+ prompt: str
31
+ events: list = field(default_factory=list)
32
+ ts: str = ""
33
+ agent: str = "claude-code"
34
+ model: str = ""
35
+ project: str = ""
36
+
37
+
38
+ def _summarize_input(name: str, inp) -> str:
39
+ if not isinstance(inp, dict):
40
+ return ""
41
+ if name == "Bash":
42
+ return inp.get("command", "") or ""
43
+ for k in ("file_path", "path", "notebook_path", "url", "pattern", "query", "prompt", "description"):
44
+ if k in inp:
45
+ return str(inp[k])
46
+ return ""
47
+
48
+
49
+ def iter_sessions(root: str | None = None):
50
+ root = root or os.path.expanduser("~/.claude/projects")
51
+ for path in sorted(glob.glob(os.path.join(root, "*", "*.jsonl"))):
52
+ yield path
53
+
54
+
55
+ def parse_session(path: str):
56
+ session = os.path.splitext(os.path.basename(path))[0]
57
+ turns: list[Turn] = []
58
+ cur: Turn | None = None
59
+ results: dict[str, tuple] = {} # tool_use_id -> (is_error, text)
60
+ pending: list[Event] = []
61
+ version = ""
62
+ project = ""
63
+ with open(path, encoding="utf-8", errors="ignore") as fh:
64
+ for line in fh:
65
+ try:
66
+ r = json.loads(line)
67
+ except Exception:
68
+ continue
69
+ if not isinstance(r, dict):
70
+ continue
71
+ if r.get("isSidechain"):
72
+ continue # subagent lines live elsewhere; keep main thread only
73
+ t = r.get("type")
74
+ version = r.get("version") if isinstance(r.get("version"), str) else version
75
+ project = r.get("cwd") if isinstance(r.get("cwd"), str) else project
76
+ msg = r.get("message") if isinstance(r.get("message"), dict) else {}
77
+ origin = r.get("origin") if isinstance(r.get("origin"), dict) else {}
78
+ ts = str(r.get("timestamp") or "")
79
+ content = msg.get("content")
80
+ if t == "user":
81
+ human = origin.get("kind") == "human" or isinstance(content, str)
82
+ if isinstance(content, list):
83
+ for b in content:
84
+ if isinstance(b, dict) and b.get("type") == "tool_result":
85
+ rc = b.get("content")
86
+ if isinstance(rc, list):
87
+ rc = " ".join(str(x.get("text") or "") for x in rc if isinstance(x, dict))
88
+ results[b.get("tool_use_id", "")] = (bool(b.get("is_error")), (rc or "")[:3000])
89
+ if any(isinstance(b, dict) and b.get("type") == "text" for b in content) and origin.get("kind") == "human":
90
+ human = True
91
+ if human:
92
+ prompt = content if isinstance(content, str) else " ".join(
93
+ str(b.get("text") or "") for b in content if isinstance(b, dict) and b.get("type") == "text")
94
+ if cur is not None:
95
+ turns.append(cur)
96
+ cur = Turn(session=session, index=len(turns), version=version, prompt=prompt[:200], ts=ts, project=project)
97
+ elif t == "assistant" and isinstance(content, list):
98
+ if cur is None:
99
+ cur = Turn(session=session, index=0, version=version, prompt="", ts=ts)
100
+ if isinstance(msg.get("model"), str) and not cur.model:
101
+ cur.model = msg["model"]
102
+ for b in content:
103
+ if not isinstance(b, dict):
104
+ continue
105
+ if b.get("type") == "text":
106
+ cur.events.append(Event("text", text=str(b.get("text") or ""), ts=ts))
107
+ elif b.get("type") == "tool_use":
108
+ cur.events.append(Event("tool", name=str(b.get("name") or ""), text=_summarize_input(str(b.get("name") or ""), b.get("input")),
109
+ tool_use_id=str(b.get("id") or ""), ts=ts))
110
+ if cur is not None:
111
+ turns.append(cur)
112
+ for turn in turns:
113
+ for e in turn.events:
114
+ if e.kind == "tool" and e.tool_use_id in results:
115
+ e.is_error, e.result = results[e.tool_use_id]
116
+ return turns