Files
Oleks c8943690e0
ci/woodpecker/push/test Pipeline failed
ci/woodpecker/pr/test Pipeline failed
offload-scan: caveat reconstructed invocation counts as a lower bound
reconstruct_invocations() only sees already-attributed turns and splits on a
wall-clock idle gap (10 min default); it has no signal for unrelated
activity between two genuinely separate same-skill calls, so back-to-back
invocations closer together than the gap collapse into one reported
invocation. This inflates the headline per-invocation figures quoted in
filed issue bodies without affecting group totals (weighted_tokens,
offload_value, share_of_audited_spend).

Sidechain-leakage was investigated as the suspected root cause and ruled
out: turn attribution (the `mine` check) is correct, nothing session-wide
enters `turns`. The real, narrower defect is invocation-count inflation
from idle-gap merging.

Adds `totals.invocation_caveat` to offload-scan's JSON output, threads it
through audit-snapshot's snapshot totals, and renders it as a note in the
issue-body template so downstream issue bodies don't overstate confidence
in the per-invocation figure.

Fixes kotkan/claude-plugin-inference-arbitrage#12
2026-07-30 05:39:16 +03:00

774 lines
30 KiB
Python
Executable File

#!/usr/bin/env python3
"""Dynamic pass over local Claude Code transcripts -> one JSON document.
Usage: offload-scan --plugin <name> [--days N | --since D --until D]
[--root ~/.claude/projects] [--json] [--catalog PATH]
Measures what a plugin's skills and agents ACTUALLY cost in real sessions,
and how much of that cost bought no judgment: Mechanical Turn Ratio,
recurring tool-call n-grams, read amplification, retry density, judgment
density, fan-out multiplier, and the composite offload_value (design/spec.md
§5.2). Single streaming stdlib process, offline.
PRIVACY (FR-3.5): only normalized shapes and counts leave this process. Tool
arguments are masked to <path>/<n>/<sha>/<url>/<str> before they ever enter a
data structure that is emitted; Bash commands are reduced to their bare
executable/subcommand words. No raw transcript content, file content, command
argument, or user prose is ever printed.
MONEY MATH (FR-3.2): the pricing table, tier weighting, and cache multipliers
come from token-budget's bin/cc-tokens and are never reimplemented here. See
resolve_cc_tokens() for the resolution order and the note there about why this
imports the module rather than shelling out.
"""
import argparse
import glob
import importlib.machinery
import importlib.util
import json
import math
import os
import re
import sys
from collections import Counter, defaultdict
from datetime import datetime, timedelta, timezone
PLUGIN_ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
DEFAULT_CATALOG = os.path.join(PLUGIN_ROOT, "references", "signals-catalog.md")
DEFAULT_ROOT = os.path.expanduser("~/.claude/projects")
CC_TOKENS_GLOB = os.path.expanduser(
"~/.claude/plugins/cache/*/token-budget/*/bin/cc-tokens")
# --------------------------------------------------------------------------
# cc-tokens dependency (plan.md §4.3)
# --------------------------------------------------------------------------
def semver_key(text):
m = re.match(r"^(\d+)\.(\d+)\.(\d+)", text)
return tuple(int(g) for g in m.groups()) if m else (0, 0, 0)
def resolve_cc_tokens():
"""Locate cc-tokens: PATH first, then the plugin cache, else fail loudly.
Imported as a module rather than shelled out with --json, because
cc-tokens' subcommands aggregate by day/session/project/model and none of
them can answer "weighted tokens for THIS set of (message.id, requestId)
turns" — the attribution fields this pass filters on are not part of its
CLI surface. Importing keeps the pricing table, the cache multipliers and
the tier weighting in exactly one home (its Rec.cost / Rec.weighted are
what compute every number here), which is the property plan.md §4.3
actually cares about. The clean long-term fix is still
`cc-tokens attribute --by skill|plugin --json` upstream.
"""
path = None
for d in os.environ.get("PATH", "").split(os.pathsep):
cand = os.path.join(d, "cc-tokens")
if os.path.isfile(cand) and os.access(cand, os.X_OK):
path = cand
break
if path is None:
cached = sorted(glob.glob(CC_TOKENS_GLOB),
key=lambda p: semver_key(os.path.basename(
os.path.dirname(os.path.dirname(p)))))
if cached:
path = cached[-1]
if path is None:
sys.exit(
"error: cc-tokens not found on PATH and not in "
"~/.claude/plugins/cache/*/token-budget/*/bin/cc-tokens.\n"
" offload-scan takes all token and cost arithmetic from "
"token-budget's cc-tokens and never vendors a copy.\n"
" install: claude plugin install token-budget@oleks-local "
"(or add token-budget/bin to PATH)")
spec = importlib.util.spec_from_loader(
"cc_tokens", importlib.machinery.SourceFileLoader("cc_tokens", path))
module = importlib.util.module_from_spec(spec)
spec.loader.exec_module(module)
for attr in ("Rec", "price_for", "parse_day"):
if not hasattr(module, attr):
sys.exit(f"error: {path} has no {attr!r} — incompatible cc-tokens; "
f"update token-budget or report this mismatch")
return module, path
# --------------------------------------------------------------------------
# signals-catalog.md loader (same fenced-block contract as plugin-inventory,
# but tolerant of glob patterns like list_* in the tool sections)
# --------------------------------------------------------------------------
SCAN_SECTIONS = {"mechanical-tool-calls", "read-tool-calls", "read-only-bash",
"scan-thresholds"}
def load_scan_catalog(path):
with open(path, encoding="utf-8") as f:
text = f.read()
sections = {}
for m in re.finditer(r"^##\s+([a-z-]+)\s*$", text, re.MULTILINE):
name = m.group(1)
if name not in SCAN_SECTIONS:
continue
block = re.search(r"```\S*\n(.*?)```", text[m.end():], re.DOTALL)
if not block:
continue
body = block.group(1)
if name == "scan-thresholds":
values = {}
for raw in body.split("\n"):
line = raw.split("#", 1)[0].strip()
if ":" in line:
k, v = line.split(":", 1)
values[k.strip()] = float(v.strip())
sections[name] = values
else:
items = set()
for raw in body.split("\n"):
for item in raw.split(","):
item = item.strip()
if re.match(r"^[A-Za-z_*][A-Za-z0-9_ *-]*$", item):
items.add(item)
sections[name] = items
missing = SCAN_SECTIONS - sections.keys()
if missing:
sys.exit(f"error: {path} is missing section(s): {', '.join(sorted(missing))}")
return sections
def matches_any(name, patterns):
for pat in patterns:
if pat == name:
return True
if "*" in pat and re.fullmatch(re.escape(pat).replace(r"\*", ".*"), name):
return True
return False
# --------------------------------------------------------------------------
# masking (FR-3.5) — nothing that has not been through here may be emitted
# --------------------------------------------------------------------------
HEX_RE = re.compile(r"^[0-9a-fA-F]{7,}$")
URL_RE = re.compile(r"^[a-z][a-z0-9+.-]*://", re.IGNORECASE)
PATH_RE = re.compile(r"^~?/|^\.{1,2}/|^[\w.-]+/[\w./-]+$")
NUM_RE = re.compile(r"^-?\d+(\.\d+)?$")
BARE_WORD_RE = re.compile(r"^[a-z][a-z0-9_-]*$")
def mask_value(value):
if isinstance(value, bool):
return "<bool>"
if isinstance(value, (int, float)):
return "<n>"
if isinstance(value, dict):
return "<obj>"
if isinstance(value, list):
return "<list>"
if value is None:
return "<null>"
text = str(value).strip()
if not text:
return "<empty>"
if URL_RE.match(text):
return "<url>"
if NUM_RE.match(text):
return "<n>"
if HEX_RE.match(text):
return "<sha>"
if PATH_RE.match(text) and " " not in text:
return "<path>"
return "<str>"
def mask_bash(command):
"""Reduce a shell command to its executable/subcommand words only.
Keeps bare lowercase words (`git`, `status`, `kubectl`, `get`) and masks
everything else, so `cat /etc/secrets/token` becomes `cat <path>` and
`curl https://…` becomes `curl <url>`. Argument literals never survive.
"""
tokens = str(command).strip().split()
kept = []
for tok in tokens[:3]:
if BARE_WORD_RE.match(tok):
kept.append(tok)
else:
kept.append(mask_value(tok))
break
return " ".join(kept) if kept else "<empty>"
def short_tool_name(name):
"""mcp__plugin_cluster_gitea-tools__issue_read -> issue_read."""
if name.startswith("mcp__") and "__" in name[5:]:
return name.rsplit("__", 1)[-1]
return name
def tool_signature(block):
name = short_tool_name(block.get("name") or "?")
args = block.get("input")
if not isinstance(args, dict) or not args:
return f"{name}()"
parts = []
for key in sorted(args):
val = args[key]
if name in ("Bash", "BashOutput") and key in ("command", "cmd"):
parts.append(f"{key}={mask_bash(val)}")
else:
parts.append(f"{key}={mask_value(val)}")
return f"{name}({', '.join(parts)})"
# --------------------------------------------------------------------------
# turn model
# --------------------------------------------------------------------------
class Turn:
"""One de-duplicated attributed assistant turn, plus its classification
inputs. `rec` is a cc-tokens Rec — all token/cost arithmetic lives there."""
__slots__ = ("rec", "session", "ts", "skill", "agent", "plugin", "sidechain",
"sigs", "tool_names", "text_words", "output_tokens",
"after_error", "asks_user", "pulled_tokens", "referenced_tokens")
def __init__(self, rec):
self.rec = rec
self.session = rec.session
self.ts = rec.ts
self.skill = None
self.agent = None
self.plugin = None
self.sidechain = bool(rec.sidechain)
self.sigs = []
self.tool_names = []
self.text_words = 0
self.output_tokens = rec.out
self.after_error = False
self.asks_user = False
self.pulled_tokens = 0
self.referenced_tokens = 0
@property
def group(self):
return self.agent or self.skill or f"{self.plugin}:(unattributed)"
@property
def kind(self):
return "agent" if self.agent else "skill"
def est_tokens(text):
"""~4 chars per token. Only ever applied to material that stays local."""
return max(1, len(text) // 4)
def content_text(blocks):
out = []
if isinstance(blocks, str):
return blocks
if not isinstance(blocks, list):
return ""
for b in blocks:
if isinstance(b, dict) and b.get("type") == "text":
out.append(str(b.get("text") or ""))
return "\n".join(out)
def result_text(block):
content = block.get("content")
if isinstance(content, str):
return content
if isinstance(content, list):
parts = []
for c in content:
if isinstance(c, dict) and c.get("type") == "text":
parts.append(str(c.get("text") or ""))
return "\n".join(parts)
return ""
WORD_RE = re.compile(r"[A-Za-z0-9_./-]{3,}")
def word_set(text, cap=4000):
return set(WORD_RE.findall(text)[:cap])
# --------------------------------------------------------------------------
# streaming scan
# --------------------------------------------------------------------------
def normalize_skill(value, plugin):
if not value:
return None
return value if ":" in value else f"{plugin}:{value}"
def scan(root, plugin, since, until, cc, catalog):
"""Streams every transcript once. Yields nothing; returns
(turns, session_totals, sessions_with_target).
Memory is O(turns attributed to the target + sessions), not O(all turns):
unattributed turns are counted and dropped immediately.
"""
read_tools = catalog["read-tool-calls"]
turns = []
session_turns = Counter()
session_hit = set()
session_span = {}
floor = since.timestamp() - 86400 if since else None
seen = set()
for path in sorted(glob.iglob(os.path.join(root, "**", "*.jsonl"),
recursive=True)):
try:
if floor is not None and os.stat(path).st_mtime < floor:
continue
except OSError:
continue
try:
fh = open(path, "r", errors="replace")
except OSError:
continue
pending = {}
order = []
# Rolling state, valid only within one file (== one session's append
# log, so file order is chronological).
last_error = False
pending_reads = []
tool_use_names = {}
with fh:
for line in fh:
if '"type"' not in line:
continue
try:
o = json.loads(line)
except (ValueError, TypeError):
continue
kind = o.get("type")
if kind == "user":
blocks = (o.get("message") or {}).get("content")
if not isinstance(blocks, list):
continue
for b in blocks:
if not isinstance(b, dict) or b.get("type") != "tool_result":
continue
if b.get("is_error"):
last_error = True
name = tool_use_names.pop(b.get("tool_use_id"), None)
if not name or not matches_any(name, read_tools):
continue
text = result_text(b)
if text and len(pending_reads) < 32:
pending_reads.append((est_tokens(text), word_set(text)))
continue
if kind != "assistant":
continue
msg = o.get("message") or {}
usage = msg.get("usage") or {}
if not usage or msg.get("model") == "<synthetic>":
continue
try:
when = datetime.fromisoformat(
str(o.get("timestamp")).replace("Z", "+00:00"))
except (ValueError, TypeError):
continue
if since and when < since:
continue
if until and when > until:
continue
session = o.get("sessionId") or os.path.basename(path)[:-6]
session_turns[session] += 1
span = session_span.get(session)
if span is None:
session_span[session] = [when, when]
else:
if when < span[0]:
span[0] = when
if when > span[1]:
span[1] = when
# Registered for every assistant turn, attributed or not: the
# read is issued by turn N and consumed by turn N+1, and only
# one of the two need belong to the target.
if len(tool_use_names) > 256:
tool_use_names.clear()
for b in msg.get("content") or []:
if isinstance(b, dict) and b.get("type") == "tool_use":
tool_use_names[b.get("id")] = short_tool_name(b.get("name") or "?")
attr_plugin = o.get("attributionPlugin")
attr_agent = o.get("attributionAgent")
attr_skill = o.get("attributionSkill")
mine = (attr_plugin == plugin
or (attr_agent or "").startswith(plugin + ":")
or (attr_skill or "").startswith(plugin + ":"))
if not mine:
# Still consumed: a tool error or a read that precedes an
# unattributed turn belongs to that turn, not to ours.
last_error = False
pending_reads = []
continue
session_hit.add(session)
cw = usage.get("cache_creation") or {}
cw5 = int(cw.get("ephemeral_5m_input_tokens") or 0)
cw1 = int(cw.get("ephemeral_1h_input_tokens") or 0)
total_cw = int(usage.get("cache_creation_input_tokens") or 0)
if not (cw5 or cw1) and total_cw:
cw1 = total_cw
rec = cc.Rec(
ts=when, model=msg.get("model"), session=session,
cwd=o.get("cwd") or "", sidechain=bool(o.get("isSidechain")),
inp=int(usage.get("input_tokens") or 0), cw5=cw5, cw1=cw1,
cr=int(usage.get("cache_read_input_tokens") or 0),
out=int(usage.get("output_tokens") or 0))
key = (msg.get("id"), o.get("requestId"))
if key == (None, None):
key = ("_anon", o.get("uuid") or len(order))
turn = pending.get(key)
if turn is None:
turn = Turn(rec)
turn.plugin = attr_plugin or plugin
turn.skill = normalize_skill(attr_skill, turn.plugin)
turn.agent = attr_agent
turn.after_error = last_error
pending[key] = turn
order.append(key)
else:
# Same message, later streaming snapshot: max each field.
# (The cache fields never vary; output_tokens grows.)
for f in ("inp", "cw5", "cw1", "cr", "out"):
setattr(turn.rec, f,
max(getattr(turn.rec, f), getattr(rec, f)))
turn.output_tokens = turn.rec.out
for b in msg.get("content") or []:
if not isinstance(b, dict):
continue
if b.get("type") == "tool_use":
name = short_tool_name(b.get("name") or "?")
turn.tool_names.append(name)
turn.sigs.append(tool_signature(b))
if name == "AskUserQuestion":
turn.asks_user = True
elif b.get("type") == "text":
turn.text_words += len(str(b.get("text") or "").split())
if pending_reads:
words = word_set(content_text(msg.get("content")))
for pulled, result_words in pending_reads:
turn.pulled_tokens += pulled
overlap = words & result_words
turn.referenced_tokens += est_tokens(" ".join(overlap))
pending_reads = []
last_error = False
for key in order:
if key in seen:
continue
seen.add(key)
turns.append(pending[key])
return turns, session_turns, session_hit, session_span
# --------------------------------------------------------------------------
# classification (spec §5.2.1, §5.2.4, §5.2.5)
# --------------------------------------------------------------------------
def is_mechanical_tool(name, sig, catalog):
if name in ("Bash", "BashOutput"):
head = sig.split("cmd=", 1)[-1].split("command=", 1)[-1].rstrip(")")
words = [w for w in head.split() if BARE_WORD_RE.match(w)]
if not words:
return False
return (matches_any(words[0], catalog["read-only-bash"])
or matches_any(" ".join(words[:2]), catalog["read-only-bash"]))
return matches_any(name, catalog["mechanical-tool-calls"])
def classify(turn, catalog):
th = catalog["scan-thresholds"]
if turn.after_error:
return "retry"
if turn.tool_names:
if (turn.output_tokens < th["mechanical_output_tokens_max"]
and all(is_mechanical_tool(n, s, catalog)
for n, s in zip(turn.tool_names, turn.sigs))):
return "mechanical"
return "other"
if turn.output_tokens >= th["judgment_output_tokens_min"] or turn.asks_user:
return "judgment"
return "other"
# --------------------------------------------------------------------------
# invocation reconstruction (plan.md §4.1 step 4)
# --------------------------------------------------------------------------
def reconstruct_invocations(turns, idle_gap_minutes):
"""Contiguous runs of attributed turns in one session and one group,
split on an idle gap. Returns [(invocation_id, group, [turns])]."""
by_session = defaultdict(list)
for t in turns:
by_session[t.session].append(t)
invocations = []
gap = timedelta(minutes=idle_gap_minutes)
for session, group_turns in sorted(by_session.items()):
group_turns.sort(key=lambda t: t.ts)
current = []
for t in group_turns:
if current and (t.group != current[-1].group
or t.ts - current[-1].ts > gap):
invocations.append((f"{session}#{len(invocations)}",
current[0].group, current))
current = []
current.append(t)
if current:
invocations.append((f"{session}#{len(invocations)}",
current[0].group, current))
return invocations
# --------------------------------------------------------------------------
# n-gram mining (spec §5.2.2)
# --------------------------------------------------------------------------
def is_subsequence(short, long_):
n = len(short)
return any(long_[i:i + n] == short for i in range(len(long_) - n + 1))
def mine_ngrams(invocations, weighted_of, min_len, max_len, min_invocations,
top=5):
"""Ordered signature sequences per invocation; n-grams of length >= min_len
recurring in >= min_invocations distinct invocations. Only maximal n-grams
are reported — a 3-gram fully inside a kept 5-gram with no extra
recurrences is the same finding stated twice."""
seqs = []
for inv_id, _group, turns in invocations:
seq = []
for t in turns:
for sig in t.sigs:
seq.append((sig, t))
if len(seq) >= min_len:
seqs.append((inv_id, seq))
found = {}
for n in range(min_len, max_len + 1):
counts = defaultdict(lambda: [0, set(), set()])
for inv_id, seq in seqs:
for i in range(len(seq) - n + 1):
gram = tuple(s for s, _ in seq[i:i + n])
entry = counts[gram]
entry[0] += 1
entry[1].add(inv_id)
for _, t in seq[i:i + n]:
entry[2].add(id(t))
for gram, (occurrences, invs, turn_ids) in counts.items():
if len(invs) >= min_invocations:
found[gram] = (occurrences, len(invs), turn_ids)
kept = []
for gram in sorted(found, key=lambda g: (-len(g), -found[g][0])):
occurrences, invs, turn_ids = found[gram]
if any(len(gram) < len(k) and is_subsequence(list(gram), list(k))
and found[k][1] >= invs for k in (kk for kk, _ in kept)):
continue
kept.append((gram, (occurrences, invs, turn_ids)))
turn_weight = {id(t): weighted_of(t)
for _i, _g, ts in invocations for t in ts}
out = []
for gram, (occurrences, invs, turn_ids) in kept:
waste = int(sum(turn_weight.get(tid, 0) for tid in turn_ids))
out.append({"sig": list(gram), "recurrences": occurrences,
"invocations": invs, "waste": waste})
out.sort(key=lambda g: (-g["waste"], -g["recurrences"]))
return out[:top]
# --------------------------------------------------------------------------
# aggregation (spec §5.2.7)
# --------------------------------------------------------------------------
def summarize_group(name, kind, turns, invocations, plugin_weighted,
inline_mean_ctx, catalog):
"""fanout_multiplier is mean context per SIDECHAIN turn over mean context
per inline turn of the same plugin — literally "a step in here costs N x
the same step inline". It is 1.0 when the group has no sidechain turns,
and it can legitimately come out BELOW 1: a subagent spawned with a tight
brief carries less context than a marathon main session, which is the
fan-out done right. Reported unclamped; it is a diagnostic, not an input
to offload_value."""
th = catalog["scan-thresholds"]
n = len(turns)
weighted = sum(t.rec.weighted for t in turns)
kinds = Counter(classify(t, catalog) for t in turns)
mechanical = [t for t in turns if classify(t, catalog) == "mechanical"]
offload_waste = sum(t.rec.weighted for t in mechanical)
pulled = sum(t.pulled_tokens for t in turns)
referenced = sum(t.referenced_tokens for t in turns)
read_amp = round(pulled / referenced, 2) if referenced else (
float(pulled) if pulled else 0.0)
sidechain = [t for t in turns if t.sidechain]
if sidechain and inline_mean_ctx:
mean_ctx = sum(t.rec.ctx for t in sidechain) / len(sidechain)
fanout = round(mean_ctx / inline_mean_ctx, 2)
else:
fanout = 1.0
ngrams = mine_ngrams(
invocations, lambda t: t.rec.weighted,
int(th["ngram_min_len"]), int(th["ngram_max_len"]),
int(th["ngram_min_invocations"]))
judgment_density = round(kinds["judgment"] / n, 3) if n else 0.0
dominant = max((g["recurrences"] for g in ngrams), default=0)
repetition_factor = 1 + math.log2(dominant) if dominant > 1 else 1.0
offload_value = int(offload_waste * (1 - judgment_density) * repetition_factor)
return {
("agent" if kind == "agent" else "skill"): name,
"invocations": len(invocations),
"turns": n,
"sidechain_turns": len(sidechain),
"weighted_tokens": int(weighted),
"cost_estimate_usd": round(sum(t.rec.cost for t in turns), 2),
"share_of_plugin": round(weighted / plugin_weighted, 3) if plugin_weighted else 0.0,
"mtr": round(len(mechanical) / n, 3) if n else 0.0,
"judgment_density": judgment_density,
"retry_density": round(kinds["retry"] / n, 3) if n else 0.0,
"offload_waste": int(offload_waste),
"read_amplification": read_amp,
"fanout_multiplier": fanout,
"repetition_factor": round(repetition_factor, 3),
"ngrams": ngrams,
"offload_value": offload_value,
}
# --------------------------------------------------------------------------
# main
# --------------------------------------------------------------------------
def main():
ap = argparse.ArgumentParser(prog="offload-scan")
ap.add_argument("--plugin", required=True, help="plugin name to attribute against")
ap.add_argument("--days", type=int, help="window size in days back from now (default 30)")
ap.add_argument("--since", help="inclusive lower bound YYYY-MM-DD")
ap.add_argument("--until", help="inclusive upper bound YYYY-MM-DD")
ap.add_argument("--root", default=DEFAULT_ROOT, help="transcripts root")
ap.add_argument("--catalog", default=DEFAULT_CATALOG, help="path to signals-catalog.md")
ap.add_argument("--json", action="store_true", help="compact single-line JSON")
args = ap.parse_args()
cc, cc_path = resolve_cc_tokens()
catalog = load_scan_catalog(args.catalog)
root = os.path.expanduser(args.root)
if not os.path.isdir(root):
sys.exit(f"error: no such directory: {root}")
if args.since or args.until:
since = cc.parse_day(args.since) if args.since else None
until = cc.parse_day(args.until) if args.until else None
if until:
until += timedelta(days=1) - timedelta(microseconds=1)
else:
days = args.days if args.days else 30
since = datetime.now(timezone.utc) - timedelta(days=days)
until = None
turns, session_turns, session_hit, session_span = scan(
root, args.plugin, since, until, cc, catalog)
th = catalog["scan-thresholds"]
invocations = reconstruct_invocations(turns, th["idle_gap_minutes"])
plugin_weighted = sum(t.rec.weighted for t in turns)
inline = [t for t in turns if not t.sidechain]
inline_mean_ctx = (sum(t.rec.ctx for t in inline) / len(inline)) if inline else 0.0
by_group = defaultdict(list)
for t in turns:
by_group[(t.group, t.kind)].append(t)
invs_by_group = defaultdict(list)
for inv in invocations:
invs_by_group[inv[1]].append(inv)
skills, agents = [], []
for (name, kind), group_turns in by_group.items():
row = summarize_group(name, kind, group_turns, invs_by_group[name],
plugin_weighted, inline_mean_ctx, catalog)
(agents if kind == "agent" else skills).append(row)
skills.sort(key=lambda r: -r["offload_value"])
agents.sort(key=lambda r: -r["offload_value"])
candidate_turns = sum(c for s, c in session_turns.items() if s in session_hit)
spans = [session_span[s] for s in session_hit if s in session_span]
window_since = min((s[0] for s in spans), default=since)
window_until = max((s[1] for s in spans), default=until)
doc = {
"window": {
"since": window_since.isoformat() if window_since else None,
"until": window_until.isoformat() if window_until else None,
"requested_since": since.isoformat() if since else None,
"requested_until": until.isoformat() if until else None,
"sessions": len(session_hit),
},
"plugin": args.plugin,
"tool_versions": {"cc_tokens_path": cc_path},
"coverage": {
"attributed_turns": len(turns),
"candidate_turns": candidate_turns,
"ratio": round(len(turns) / candidate_turns, 3) if candidate_turns else 0.0,
"caveat": "hook-driven and unattributed agent turns excluded",
},
"totals": {
"weighted_tokens": int(plugin_weighted),
"cost_estimate_usd": round(sum(t.rec.cost for t in turns), 2),
"invocations": len(invocations),
"mechanical_share": round(
sum(1 for t in turns if classify(t, catalog) == "mechanical")
/ len(turns), 3) if turns else 0.0,
# reconstruct_invocations() only sees already-attributed turns and
# splits on a wall-clock idle gap; it has no signal for unrelated
# activity between two genuinely separate same-skill calls, so
# back-to-back invocations closer together than the gap collapse
# into one reported invocation. Counts here are a LOWER BOUND on
# the true invocation count, and per-invocation figures derived
# from them (e.g. cost/invocation) are correspondingly inflated.
"invocation_caveat": (
"invocation counts are a lower bound: two same-skill calls "
f"within {int(th['idle_gap_minutes'])} minutes of each other on the wall "
"clock are merged into one reported invocation"),
},
"skills": skills,
"agents": agents,
}
print(json.dumps(doc) if args.json else json.dumps(doc, indent=2))
if __name__ == "__main__":
main()