stepcheck-dev 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.
- backend/__init__.py +0 -0
- backend/audit.py +351 -0
- backend/cli.py +274 -0
- backend/kernel/__init__.py +614 -0
- backend/kernel/_gates.py +299 -0
- backend/kernel/_okf.py +663 -0
- backend/kernel/_sandbox.py +135 -0
- backend/kernel/_telemetry.py +52 -0
- backend/kernel/_tokens.py +7 -0
- backend/server.py +584 -0
- stepcheck_dev-0.1.0.dist-info/METADATA +123 -0
- stepcheck_dev-0.1.0.dist-info/RECORD +17 -0
- stepcheck_dev-0.1.0.dist-info/WHEEL +5 -0
- stepcheck_dev-0.1.0.dist-info/entry_points.txt +2 -0
- stepcheck_dev-0.1.0.dist-info/licenses/LICENSE +202 -0
- stepcheck_dev-0.1.0.dist-info/licenses/NOTICE +4 -0
- stepcheck_dev-0.1.0.dist-info/top_level.txt +1 -0
backend/__init__.py
ADDED
|
File without changes
|
backend/audit.py
ADDED
|
@@ -0,0 +1,351 @@
|
|
|
1
|
+
"""`stepcheck audit` — read real agent session logs and show what StepCheck
|
|
2
|
+
would have saved and blocked. Pure parsing + deterministic arithmetic; no LLM.
|
|
3
|
+
|
|
4
|
+
Input: Claude Code transcripts (`~/.claude/projects/<slug>/<session>.jsonl`),
|
|
5
|
+
one JSON object per line with `type` (user|assistant), `timestamp`, and
|
|
6
|
+
`message.content[]` blocks (`tool_use` / `tool_result`) plus `message.usage`
|
|
7
|
+
on assistant turns. Unknown shapes are skipped, never fatal.
|
|
8
|
+
|
|
9
|
+
Two questions, answered per session and in total:
|
|
10
|
+
a) Cache-cliff waste — on assistant turns that start more than
|
|
11
|
+
CACHE_CLIFF_SECONDS after the previous one, the prompt cache has expired
|
|
12
|
+
and `cache_creation_input_tokens` is context re-written at full rate.
|
|
13
|
+
Against that: what the agent pulled with the Read tool, and what the same
|
|
14
|
+
files cost as AST skeleton checkouts.
|
|
15
|
+
b) Regressions — Edit calls whose `old_string` carried a decorator or a
|
|
16
|
+
leading `if …: raise` guard that `new_string` dropped, and the retry loop
|
|
17
|
+
(further edits to the same file / failing tool results) that followed.
|
|
18
|
+
"""
|
|
19
|
+
from __future__ import annotations
|
|
20
|
+
|
|
21
|
+
import ast
|
|
22
|
+
import json
|
|
23
|
+
import math
|
|
24
|
+
import re
|
|
25
|
+
from dataclasses import asdict, dataclass, field
|
|
26
|
+
from datetime import datetime
|
|
27
|
+
from pathlib import Path
|
|
28
|
+
|
|
29
|
+
from kernel import _okf, _telemetry
|
|
30
|
+
|
|
31
|
+
CACHE_CLIFF_SECONDS = _telemetry.CACHE_CLIFF_SECONDS
|
|
32
|
+
USD_FULL = _telemetry.USD_PER_MTOK_INPUT # 3.00 / MTok input (conservative: cache *write* is 3.75)
|
|
33
|
+
USD_CACHE_READ = USD_FULL * _telemetry.CACHE_HIT_FRACTION
|
|
34
|
+
RETRY_WINDOW = 5 # assistant turns after a dropping edit in which later edits count as the retry loop
|
|
35
|
+
|
|
36
|
+
_DECORATOR_RE = re.compile(r"^\s*@([\w.]+)", re.M)
|
|
37
|
+
_GUARD_RE = re.compile(r"^\s*if\b[^\n]*:\s*\n\s+raise\s+(\w+)", re.M)
|
|
38
|
+
_NUMBERED_RE = re.compile(r"^\s*\d+→", re.M) # Claude Code Read output: " 1→line"
|
|
39
|
+
_TEST_PATH_RE = re.compile(r"(^|/)(tests?|__tests__|spec)(/|$)|(^|/)(test_[^/]*|[^/]*_test|conftest)\.py$")
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def _tokens(text: str) -> int:
|
|
43
|
+
return math.ceil(len(text) / 4)
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def _ts(row: dict) -> datetime | None:
|
|
47
|
+
raw = row.get("timestamp")
|
|
48
|
+
if not raw:
|
|
49
|
+
return None
|
|
50
|
+
try:
|
|
51
|
+
return datetime.fromisoformat(str(raw).replace("Z", "+00:00"))
|
|
52
|
+
except ValueError:
|
|
53
|
+
return None
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def _blocks(row: dict) -> list[dict]:
|
|
57
|
+
msg = row.get("message") or {}
|
|
58
|
+
content = msg.get("content")
|
|
59
|
+
if isinstance(content, str):
|
|
60
|
+
return [{"type": "text", "text": content}]
|
|
61
|
+
return [b for b in (content or []) if isinstance(b, dict)]
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def _result_text(block: dict, row: dict | None = None) -> tuple[str, str]:
|
|
65
|
+
"""(text, kind) where kind is "text" | "image" | "empty". Prefers the raw
|
|
66
|
+
file body Claude Code stores in the row-level `toolUseResult.file.content`
|
|
67
|
+
over the line-numbered rendering inside the block."""
|
|
68
|
+
tur = (row or {}).get("toolUseResult")
|
|
69
|
+
if isinstance(tur, dict):
|
|
70
|
+
if tur.get("type") == "image":
|
|
71
|
+
return "", "image"
|
|
72
|
+
f = tur.get("file")
|
|
73
|
+
if isinstance(f, dict) and isinstance(f.get("content"), str):
|
|
74
|
+
return f["content"], "text"
|
|
75
|
+
c = block.get("content", "")
|
|
76
|
+
if isinstance(c, list):
|
|
77
|
+
if any(isinstance(x, dict) and x.get("type") == "image" for x in c):
|
|
78
|
+
return "", "image"
|
|
79
|
+
c = "\n".join(str(x.get("text", "")) for x in c if isinstance(x, dict))
|
|
80
|
+
c = str(c)
|
|
81
|
+
return c, ("text" if c else "empty")
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def _skeleton_tokens(text: str) -> tuple[int, bool]:
|
|
85
|
+
"""(tokens of a focus=[] AST skeleton, exact?). Falls back to 25% when the
|
|
86
|
+
text is not parseable Python."""
|
|
87
|
+
src = _NUMBERED_RE.sub("", text)
|
|
88
|
+
try:
|
|
89
|
+
return _tokens(_okf.skeleton(src, [])), True
|
|
90
|
+
except (SyntaxError, ValueError):
|
|
91
|
+
return math.ceil(_tokens(text) * 0.25), False
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
@dataclass
|
|
95
|
+
class Regression:
|
|
96
|
+
turn: int
|
|
97
|
+
file: str
|
|
98
|
+
kind: str # "decorator" | "guard"
|
|
99
|
+
symbol: str
|
|
100
|
+
retries: int = 0
|
|
101
|
+
failing_results: int = 0
|
|
102
|
+
turns_to_recover: int | None = None
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
@dataclass
|
|
106
|
+
class SessionAudit:
|
|
107
|
+
path: str
|
|
108
|
+
session_id: str | None
|
|
109
|
+
assistant_turns: int = 0
|
|
110
|
+
billed_input_tokens: int = 0 # input + cache_creation across all turns
|
|
111
|
+
cache_read_tokens: int = 0
|
|
112
|
+
cache_gap_turns: list[int] = field(default_factory=list)
|
|
113
|
+
gap_rebilled_tokens: int = 0 # cache_creation on gap turns
|
|
114
|
+
gap_waste_usd: float = 0.0
|
|
115
|
+
read_calls: int = 0
|
|
116
|
+
image_reads: int = 0 # screenshots etc. — no code to skeletonize
|
|
117
|
+
read_tokens: int = 0
|
|
118
|
+
skeleton_tokens: int = 0
|
|
119
|
+
skeleton_exact: bool = True
|
|
120
|
+
read_saving_usd: float = 0.0
|
|
121
|
+
regressions: list[Regression] = field(default_factory=list)
|
|
122
|
+
ignored_test_edits: list[Regression] = field(default_factory=list)
|
|
123
|
+
|
|
124
|
+
@property
|
|
125
|
+
def retry_turns(self) -> int:
|
|
126
|
+
return sum(r.retries for r in self.regressions)
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def audit_session(path: Path) -> SessionAudit:
|
|
130
|
+
rows: list[dict] = []
|
|
131
|
+
for line in path.read_text(encoding="utf-8", errors="replace").splitlines():
|
|
132
|
+
line = line.strip()
|
|
133
|
+
if not line:
|
|
134
|
+
continue
|
|
135
|
+
try:
|
|
136
|
+
rows.append(json.loads(line))
|
|
137
|
+
except json.JSONDecodeError:
|
|
138
|
+
continue
|
|
139
|
+
|
|
140
|
+
out = SessionAudit(path=str(path), session_id=next((r.get("sessionId") for r in rows if r.get("sessionId")), None))
|
|
141
|
+
results: dict[str, tuple[str, bool, str]] = {} # id -> (text, is_error, kind)
|
|
142
|
+
for r in rows:
|
|
143
|
+
for b in _blocks(r):
|
|
144
|
+
if b.get("type") == "tool_result" and b.get("tool_use_id"):
|
|
145
|
+
text, kind = _result_text(b, r)
|
|
146
|
+
results[b["tool_use_id"]] = (text, bool(b.get("is_error")), kind)
|
|
147
|
+
|
|
148
|
+
assistants = [r for r in rows if r.get("type") == "assistant" or (r.get("message") or {}).get("role") == "assistant"]
|
|
149
|
+
out.assistant_turns = len(assistants)
|
|
150
|
+
prev_ts: datetime | None = None
|
|
151
|
+
edits_by_turn: list[tuple[int, str, dict]] = [] # (turn, file, input)
|
|
152
|
+
|
|
153
|
+
for i, r in enumerate(assistants, start=1):
|
|
154
|
+
usage = (r.get("message") or {}).get("usage") or {}
|
|
155
|
+
inp = int(usage.get("input_tokens") or 0)
|
|
156
|
+
created = int(usage.get("cache_creation_input_tokens") or 0)
|
|
157
|
+
read = int(usage.get("cache_read_input_tokens") or 0)
|
|
158
|
+
out.billed_input_tokens += inp + created
|
|
159
|
+
out.cache_read_tokens += read
|
|
160
|
+
|
|
161
|
+
ts = _ts(r)
|
|
162
|
+
if ts and prev_ts and (ts - prev_ts).total_seconds() > CACHE_CLIFF_SECONDS:
|
|
163
|
+
out.cache_gap_turns.append(i)
|
|
164
|
+
out.gap_rebilled_tokens += created
|
|
165
|
+
if ts:
|
|
166
|
+
prev_ts = ts
|
|
167
|
+
|
|
168
|
+
for b in _blocks(r):
|
|
169
|
+
if b.get("type") != "tool_use":
|
|
170
|
+
continue
|
|
171
|
+
name, tool_input = b.get("name"), b.get("input") or {}
|
|
172
|
+
if name == "Read":
|
|
173
|
+
text, _, kind = results.get(b.get("id", ""), ("", False, "empty"))
|
|
174
|
+
if kind == "image":
|
|
175
|
+
out.image_reads += 1
|
|
176
|
+
continue
|
|
177
|
+
out.read_calls += 1
|
|
178
|
+
if text:
|
|
179
|
+
rt = _tokens(text)
|
|
180
|
+
st, exact = _skeleton_tokens(text)
|
|
181
|
+
out.read_tokens += rt
|
|
182
|
+
out.skeleton_tokens += st
|
|
183
|
+
out.skeleton_exact &= exact
|
|
184
|
+
elif name in ("Edit", "MultiEdit") and "old_string" in tool_input:
|
|
185
|
+
edits_by_turn.append((i, str(tool_input.get("file_path", "?")), tool_input))
|
|
186
|
+
elif name == "Write":
|
|
187
|
+
edits_by_turn.append((i, str(tool_input.get("file_path", "?")), tool_input))
|
|
188
|
+
|
|
189
|
+
# Regressions: a dropped decorator / guard, then the retry loop after it.
|
|
190
|
+
for idx, (turn, file, inp) in enumerate(edits_by_turn):
|
|
191
|
+
old, new = str(inp.get("old_string", "")), str(inp.get("new_string", ""))
|
|
192
|
+
if not old:
|
|
193
|
+
continue
|
|
194
|
+
dropped: list[tuple[str, str]] = []
|
|
195
|
+
for m in _DECORATOR_RE.finditer(old):
|
|
196
|
+
if f"@{m.group(1)}" not in new:
|
|
197
|
+
dropped.append(("decorator", f"@{m.group(1)}"))
|
|
198
|
+
for m in _GUARD_RE.finditer(old):
|
|
199
|
+
if not _GUARD_RE.search(new):
|
|
200
|
+
dropped.append(("guard", f"if …: raise {m.group(1)}"))
|
|
201
|
+
for kind, sym in dropped:
|
|
202
|
+
reg = Regression(turn=turn, file=file, kind=kind, symbol=sym)
|
|
203
|
+
later = [(t, f) for (t, f, _) in edits_by_turn[idx + 1:] if f == file and t - turn <= RETRY_WINDOW]
|
|
204
|
+
reg.retries = len(later)
|
|
205
|
+
if later:
|
|
206
|
+
reg.turns_to_recover = later[-1][0] - turn
|
|
207
|
+
# failing tool results between the drop and the last retry
|
|
208
|
+
window_end = later[-1][0] if later else turn + RETRY_WINDOW
|
|
209
|
+
for r2 in assistants[turn - 1: window_end]:
|
|
210
|
+
for b in _blocks(r2):
|
|
211
|
+
if b.get("type") == "tool_use":
|
|
212
|
+
_, err, _ = results.get(b.get("id", ""), ("", False, "empty"))
|
|
213
|
+
reg.failing_results += int(err)
|
|
214
|
+
out.regressions.append(reg)
|
|
215
|
+
# Test files are where guards get edited on purpose; keep them out of the headline.
|
|
216
|
+
out.ignored_test_edits = [r for r in out.regressions if _TEST_PATH_RE.search(r.file)]
|
|
217
|
+
out.regressions = [r for r in out.regressions if not _TEST_PATH_RE.search(r.file)]
|
|
218
|
+
|
|
219
|
+
out.gap_waste_usd = out.gap_rebilled_tokens * (USD_FULL - USD_CACHE_READ) / 1_000_000
|
|
220
|
+
out.read_saving_usd = max(0, out.read_tokens - out.skeleton_tokens) * USD_FULL / 1_000_000
|
|
221
|
+
return out
|
|
222
|
+
|
|
223
|
+
|
|
224
|
+
def find_logs(target: Path) -> list[Path]:
|
|
225
|
+
if target.is_file():
|
|
226
|
+
return [target]
|
|
227
|
+
if target.is_dir():
|
|
228
|
+
return sorted(p for p in target.rglob("*.jsonl") if p.stat().st_size > 0)
|
|
229
|
+
return []
|
|
230
|
+
|
|
231
|
+
|
|
232
|
+
def run(paths: list[Path]) -> dict:
|
|
233
|
+
sessions = [audit_session(p) for p in paths]
|
|
234
|
+
total = {
|
|
235
|
+
"sessions": len(sessions),
|
|
236
|
+
"assistant_turns": sum(s.assistant_turns for s in sessions),
|
|
237
|
+
"billed_input_tokens": sum(s.billed_input_tokens for s in sessions),
|
|
238
|
+
"cache_read_tokens": sum(s.cache_read_tokens for s in sessions),
|
|
239
|
+
"cache_gap_turns": sum(len(s.cache_gap_turns) for s in sessions),
|
|
240
|
+
"gap_rebilled_tokens": sum(s.gap_rebilled_tokens for s in sessions),
|
|
241
|
+
"gap_waste_usd": round(sum(s.gap_waste_usd for s in sessions), 4),
|
|
242
|
+
"read_calls": sum(s.read_calls for s in sessions),
|
|
243
|
+
"image_reads": sum(s.image_reads for s in sessions),
|
|
244
|
+
"ignored_test_edits": sum(len(s.ignored_test_edits) for s in sessions),
|
|
245
|
+
"read_tokens": sum(s.read_tokens for s in sessions),
|
|
246
|
+
"skeleton_tokens": sum(s.skeleton_tokens for s in sessions),
|
|
247
|
+
"skeleton_exact": all(s.skeleton_exact for s in sessions),
|
|
248
|
+
"read_saving_usd": round(sum(s.read_saving_usd for s in sessions), 4),
|
|
249
|
+
"regressions": sum(len(s.regressions) for s in sessions),
|
|
250
|
+
"retry_turns": sum(s.retry_turns for s in sessions),
|
|
251
|
+
"failing_results": sum(r.failing_results for s in sessions for r in s.regressions),
|
|
252
|
+
}
|
|
253
|
+
total["read_saving_pct"] = (
|
|
254
|
+
round(100 * (total["read_tokens"] - total["skeleton_tokens"]) / total["read_tokens"], 1)
|
|
255
|
+
if total["read_tokens"] else 0.0
|
|
256
|
+
)
|
|
257
|
+
return {"total": total, "sessions": [asdict(s) | {"retry_turns": s.retry_turns} for s in sessions]}
|
|
258
|
+
|
|
259
|
+
|
|
260
|
+
# ----------------------------------------------------------------- output
|
|
261
|
+
|
|
262
|
+
|
|
263
|
+
def terminal_summary(report: dict, source_note: str) -> str:
|
|
264
|
+
t = report["total"]
|
|
265
|
+
lines = [
|
|
266
|
+
f"StepCheck audit — {t['sessions']} session(s), {t['assistant_turns']} assistant turns [{source_note}]",
|
|
267
|
+
"-" * 72,
|
|
268
|
+
"a) cache-cliff waste",
|
|
269
|
+
f" turns resumed >{int(CACHE_CLIFF_SECONDS//60)} min after the previous one : {t['cache_gap_turns']:>8}",
|
|
270
|
+
f" context re-written at full rate on those turns : {t['gap_rebilled_tokens']:>8,} tokens (${t['gap_waste_usd']:.4f} vs cache-read)",
|
|
271
|
+
f" Read-tool file pulls : {t['read_calls']:>8} = {t['read_tokens']:,} tokens"
|
|
272
|
+
+ (f" (+{t['image_reads']} image reads, no code)" if t['image_reads'] else ""),
|
|
273
|
+
f" same files as AST skeleton checkouts : {t['skeleton_tokens']:>8,} tokens ({t['read_saving_pct']}% less{'' if t['skeleton_exact'] else ', estimated'}) ${t['read_saving_usd']:.4f}",
|
|
274
|
+
"b) regressions a gate would have blocked",
|
|
275
|
+
f" edits that dropped a decorator / guard clause : {t['regressions']:>8}",
|
|
276
|
+
f" retry edits that followed : {t['retry_turns']:>8}",
|
|
277
|
+
f" failing tool results inside those loops : {t['failing_results']:>8}",
|
|
278
|
+
]
|
|
279
|
+
if t["ignored_test_edits"]:
|
|
280
|
+
lines.append(f" (ignored {t['ignored_test_edits']} guard/decorator edit(s) inside test files)")
|
|
281
|
+
for s in report["sessions"]:
|
|
282
|
+
for r in s["regressions"]:
|
|
283
|
+
lines.append(f" · T{r['turn']:<3} {Path(r['file']).name:<16} dropped {r['kind']:<9} {r['symbol']:<28} "
|
|
284
|
+
f"→ {r['retries']} retr{'y' if r['retries']==1 else 'ies'}, {r['failing_results']} failing result(s)")
|
|
285
|
+
return "\n".join(lines)
|
|
286
|
+
|
|
287
|
+
|
|
288
|
+
def html_receipt(report: dict, source_note: str) -> str:
|
|
289
|
+
t = report["total"]
|
|
290
|
+
reg_rows = "".join(
|
|
291
|
+
f"<tr><td>T{r['turn']}</td><td><code>{Path(r['file']).name}</code></td><td>{r['kind']}</td>"
|
|
292
|
+
f"<td><code>{r['symbol']}</code></td><td>{r['retries']}</td><td>{r['failing_results']}</td>"
|
|
293
|
+
f"<td>{'' if r['turns_to_recover'] is None else str(r['turns_to_recover']) + ' turns'}</td></tr>"
|
|
294
|
+
for s in report["sessions"] for r in s["regressions"]
|
|
295
|
+
) or "<tr><td colspan='7' class='dim'>none found</td></tr>"
|
|
296
|
+
sess_rows = "".join(
|
|
297
|
+
f"<tr><td><code>{s['session_id'] or Path(s['path']).stem}</code></td><td>{s['assistant_turns']}</td>"
|
|
298
|
+
f"<td>{len(s['cache_gap_turns'])}</td><td>{s['gap_rebilled_tokens']:,}</td><td>{s['read_tokens']:,}</td>"
|
|
299
|
+
f"<td>{s['skeleton_tokens']:,}</td><td>{len(s['regressions'])}</td><td>{s['retry_turns']}</td></tr>"
|
|
300
|
+
for s in report["sessions"]
|
|
301
|
+
)
|
|
302
|
+
gen = datetime.now().astimezone().strftime("%Y-%m-%d %H:%M %Z")
|
|
303
|
+
return f"""<!doctype html><html lang="en"><head><meta charset="utf-8"/>
|
|
304
|
+
<meta name="viewport" content="width=device-width,initial-scale=1"/><meta name="color-scheme" content="dark"/>
|
|
305
|
+
<title>StepCheck audit receipt</title>
|
|
306
|
+
<style>
|
|
307
|
+
:root{{--well:#0e1116;--pane:#151a21;--line:#232b35;--ink:#d7dee7;--dim:#8a96a5;--ok:#3fb87f;--dirty:#e0a33b;--bad:#e25d5d;--mono:ui-monospace,Consolas,monospace}}
|
|
308
|
+
body{{margin:0;background:var(--well);color:var(--ink);font:15px/1.5 "Segoe UI","Helvetica Neue",Arial,sans-serif}}
|
|
309
|
+
main{{max-width:880px;margin:0 auto;padding:36px 20px 70px}}h1{{font-size:24px;margin:0 0 4px}}h2{{font-size:16px;margin:32px 0 10px}}
|
|
310
|
+
.sub{{color:var(--dim);margin:0 0 22px}}.grid{{display:grid;grid-template-columns:repeat(auto-fit,minmax(190px,1fr));gap:1px;background:var(--line);border:1px solid var(--line)}}
|
|
311
|
+
.grid div{{background:var(--pane);padding:12px 14px}}.grid b{{display:block;font-size:22px;font-variant-numeric:tabular-nums}}.grid span{{color:var(--dim);font-size:12px}}
|
|
312
|
+
table{{border-collapse:collapse;width:100%;font-size:13px}}th,td{{border:1px solid var(--line);padding:6px 9px;text-align:left}}th{{background:var(--pane);color:var(--dim)}}
|
|
313
|
+
code{{font:12.5px var(--mono)}}.ok{{color:var(--ok)}}.bad{{color:var(--bad)}}.dirty{{color:var(--dirty)}}.dim{{color:var(--dim)}}p{{max-width:72ch}}
|
|
314
|
+
</style></head><body><main>
|
|
315
|
+
<h1>StepCheck audit receipt</h1>
|
|
316
|
+
<p class="sub">{t['sessions']} session(s) · {t['assistant_turns']} assistant turns · {source_note} · generated {gen}</p>
|
|
317
|
+
|
|
318
|
+
<h2>a) Cache-cliff waste</h2>
|
|
319
|
+
<div class="grid">
|
|
320
|
+
<div><b class="bad">{t['cache_gap_turns']}</b><span>turns resumed more than {int(CACHE_CLIFF_SECONDS//60)} min after the previous one</span></div>
|
|
321
|
+
<div><b class="bad">{t['gap_rebilled_tokens']:,}</b><span>tokens of context re-written at full rate on those turns</span></div>
|
|
322
|
+
<div><b class="bad">${t['gap_waste_usd']:.4f}</b><span>paid above cache-read price (${USD_FULL:.2f} vs ${USD_CACHE_READ:.2f} / MTok)</span></div>
|
|
323
|
+
<div><b>{t['read_tokens']:,}</b><span>tokens pulled with the Read tool across {t['read_calls']} call(s)</span></div>
|
|
324
|
+
<div><b class="ok">{t['skeleton_tokens']:,}</b><span>the same files as AST skeleton checkouts{'' if t['skeleton_exact'] else ' (estimated)'}</span></div>
|
|
325
|
+
<div><b class="ok">{t['read_saving_pct']}% · ${t['read_saving_usd']:.4f}</b><span>less context per read</span></div>
|
|
326
|
+
</div>
|
|
327
|
+
|
|
328
|
+
<h2>b) Regressions a commit gate would have blocked</h2>
|
|
329
|
+
<div class="grid">
|
|
330
|
+
<div><b class="dirty">{t['regressions']}</b><span>edits that dropped a decorator or a leading guard clause</span></div>
|
|
331
|
+
<div><b class="dirty">{t['retry_turns']}</b><span>retry edits that followed on the same file</span></div>
|
|
332
|
+
<div><b class="dirty">{t['failing_results']}</b><span>failing tool results inside those retry loops</span></div>
|
|
333
|
+
</div>
|
|
334
|
+
<table style="margin-top:12px"><tr><th>Turn</th><th>File</th><th>Dropped</th><th>Symbol</th><th>Retries</th><th>Failing results</th><th>Recovered after</th></tr>{reg_rows}</table>
|
|
335
|
+
|
|
336
|
+
<h2>Per session</h2>
|
|
337
|
+
<table><tr><th>Session</th><th>Turns</th><th>Cache gaps</th><th>Re-billed tokens</th><th>Read tokens</th><th>Skeleton tokens</th><th>Regressions</th><th>Retries</th></tr>{sess_rows}</table>
|
|
338
|
+
|
|
339
|
+
<h2>How this was computed</h2>
|
|
340
|
+
<p>Pure arithmetic over the transcript, no model calls. A turn is a cache-gap turn when its timestamp is more than {int(CACHE_CLIFF_SECONDS)} s after the previous assistant turn; its <code>cache_creation_input_tokens</code> is the prefix that had to be re-written. Skeleton tokens are the real <code>focus=[]</code> AST skeleton of each Read result where it parses as Python (otherwise a flat 25% estimate, flagged). A regression is an <code>Edit</code> whose <code>old_string</code> had a decorator or <code>if …: raise</code> that <code>new_string</code> lacks; retries are later edits to the same file within {RETRY_WINDOW} turns. Gate B catches the first kind, an auto-mined Gate C invariant the second.</p>
|
|
341
|
+
</main></body></html>"""
|
|
342
|
+
|
|
343
|
+
|
|
344
|
+
def display_path(path: Path) -> str:
|
|
345
|
+
"""Shareable form of a path: forward slashes, home directory shown as `~`,
|
|
346
|
+
so receipts never carry an OS username."""
|
|
347
|
+
try:
|
|
348
|
+
relative = path.resolve().relative_to(Path.home().resolve())
|
|
349
|
+
except ValueError:
|
|
350
|
+
return path.as_posix()
|
|
351
|
+
return "~" if relative == Path() else f"~/{relative.as_posix()}"
|
backend/cli.py
ADDED
|
@@ -0,0 +1,274 @@
|
|
|
1
|
+
"""`stepcheck` console entrypoint.
|
|
2
|
+
|
|
3
|
+
stepcheck up one port: Studio UI (/studio) + Door 1 proxy + REST + MCP (/mcp) + WS
|
|
4
|
+
stepcheck benchmark run the 50-turn coding-rot benchmark, print the table
|
|
5
|
+
stepcheck review regenerate architecture-review.html from benchmarks/results.json
|
|
6
|
+
stepcheck preview regenerate the self-contained stepcheck-studio-preview.html
|
|
7
|
+
stepcheck audit [p] read Claude Code session logs (default ~/.claude/projects/) and
|
|
8
|
+
receipt the cache-cliff waste + dropped-symbol regressions
|
|
9
|
+
stepcheck ci [path] headless gatekeeper: parse, attestations, Gates A/B/C for every
|
|
10
|
+
module; exit 1 names the failing module and violation
|
|
11
|
+
"""
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
import argparse
|
|
15
|
+
import os
|
|
16
|
+
import runpy
|
|
17
|
+
import sys
|
|
18
|
+
from pathlib import Path
|
|
19
|
+
|
|
20
|
+
BACKEND = Path(__file__).resolve().parent
|
|
21
|
+
ROOT = BACKEND.parent
|
|
22
|
+
if str(BACKEND) not in sys.path:
|
|
23
|
+
sys.path.insert(0, str(BACKEND))
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def _utf8_stdio() -> None:
|
|
27
|
+
"""Emit UTF-8 on redirected stdout/stderr. A Windows CI runner hands the
|
|
28
|
+
process a cp1252 pipe, where `→` or `✓` in a table is a UnicodeEncodeError;
|
|
29
|
+
streams already on UTF-8 (consoles, pytest capture) are left alone. Defined
|
|
30
|
+
ahead of the `__main__` block so `python -m cli` can call it."""
|
|
31
|
+
for stream in (sys.stdout, sys.stderr):
|
|
32
|
+
reconfigure = getattr(stream, "reconfigure", None)
|
|
33
|
+
encoding = (getattr(stream, "encoding", None) or "").lower().replace("-", "")
|
|
34
|
+
if reconfigure is None or encoding == "utf8":
|
|
35
|
+
continue
|
|
36
|
+
try:
|
|
37
|
+
reconfigure(encoding="utf-8")
|
|
38
|
+
except (ValueError, OSError):
|
|
39
|
+
pass
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def _run_script(name: str) -> int:
|
|
43
|
+
script = ROOT / "benchmarks" / name
|
|
44
|
+
if not script.exists():
|
|
45
|
+
print(f"stepcheck: {script} not found (installed without the repo checkout?)", file=sys.stderr)
|
|
46
|
+
return 2
|
|
47
|
+
try:
|
|
48
|
+
runpy.run_path(str(script), run_name="__main__")
|
|
49
|
+
except SystemExit as exc:
|
|
50
|
+
return int(exc.code or 0)
|
|
51
|
+
return 0
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def cmd_up(args: argparse.Namespace) -> int:
|
|
55
|
+
import uvicorn
|
|
56
|
+
|
|
57
|
+
import server
|
|
58
|
+
|
|
59
|
+
studio = "mounted at /studio" if server.STUDIO_DIST.exists() else (
|
|
60
|
+
f"not built — run `npm run build` in {ROOT / 'frontend'} to serve it here"
|
|
61
|
+
)
|
|
62
|
+
print(f"StepCheck gateway http://{args.host}:{args.port}")
|
|
63
|
+
print(f" Studio UI {studio}")
|
|
64
|
+
print(f" Door 1 proxy POST /v1/chat/completions · POST /v1/messages")
|
|
65
|
+
print(f" Door 2 /v1/step/* · MCP (streamable-http) /mcp · WS /v1/ws/{{session_id}}")
|
|
66
|
+
uvicorn.run(server.app, host=args.host, port=args.port, log_level=args.log_level)
|
|
67
|
+
return 0
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def cmd_benchmark(_: argparse.Namespace) -> int:
|
|
71
|
+
return _run_script("coding_rot_benchmark.py")
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def cmd_review(_: argparse.Namespace) -> int:
|
|
75
|
+
return _run_script("generate_review.py")
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def cmd_preview(_: argparse.Namespace) -> int:
|
|
79
|
+
return _run_script("generate_preview.py")
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
SAMPLE_SESSION = ROOT / "benchmarks" / "fixtures" / "sample_session.jsonl"
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def cmd_audit(args: argparse.Namespace) -> int:
|
|
86
|
+
import audit
|
|
87
|
+
|
|
88
|
+
target = Path(args.log_path).expanduser() if args.log_path else Path.home() / ".claude" / "projects"
|
|
89
|
+
shown = audit.display_path(target)
|
|
90
|
+
paths = audit.find_logs(target)
|
|
91
|
+
if paths:
|
|
92
|
+
note = f"{len(paths)} log file(s) under {shown}"
|
|
93
|
+
else:
|
|
94
|
+
if not SAMPLE_SESSION.exists():
|
|
95
|
+
print(f"stepcheck audit: no .jsonl logs under {shown} and no sample fixture", file=sys.stderr)
|
|
96
|
+
return 2
|
|
97
|
+
paths, note = [SAMPLE_SESSION], f"no logs under {shown}; showing the bundled sample session"
|
|
98
|
+
if args.limit:
|
|
99
|
+
paths = paths[-args.limit:]
|
|
100
|
+
report = audit.run(paths)
|
|
101
|
+
print(audit.terminal_summary(report, note))
|
|
102
|
+
out = Path(args.out)
|
|
103
|
+
out.write_text(audit.html_receipt(report, note), encoding="utf-8")
|
|
104
|
+
print(f"\nreceipt → {audit.display_path(out)}")
|
|
105
|
+
if args.json:
|
|
106
|
+
Path(args.json).write_text(__import__("json").dumps(report, indent=2), encoding="utf-8")
|
|
107
|
+
return 0
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
# ------------------------------------------------------------- stepcheck ci
|
|
111
|
+
# (defined ahead of main() and the __main__ block so `python -m cli` works too)
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def _ci_parse(root: Path) -> list[tuple[str, str]]:
|
|
115
|
+
"""(file, problem) for every tracked module that does not parse. Decodes the
|
|
116
|
+
way the kernel does (UTF-8, BOM stripped), so a file the kernel tracks never
|
|
117
|
+
fails here."""
|
|
118
|
+
import ast
|
|
119
|
+
|
|
120
|
+
from kernel import _okf
|
|
121
|
+
|
|
122
|
+
problems: list[tuple[str, str]] = []
|
|
123
|
+
for p in sorted(root.rglob("*.py")):
|
|
124
|
+
if ".stepcheck" in p.parts or p.name.startswith("."):
|
|
125
|
+
continue
|
|
126
|
+
rel = str(p.relative_to(root))
|
|
127
|
+
try:
|
|
128
|
+
ast.parse(_okf.decode_source(p.read_bytes()), filename=rel)
|
|
129
|
+
except UnicodeDecodeError:
|
|
130
|
+
continue # the kernel leaves non-UTF-8 files untracked
|
|
131
|
+
except SyntaxError as exc:
|
|
132
|
+
problems.append((rel, f"Gate A: invalid AST syntax: {exc.msg} (line {exc.lineno})"))
|
|
133
|
+
return problems
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
def _ci_frames(root: Path, live: dict[str, str]) -> tuple[list[tuple[str, str]], int]:
|
|
137
|
+
"""(problems, checked) from the existing OKF frames of the nodes in `live`
|
|
138
|
+
(node_id -> path, the set the current tree actually has): a node left in
|
|
139
|
+
DIRTY_OMISSION_RISK, or an attested file whose on-disk bytes no longer hash
|
|
140
|
+
to its latest DSSE sha256. Frames of nodes that no longer exist under that
|
|
141
|
+
name (left by an older session) are not evidence and are skipped."""
|
|
142
|
+
import hashlib
|
|
143
|
+
|
|
144
|
+
import yaml
|
|
145
|
+
|
|
146
|
+
problems: list[tuple[str, str]] = []
|
|
147
|
+
checked = 0
|
|
148
|
+
okf = root / ".stepcheck" / "okf"
|
|
149
|
+
if not okf.is_dir():
|
|
150
|
+
return problems, checked
|
|
151
|
+
for frame in sorted(okf.glob("*.md")):
|
|
152
|
+
if frame.name == "index.md":
|
|
153
|
+
continue
|
|
154
|
+
try:
|
|
155
|
+
meta = yaml.safe_load(frame.read_text(encoding="utf-8").split("---\n")[1]) or {}
|
|
156
|
+
except (yaml.YAMLError, IndexError):
|
|
157
|
+
problems.append((frame.stem, f"frame {frame.name} has unreadable frontmatter"))
|
|
158
|
+
continue
|
|
159
|
+
nid = str(meta.get("node_id", frame.stem))
|
|
160
|
+
rel = str(meta.get("path", ""))
|
|
161
|
+
if live.get(nid, None) != rel:
|
|
162
|
+
continue # stale: no node of that name maps to that file any more
|
|
163
|
+
checked += 1
|
|
164
|
+
if meta.get("status") == "DIRTY_OMISSION_RISK":
|
|
165
|
+
problems.append((nid, "holds DIRTY_OMISSION_RISK: a dependency's exports changed; "
|
|
166
|
+
"check it out and re-verify before merging"))
|
|
167
|
+
verified = meta.get("verified") or []
|
|
168
|
+
if not verified:
|
|
169
|
+
continue
|
|
170
|
+
payload = (verified[-1] or {}).get("payload", {})
|
|
171
|
+
attested, turn = payload.get("sha256"), payload.get("turn")
|
|
172
|
+
target = root / rel
|
|
173
|
+
if not target.is_file():
|
|
174
|
+
problems.append((nid, f"attested file {rel} is missing from the tree"))
|
|
175
|
+
continue
|
|
176
|
+
actual = hashlib.sha256(target.read_bytes()).hexdigest()
|
|
177
|
+
if actual != attested:
|
|
178
|
+
problems.append((nid, f"{rel} on disk (sha256 {actual[:12]}…) differs from its latest "
|
|
179
|
+
f"attestation (turn {turn}, sha256 {str(attested)[:12]}…)"))
|
|
180
|
+
return problems, checked
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
def cmd_ci(args: argparse.Namespace) -> int:
|
|
184
|
+
"""Headless gatekeeper. Phases: parse → index the tree on an ephemeral kernel
|
|
185
|
+
(writes nothing, anywhere) → attestations/dirty nodes for the tracked set →
|
|
186
|
+
re-commit every module through Gates A/B/C, executing all invariants. Exit 0
|
|
187
|
+
with a table, or 1 naming each failing module and its violation."""
|
|
188
|
+
from kernel import StateKernel
|
|
189
|
+
|
|
190
|
+
root = Path(args.path).resolve()
|
|
191
|
+
if not root.is_dir():
|
|
192
|
+
print(f"stepcheck ci: {root} is not a directory", file=sys.stderr)
|
|
193
|
+
return 2
|
|
194
|
+
print(f"stepcheck ci — {root}")
|
|
195
|
+
|
|
196
|
+
def fail(problems: list[tuple[str, str]], phase: str) -> int:
|
|
197
|
+
for where, what in problems:
|
|
198
|
+
print(f" FAIL {where}: {what}")
|
|
199
|
+
where, what = problems[0]
|
|
200
|
+
print(f"stepcheck ci: FAIL — {len(problems)} problem(s) in {phase}; first: {where} — {what}")
|
|
201
|
+
return 1
|
|
202
|
+
|
|
203
|
+
problems = _ci_parse(root)
|
|
204
|
+
if problems:
|
|
205
|
+
return fail(problems, "parse")
|
|
206
|
+
print(" parse every module parses")
|
|
207
|
+
|
|
208
|
+
kernel = StateKernel(ephemeral=True) # verification only: writes nothing, anywhere
|
|
209
|
+
sid = kernel.init_session(root)
|
|
210
|
+
nodes = kernel.graph(sid)["nodes"]
|
|
211
|
+
problems, checked = _ci_frames(root, {n["node_id"]: n["path"] for n in nodes})
|
|
212
|
+
if problems:
|
|
213
|
+
return fail(problems, "attestations")
|
|
214
|
+
print(" attestations " + (f"{checked} frame(s) checked: no dirty nodes, on-disk bytes match"
|
|
215
|
+
if checked else "no frames for the tracked modules yet"))
|
|
216
|
+
|
|
217
|
+
rows: list[tuple[str, str, int, str]] = []
|
|
218
|
+
failures: list[tuple[str, str]] = []
|
|
219
|
+
executed = 0
|
|
220
|
+
for node in nodes:
|
|
221
|
+
nid, n_inv = node["node_id"], len(node["invariants"])
|
|
222
|
+
verdict = kernel.commit(sid, nid, source=node["source"])
|
|
223
|
+
if verdict.accepted:
|
|
224
|
+
executed += n_inv
|
|
225
|
+
rows.append((nid, "PASS", n_inv, verdict.attestation["payload"]["sha256"][:12]))
|
|
226
|
+
else:
|
|
227
|
+
failures.append((f"{node['path']} ({nid})", f"Gate {verdict.gate}: {verdict.error}"))
|
|
228
|
+
rows.append((nid, f"FAIL {verdict.gate}", n_inv, "-"))
|
|
229
|
+
|
|
230
|
+
width = max([len("node")] + [len(r[0]) for r in rows])
|
|
231
|
+
print(f" {'node'.ljust(width)} status inv sha256")
|
|
232
|
+
for nid, status, n_inv, sha in rows:
|
|
233
|
+
print(f" {nid.ljust(width)} {status:<7} {n_inv:>3} {sha}")
|
|
234
|
+
if failures:
|
|
235
|
+
return fail(failures, "gates")
|
|
236
|
+
print(f"stepcheck ci: PASS — {len(rows)} modules verified through Gates A·B·C, "
|
|
237
|
+
f"{executed} invariants executed")
|
|
238
|
+
return 0
|
|
239
|
+
|
|
240
|
+
|
|
241
|
+
def main(argv: list[str] | None = None) -> int:
|
|
242
|
+
_utf8_stdio()
|
|
243
|
+
parser = argparse.ArgumentParser(prog="stepcheck", description=__doc__,
|
|
244
|
+
formatter_class=argparse.RawDescriptionHelpFormatter)
|
|
245
|
+
sub = parser.add_subparsers(dest="command", required=True)
|
|
246
|
+
|
|
247
|
+
up = sub.add_parser("up", help="start the unified gateway + Studio + MCP server on one port")
|
|
248
|
+
up.add_argument("--host", default=os.environ.get("HOST", "127.0.0.1"))
|
|
249
|
+
up.add_argument("--port", type=int, default=int(os.environ.get("PORT", "8420")))
|
|
250
|
+
up.add_argument("--log-level", default="info")
|
|
251
|
+
up.set_defaults(fn=cmd_up)
|
|
252
|
+
|
|
253
|
+
sub.add_parser("benchmark", help="run the 50-turn coding-rot benchmark").set_defaults(fn=cmd_benchmark)
|
|
254
|
+
sub.add_parser("review", help="generate architecture-review.html").set_defaults(fn=cmd_review)
|
|
255
|
+
sub.add_parser("preview", help="generate stepcheck-studio-preview.html").set_defaults(fn=cmd_preview)
|
|
256
|
+
|
|
257
|
+
au = sub.add_parser("audit", help="audit Claude Code session logs for cache-cliff waste and dropped-symbol regressions")
|
|
258
|
+
au.add_argument("log_path", nargs="?", help="a .jsonl transcript or a directory of them (default: ~/.claude/projects/)")
|
|
259
|
+
au.add_argument("--out", default="stepcheck-audit.html", help="receipt path (default: ./stepcheck-audit.html)")
|
|
260
|
+
au.add_argument("--json", default=None, help="also write the raw report as JSON here")
|
|
261
|
+
au.add_argument("--limit", type=int, default=0, help="only the N most recent log files")
|
|
262
|
+
au.set_defaults(fn=cmd_audit)
|
|
263
|
+
|
|
264
|
+
ci = sub.add_parser("ci", help="headless CI/PR gatekeeper: every module must parse, match its attestation, "
|
|
265
|
+
"and pass Gates A/B/C with all invariants executed (exit 1 on the first violation)")
|
|
266
|
+
ci.add_argument("path", nargs="?", default=".", help="project root (default: current directory)")
|
|
267
|
+
ci.set_defaults(fn=cmd_ci)
|
|
268
|
+
|
|
269
|
+
args = parser.parse_args(argv)
|
|
270
|
+
return args.fn(args)
|
|
271
|
+
|
|
272
|
+
|
|
273
|
+
if __name__ == "__main__":
|
|
274
|
+
raise SystemExit(main())
|