mirror of
https://github.com/kuhyx/testsAndMisc.git
synced 2026-11-11 00:27:01 +01:00
The transcript analyzer behind /compile-candidate lived only in the working tree. Untracked meant unlinted — pre-commit only inspects files handed to it — so nothing had ever checked it, and it had been abandoned mid-refactor with every parse raising AttributeError: '_Accumulator' object has no attribute 'first_timestamp'. The accumulator had been moved onto the grouped records dataclasses, but three increment sites and parse_session still read flat attributes that no longer existed. parse_session now hands over acc.meta and acc.counts directly; only subagent_count comes from elsewhere, since no transcript line carries it. Two modules broke the 500-line limit, so they were split along seams that were already there. signatures.py takes the text-signature helpers — traces.py was importing them from parse.py, making parse an accidental hub, and now neither imports the other. stats.py takes the pure number and time helpers. parse.py 521 -> 458, detectors.py 559 -> 467. The structural lint findings were refactored, not configured away. _counter_candidates took eight arguments; the five naming which counter field to mine and at what thresholds became a frozen _CounterSpec, so both call sites are one line. _extract_skill carried in_span and a line budget as loop variables across thirteen branches; a _SpanBuffer owns that state now, leaving the walk at four. record() took twelve flat kwargs and now takes one override per record block, with session_meta() so a partial meta override keeps the standard timestamp — a bare SessionMeta(slug=...) would have nulled started_at and quietly changed the per-week arithmetic. All 36 call sites rewritten by an AST pass. tests/factories.py moved into conftest.py, where every other package here keeps shared builders and where name-tests-test does not object. 965 tests pass at 100% statement and branch coverage, every hook is green, and no suppression was added anywhere. `candidates` and `traces skill-finish` both run against the real corpus. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
387 lines
11 KiB
Python
387 lines
11 KiB
Python
"""Command-line entry point for the session autopsy analyzer.
|
|
|
|
Usage, with ``PYTHONPATH=~/testsAndMisc`` and ``python3 -m
|
|
python_pkg.session_autopsy``:
|
|
|
|
ingest <transcript.jsonl>
|
|
scan --jobs 8
|
|
report [--mark-reviewed]
|
|
traces skill-finish
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import argparse
|
|
import concurrent.futures
|
|
from datetime import datetime, timezone
|
|
import json
|
|
from pathlib import Path
|
|
import sys
|
|
import time
|
|
from typing import TYPE_CHECKING
|
|
|
|
from python_pkg.session_autopsy import config, detectors, report, store, traces
|
|
from python_pkg.session_autopsy.parse import parse_session
|
|
|
|
if TYPE_CHECKING:
|
|
from python_pkg.session_autopsy.records import SessionRecord
|
|
|
|
DEFAULT_SCAN_JOBS = 4
|
|
DEFAULT_TRACE_INVOCATIONS = 5
|
|
EXIT_NO_FILE = 2
|
|
EXIT_UNKNOWN_ID = 3
|
|
|
|
|
|
def _emit(message: str) -> None:
|
|
"""Write a line to stdout.
|
|
|
|
Args:
|
|
message: Line to write, without a trailing newline.
|
|
"""
|
|
sys.stdout.write(f"{message}\n")
|
|
|
|
|
|
def _emit_err(message: str) -> None:
|
|
"""Write a line to stderr.
|
|
|
|
Args:
|
|
message: Line to write, without a trailing newline.
|
|
"""
|
|
sys.stderr.write(f"{message}\n")
|
|
|
|
|
|
def _now() -> datetime:
|
|
"""Current UTC time (single seam for tests).
|
|
|
|
Returns:
|
|
Timezone-aware now.
|
|
"""
|
|
return datetime.now(tz=timezone.utc)
|
|
|
|
|
|
def build_parser() -> argparse.ArgumentParser:
|
|
"""Construct the argument parser.
|
|
|
|
Returns:
|
|
Parser with all subcommands.
|
|
"""
|
|
parser = argparse.ArgumentParser(
|
|
prog="python -m python_pkg.session_autopsy",
|
|
description="Deterministic Claude Code transcript analyzer.",
|
|
)
|
|
subparsers = parser.add_subparsers(dest="command", required=True)
|
|
|
|
ingest = subparsers.add_parser("ingest", help="analyze one session transcript")
|
|
ingest.add_argument(
|
|
"transcript", type=Path, help="path to the <uuid>.jsonl transcript"
|
|
)
|
|
ingest.add_argument(
|
|
"--quiet", action="store_true", help="suppress the summary line"
|
|
)
|
|
ingest.add_argument(
|
|
"--no-report", action="store_true", help="skip REPORT.md regeneration"
|
|
)
|
|
|
|
scan = subparsers.add_parser(
|
|
"scan", help="analyze every transcript under the projects dir"
|
|
)
|
|
scan.add_argument(
|
|
"--all",
|
|
action="store_true",
|
|
help="accepted for symmetry; scan always walks everything",
|
|
)
|
|
scan.add_argument(
|
|
"--jobs", type=int, default=DEFAULT_SCAN_JOBS, help="parallel worker processes"
|
|
)
|
|
scan.add_argument(
|
|
"--force", action="store_true", help="reanalyze transcripts even if unchanged"
|
|
)
|
|
scan.add_argument(
|
|
"--no-report", action="store_true", help="skip REPORT.md regeneration"
|
|
)
|
|
|
|
report_cmd = subparsers.add_parser(
|
|
"report", help="regenerate REPORT.md from stored records"
|
|
)
|
|
report_cmd.add_argument(
|
|
"--mark-reviewed",
|
|
action="store_true",
|
|
help="mark all current candidates reviewed",
|
|
)
|
|
|
|
candidates = subparsers.add_parser("candidates", help="list ranked candidates")
|
|
candidates.add_argument(
|
|
"--json", action="store_true", help="machine-readable output"
|
|
)
|
|
|
|
trace_cmd = subparsers.add_parser(
|
|
"traces", help="export compact excerpts for a candidate"
|
|
)
|
|
trace_cmd.add_argument(
|
|
"candidate_id", help="candidate id from REPORT.md (e.g. skill-finish)"
|
|
)
|
|
trace_cmd.add_argument(
|
|
"--max-invocations", type=int, default=DEFAULT_TRACE_INVOCATIONS
|
|
)
|
|
trace_cmd.add_argument(
|
|
"--out", type=Path, default=None, help="write to a file instead of stdout"
|
|
)
|
|
|
|
measure = subparsers.add_parser(
|
|
"measure", help="before/after tokens for compiled skills"
|
|
)
|
|
measure.add_argument(
|
|
"skill", nargs="?", default=None, help="restrict to one skill name"
|
|
)
|
|
return parser
|
|
|
|
|
|
def _regenerate_report(records: list[SessionRecord] | None = None) -> int:
|
|
"""Re-run detectors and rewrite REPORT.md + state.json.
|
|
|
|
Args:
|
|
records: Preloaded records, or None to load from the store.
|
|
|
|
Returns:
|
|
The unreviewed-candidate count.
|
|
"""
|
|
home = config.autopsy_home()
|
|
if records is None:
|
|
records = store.load_records(home)
|
|
result = detectors.analyze(records, _now())
|
|
return report.write_report(home, records, result, _now())
|
|
|
|
|
|
def _summary_line(record: SessionRecord) -> str:
|
|
"""Render the one-line ingest summary for a record.
|
|
|
|
Args:
|
|
record: The freshly parsed record.
|
|
|
|
Returns:
|
|
A compact human-readable summary.
|
|
"""
|
|
return (
|
|
f"{record.session_id}: {record.counts.assistant_msgs} assistant msgs, "
|
|
f"{record.tokens.output} out / {record.tokens.cache_write} cache-write tokens, "
|
|
f"{len(record.obs.skill_invocations)} skill spans, "
|
|
f"{record.counts.subagent_count} subagents, "
|
|
f"{record.counts.malformed_lines} malformed lines"
|
|
)
|
|
|
|
|
|
def cmd_ingest(args: argparse.Namespace) -> int:
|
|
"""Analyze one transcript, upsert its record, refresh the report.
|
|
|
|
Args:
|
|
args: Parsed CLI arguments.
|
|
|
|
Returns:
|
|
Process exit code (0 on success, 2 on missing file).
|
|
"""
|
|
transcript: Path = args.transcript.expanduser()
|
|
if not transcript.is_file():
|
|
_emit_err(f"ingest: no such transcript: {transcript}")
|
|
return EXIT_NO_FILE
|
|
record = parse_session(transcript)
|
|
total = store.upsert_records(config.autopsy_home(), [record])
|
|
if not args.no_report:
|
|
_regenerate_report()
|
|
if not args.quiet:
|
|
_emit(_summary_line(record))
|
|
_emit(f"store: {total} sessions")
|
|
return 0
|
|
|
|
|
|
def _find_transcripts(root: Path) -> list[Path]:
|
|
"""List every top-level session transcript under the projects dir.
|
|
|
|
Subagent transcripts (``**/subagents/agent-*.jsonl``) are excluded;
|
|
they are folded into their parent session by the parser.
|
|
|
|
Args:
|
|
root: The projects directory.
|
|
|
|
Returns:
|
|
Sorted transcript paths.
|
|
"""
|
|
return sorted(path for path in root.glob("*/*.jsonl") if path.is_file())
|
|
|
|
|
|
def _needs_analysis(path: Path, index: dict[str, tuple[int, float]]) -> bool:
|
|
"""Decide whether a transcript changed since its stored record.
|
|
|
|
Args:
|
|
path: Transcript path.
|
|
index: Stored ``{path: (size, mtime)}`` map.
|
|
|
|
Returns:
|
|
True when the transcript is new or its size/mtime moved.
|
|
"""
|
|
stored = index.get(str(path))
|
|
if stored is None:
|
|
return True
|
|
stat = path.stat()
|
|
return (stat.st_size, stat.st_mtime) != stored
|
|
|
|
|
|
def _parse_one(path: Path) -> SessionRecord | None:
|
|
"""Worker-side parse wrapper that never raises across the pool.
|
|
|
|
Args:
|
|
path: Transcript path to parse.
|
|
|
|
Returns:
|
|
The record, or None when the file vanished or was unreadable.
|
|
"""
|
|
try:
|
|
return parse_session(path)
|
|
except OSError as error:
|
|
_emit_err(f"scan: skipping {path}: {error}")
|
|
return None
|
|
|
|
|
|
def cmd_scan(args: argparse.Namespace) -> int:
|
|
"""Analyze every changed transcript in parallel and upsert results.
|
|
|
|
Args:
|
|
args: Parsed CLI arguments.
|
|
|
|
Returns:
|
|
Process exit code (always 0; unreadable files are reported and skipped).
|
|
"""
|
|
started = time.monotonic()
|
|
home = config.autopsy_home()
|
|
root = config.projects_dir()
|
|
transcripts = _find_transcripts(root)
|
|
index = {} if args.force else store.load_file_index(home)
|
|
pending = [path for path in transcripts if _needs_analysis(path, index)]
|
|
records: list[SessionRecord] = []
|
|
if pending:
|
|
with concurrent.futures.ProcessPoolExecutor(
|
|
max_workers=max(1, args.jobs)
|
|
) as pool:
|
|
records = [
|
|
record for record in pool.map(_parse_one, pending) if record is not None
|
|
]
|
|
store.upsert_records(home, records)
|
|
if not args.no_report:
|
|
_regenerate_report()
|
|
elapsed = time.monotonic() - started
|
|
_emit(
|
|
f"scan: {len(transcripts)} transcripts, {len(pending)} analyzed, "
|
|
f"{len(transcripts) - len(pending)} unchanged, {elapsed:.1f}s",
|
|
)
|
|
return 0
|
|
|
|
|
|
def cmd_report(args: argparse.Namespace) -> int:
|
|
"""Regenerate REPORT.md (optionally marking candidates reviewed).
|
|
|
|
Args:
|
|
args: Parsed CLI arguments.
|
|
|
|
Returns:
|
|
Process exit code (always 0).
|
|
"""
|
|
home = config.autopsy_home()
|
|
records = store.load_records(home)
|
|
result = detectors.analyze(records, _now())
|
|
report.write_report(home, records, result, _now())
|
|
if args.mark_reviewed:
|
|
report.write_state(
|
|
home, [cand.id for cand in result.candidates], _now(), mark_reviewed=True
|
|
)
|
|
_emit("report: all candidates marked reviewed")
|
|
_emit(f"report: {len(result.candidates)} candidates → {home / report.REPORT_FILE}")
|
|
return 0
|
|
|
|
|
|
def cmd_candidates(args: argparse.Namespace) -> int:
|
|
"""List ranked candidates.
|
|
|
|
Args:
|
|
args: Parsed CLI arguments.
|
|
|
|
Returns:
|
|
Process exit code (always 0).
|
|
"""
|
|
records = store.load_records(config.autopsy_home())
|
|
result = detectors.analyze(records, _now())
|
|
if args.json:
|
|
_emit(json.dumps([cand.to_dict() for cand in result.candidates], indent=2))
|
|
return 0
|
|
for cand in result.candidates:
|
|
savings = report.fmt_tokens(cand.est_weekly_savings)
|
|
_emit(
|
|
f"{cand.id} {cand.kind:6} {cand.occurrences:4}x "
|
|
f"~{savings}/wk {cand.action}"
|
|
)
|
|
return 0
|
|
|
|
|
|
def cmd_traces(args: argparse.Namespace) -> int:
|
|
"""Export compact excerpts for one candidate.
|
|
|
|
Args:
|
|
args: Parsed CLI arguments.
|
|
|
|
Returns:
|
|
Process exit code (0, or 3 for an unknown candidate id).
|
|
"""
|
|
records = store.load_records(config.autopsy_home())
|
|
result = detectors.analyze(records, _now())
|
|
match = next(
|
|
(cand for cand in result.candidates if cand.id == args.candidate_id), None
|
|
)
|
|
if match is None:
|
|
_emit_err(f"traces: unknown candidate id: {args.candidate_id}")
|
|
return EXIT_UNKNOWN_ID
|
|
records_by_id = {record.session_id: record for record in records}
|
|
text = traces.render_traces(
|
|
match, records_by_id, max_invocations=args.max_invocations
|
|
)
|
|
if args.out is not None:
|
|
args.out.write_text(text, encoding="utf-8")
|
|
_emit(f"traces: wrote {args.out}")
|
|
return 0
|
|
_emit(text)
|
|
return 0
|
|
|
|
|
|
def cmd_measure(args: argparse.Namespace) -> int:
|
|
"""Report before/after tokens per invocation for compiled skills.
|
|
|
|
Args:
|
|
args: Parsed CLI arguments.
|
|
|
|
Returns:
|
|
Process exit code (always 0).
|
|
"""
|
|
home = config.autopsy_home()
|
|
records = store.load_records(home)
|
|
for line in report.measure_lines(records, report.load_compiled(home), args.skill):
|
|
_emit(line)
|
|
return 0
|
|
|
|
|
|
def main(argv: list[str] | None = None) -> int:
|
|
"""Run the CLI.
|
|
|
|
Args:
|
|
argv: Argument vector (None means ``sys.argv[1:]``).
|
|
|
|
Returns:
|
|
Process exit code.
|
|
"""
|
|
args = build_parser().parse_args(argv)
|
|
handlers = {
|
|
"ingest": cmd_ingest,
|
|
"scan": cmd_scan,
|
|
"report": cmd_report,
|
|
"candidates": cmd_candidates,
|
|
"traces": cmd_traces,
|
|
"measure": cmd_measure,
|
|
}
|
|
return handlers[args.command](args)
|