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 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())