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 +21 -0
- honestly-0.3.0/PKG-INFO +128 -0
- honestly-0.3.0/README.md +109 -0
- honestly-0.3.0/pyproject.toml +32 -0
- honestly-0.3.0/setup.cfg +4 -0
- honestly-0.3.0/src/honestly/__init__.py +2 -0
- honestly-0.3.0/src/honestly/audit.py +162 -0
- honestly-0.3.0/src/honestly/claude_code.py +116 -0
- honestly-0.3.0/src/honestly/cli.py +184 -0
- honestly-0.3.0/src/honestly/codex.py +134 -0
- honestly-0.3.0/src/honestly/hooks.py +298 -0
- honestly-0.3.0/src/honestly/report.py +252 -0
- honestly-0.3.0/src/honestly/rules.py +307 -0
- honestly-0.3.0/src/honestly.egg-info/PKG-INFO +128 -0
- honestly-0.3.0/src/honestly.egg-info/SOURCES.txt +23 -0
- honestly-0.3.0/src/honestly.egg-info/dependency_links.txt +1 -0
- honestly-0.3.0/src/honestly.egg-info/entry_points.txt +2 -0
- honestly-0.3.0/src/honestly.egg-info/top_level.txt +1 -0
- honestly-0.3.0/tests/test_codex.py +36 -0
- honestly-0.3.0/tests/test_encoding.py +18 -0
- honestly-0.3.0/tests/test_hooks.py +47 -0
- honestly-0.3.0/tests/test_report.py +28 -0
- honestly-0.3.0/tests/test_review_fixes.py +217 -0
- honestly-0.3.0/tests/test_rules.py +100 -0
- honestly-0.3.0/tests/test_toolchains.py +41 -0
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.
|
honestly-0.3.0/PKG-INFO
ADDED
|
@@ -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
|
+
[](https://github.com/kennywu411-dev/honestly/actions/workflows/tests.yml) [](https://pypi.org/project/honestly/)  
|
|
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
|
+

|
|
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
|
+

|
|
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)。
|
honestly-0.3.0/README.md
ADDED
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
# honestly
|
|
2
|
+
|
|
3
|
+
[](https://github.com/kennywu411-dev/honestly/actions/workflows/tests.yml) [](https://pypi.org/project/honestly/)  
|
|
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
|
+

|
|
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
|
+

|
|
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"]
|
honestly-0.3.0/setup.cfg
ADDED
|
@@ -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
|