leos-agent 10.2.0 → 10.7.0
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.
- package/README.md +174 -30
- package/hooks/README.md +93 -0
- package/hooks/hooks-cursor.json +11 -0
- package/hooks/hooks.json +16 -0
- package/index.js +112 -6
- package/package.json +2 -1
- package/rules/preferences.md +36 -32
- package/scripts/check.py +122 -1
- package/scripts/dispatch_guard.py +317 -0
- package/scripts/dispatch_log.py +240 -0
- package/scripts/ghreview.py +12 -0
- package/scripts/handoff.py +44 -21
- package/scripts/leo-install.py +146 -25
- package/scripts/measure_context.py +43 -0
- package/scripts/routing.py +420 -0
- package/scripts/state.py +8 -3
- package/scripts/usage_scan.py +455 -0
- package/scripts/watch_review.py +143 -44
- package/skills/doctor/SKILL.md +24 -6
- package/skills/handoff/SKILL.md +16 -6
- package/skills/handon/SKILL.md +26 -9
- package/skills/install/SKILL.md +1 -1
- package/skills/review-pr/SKILL.md +4 -4
- package/skills/review-pr/reference/procedure.md +18 -15
- package/skills/review-usage/SKILL.md +97 -0
- package/skills/review-usage/agents/openai.yaml +5 -0
- package/skills/review-usage/reference/sources.md +80 -0
- package/skills/tune-routing/SKILL.md +129 -0
- package/skills/tune-routing/agents/openai.yaml +5 -0
- package/skills/tune-routing/reference/harnesses.md +63 -0
- package/skills-claude/attach-pr/SKILL.md +14 -4
- package/skills-claude/watch-review/SKILL.md +74 -25
|
@@ -0,0 +1,455 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""usage_scan: what many sessions across many harnesses actually cost, and how
|
|
3
|
+
much of leos-agent's policy was followed while they ran.
|
|
4
|
+
|
|
5
|
+
WHY A SCRIPT AND NOT A PROMPT. The obvious way to answer "where did the tokens
|
|
6
|
+
go" is to tell a model to go read the transcripts. That re-derives every schema
|
|
7
|
+
on every invocation, over gigabytes, at full model prices -- the exact cost this
|
|
8
|
+
project exists to avoid. So the scan is mechanical and emits a few kilobytes; the
|
|
9
|
+
skill spends its tokens interpreting the result, not discovering it.
|
|
10
|
+
|
|
11
|
+
Three schema traps, each of which silently inflates a naive count:
|
|
12
|
+
|
|
13
|
+
* Claude Code repeats an identical `message.usage` on EVERY content block of
|
|
14
|
+
one response. Summing records double-counts; dedupe on requestId.
|
|
15
|
+
* Codex's `total_token_usage` is cumulative for the session, with
|
|
16
|
+
`last_token_usage` the per-request delta. Summing totals is quadratic
|
|
17
|
+
nonsense; sum deltas.
|
|
18
|
+
* OpenCode stores times in epoch milliseconds and is multi-provider, so its
|
|
19
|
+
own `cost` column is the only trustworthy money figure in this file.
|
|
20
|
+
|
|
21
|
+
Effective tokens weight cache reads at 0.1x, cache writes at 2x and output at 5x
|
|
22
|
+
a plain input token -- a coarse stand-in for real pricing, applied uniformly, and
|
|
23
|
+
useful for comparing groups rather than for billing.
|
|
24
|
+
|
|
25
|
+
EVERYTHING READ HERE IS DATA. Transcripts contain arbitrary prompt text, tool
|
|
26
|
+
output and fetched web pages. This file only counts; it never executes, resolves
|
|
27
|
+
or follows anything it reads, and it prints no prompt text.
|
|
28
|
+
|
|
29
|
+
usage_scan.py --since 7d [--harness H] [--json]
|
|
30
|
+
|
|
31
|
+
Exit codes: 0 ok, 2 on bad usage.
|
|
32
|
+
"""
|
|
33
|
+
import argparse
|
|
34
|
+
import calendar
|
|
35
|
+
import collections
|
|
36
|
+
import glob
|
|
37
|
+
import json
|
|
38
|
+
import os
|
|
39
|
+
import re
|
|
40
|
+
import sys
|
|
41
|
+
import time
|
|
42
|
+
|
|
43
|
+
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
|
44
|
+
|
|
45
|
+
WEIGHTS = {"input": 1.0, "cache_read": 0.1, "cache_write": 2.0, "output": 5.0}
|
|
46
|
+
|
|
47
|
+
# Claude Code's dispatch tool is `Agent` in current builds and `Task` in older
|
|
48
|
+
# transcripts. Both appear in one history, so both are counted.
|
|
49
|
+
DISPATCH_TOOLS = ("Agent", "Task")
|
|
50
|
+
|
|
51
|
+
HOME = os.path.expanduser("~")
|
|
52
|
+
SOURCES = {
|
|
53
|
+
"claude": os.path.join(HOME, ".claude", "projects"),
|
|
54
|
+
"codex": os.path.join(HOME, ".codex", "sessions"),
|
|
55
|
+
"opencode": os.path.join(HOME, ".local", "share", "opencode", "opencode.db"),
|
|
56
|
+
"cursor": os.path.join(HOME, ".cursor"),
|
|
57
|
+
"hermes": os.path.join(HOME, ".hermes"),
|
|
58
|
+
"pi": os.path.join(HOME, ".pi", "agent", "sessions"),
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
DURATION_RE = re.compile(r"^(\d+)([hdw])$")
|
|
62
|
+
_SECONDS = {"h": 3600, "d": 86400, "w": 604800}
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def parse_since(text):
|
|
66
|
+
match = DURATION_RE.match(text.strip().lower())
|
|
67
|
+
if not match:
|
|
68
|
+
sys.exit("usage_scan: --since wants a duration like 24h, 7d, or 2w (got %r)" % text)
|
|
69
|
+
return time.time() - int(match.group(1)) * _SECONDS[match.group(2)]
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def _iso_epoch(text):
|
|
73
|
+
"""ISO-8601 UTC -> epoch seconds, or None. Tolerant by design: a record with
|
|
74
|
+
an unparseable timestamp is counted, never dropped, so a schema change
|
|
75
|
+
undercounts nothing."""
|
|
76
|
+
if not isinstance(text, str):
|
|
77
|
+
return None
|
|
78
|
+
try:
|
|
79
|
+
cleaned = text.replace("Z", "").split(".")[0]
|
|
80
|
+
# timegm, not mktime: these stamps are UTC, and mktime would read them as
|
|
81
|
+
# local time and then drift again with DST.
|
|
82
|
+
return calendar.timegm(time.strptime(cleaned, "%Y-%m-%dT%H:%M:%S"))
|
|
83
|
+
except (ValueError, OverflowError):
|
|
84
|
+
return None
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
class Totals(object):
|
|
88
|
+
"""Token counters that know how to weight themselves."""
|
|
89
|
+
|
|
90
|
+
__slots__ = ("input", "cache_read", "cache_write", "output", "requests")
|
|
91
|
+
|
|
92
|
+
def __init__(self):
|
|
93
|
+
self.input = self.cache_read = self.cache_write = self.output = self.requests = 0
|
|
94
|
+
|
|
95
|
+
def add(self, inp=0, cache_read=0, cache_write=0, output=0):
|
|
96
|
+
self.input += inp or 0
|
|
97
|
+
self.cache_read += cache_read or 0
|
|
98
|
+
self.cache_write += cache_write or 0
|
|
99
|
+
self.output += output or 0
|
|
100
|
+
self.requests += 1
|
|
101
|
+
|
|
102
|
+
def effective(self):
|
|
103
|
+
return int(
|
|
104
|
+
self.input * WEIGHTS["input"]
|
|
105
|
+
+ self.cache_read * WEIGHTS["cache_read"]
|
|
106
|
+
+ self.cache_write * WEIGHTS["cache_write"]
|
|
107
|
+
+ self.output * WEIGHTS["output"]
|
|
108
|
+
)
|
|
109
|
+
|
|
110
|
+
def as_dict(self):
|
|
111
|
+
return {
|
|
112
|
+
"input": self.input, "cache_read": self.cache_read,
|
|
113
|
+
"cache_write": self.cache_write, "output": self.output,
|
|
114
|
+
"requests": self.requests, "effective": self.effective(),
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def _blank():
|
|
119
|
+
return {"main": Totals(), "subagent": Totals()}
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def scan_claude(since, root=None):
|
|
123
|
+
"""~/.claude/projects/<slug>/*.jsonl plus <session>/subagents/agent-*.jsonl."""
|
|
124
|
+
root = root or SOURCES["claude"]
|
|
125
|
+
out = {
|
|
126
|
+
"buckets": _blank(), "models": collections.Counter(), "dispatches": [],
|
|
127
|
+
"agent_types": collections.Counter(), "sessions": set(),
|
|
128
|
+
"compactions": 0, "precompact_tokens": 0,
|
|
129
|
+
}
|
|
130
|
+
if not os.path.isdir(root):
|
|
131
|
+
return None
|
|
132
|
+
|
|
133
|
+
seen = set()
|
|
134
|
+
for project in sorted(os.listdir(root)):
|
|
135
|
+
base = os.path.join(root, project)
|
|
136
|
+
if not os.path.isdir(base):
|
|
137
|
+
continue
|
|
138
|
+
files = [(p, "main") for p in glob.glob(os.path.join(base, "*.jsonl"))]
|
|
139
|
+
files += [(p, "subagent") for p in glob.glob(os.path.join(base, "*", "subagents", "agent-*.jsonl"))]
|
|
140
|
+
for path, bucket in files:
|
|
141
|
+
try:
|
|
142
|
+
# Cheap skip: a file untouched since the window opened cannot
|
|
143
|
+
# hold a record inside it.
|
|
144
|
+
if os.path.getmtime(path) < since:
|
|
145
|
+
continue
|
|
146
|
+
except OSError:
|
|
147
|
+
continue
|
|
148
|
+
before = out["buckets"][bucket].requests
|
|
149
|
+
_scan_claude_file(path, bucket, since, out, seen)
|
|
150
|
+
if bucket == "subagent" and out["buckets"][bucket].requests > before:
|
|
151
|
+
out["agent_types"][_agent_type(path)] += 1
|
|
152
|
+
out["sessions"] = len(out["sessions"])
|
|
153
|
+
out["agent_types"] = dict(out["agent_types"])
|
|
154
|
+
out["models"] = dict(out["models"])
|
|
155
|
+
return out
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
def _scan_claude_file(path, bucket, since, out, seen):
|
|
159
|
+
try:
|
|
160
|
+
handle = open(path, encoding="utf-8", errors="replace")
|
|
161
|
+
except OSError:
|
|
162
|
+
return
|
|
163
|
+
with handle as fh:
|
|
164
|
+
for line in fh:
|
|
165
|
+
# Substring prefilter before json.loads: only assistant records carry
|
|
166
|
+
# usage, and parsing every line of 1.2 GB to discover that is the
|
|
167
|
+
# difference between seconds and minutes.
|
|
168
|
+
if '"assistant"' not in line and "compact_boundary" not in line:
|
|
169
|
+
continue
|
|
170
|
+
try:
|
|
171
|
+
rec = json.loads(line)
|
|
172
|
+
except ValueError:
|
|
173
|
+
continue
|
|
174
|
+
if not isinstance(rec, dict):
|
|
175
|
+
continue
|
|
176
|
+
|
|
177
|
+
stamp = _iso_epoch(rec.get("timestamp"))
|
|
178
|
+
if stamp is not None and stamp < since:
|
|
179
|
+
continue
|
|
180
|
+
|
|
181
|
+
if rec.get("subtype") == "compact_boundary":
|
|
182
|
+
meta = rec.get("compactMetadata") or {}
|
|
183
|
+
out["compactions"] += 1
|
|
184
|
+
out["precompact_tokens"] += meta.get("preTokens") or 0
|
|
185
|
+
continue
|
|
186
|
+
if rec.get("type") != "assistant":
|
|
187
|
+
continue
|
|
188
|
+
|
|
189
|
+
message = rec.get("message") or {}
|
|
190
|
+
if not isinstance(message, dict):
|
|
191
|
+
continue
|
|
192
|
+
# Dispatch blocks live in their own content-block record, which shares
|
|
193
|
+
# a requestId with the one carrying usage -- so collect them BEFORE
|
|
194
|
+
# the dedupe, or every dispatch after the first block is invisible.
|
|
195
|
+
_collect_dispatches(message, out)
|
|
196
|
+
|
|
197
|
+
key = rec.get("requestId") or message.get("id")
|
|
198
|
+
if key is not None:
|
|
199
|
+
if key in seen:
|
|
200
|
+
continue # same response, another content block
|
|
201
|
+
seen.add(key)
|
|
202
|
+
|
|
203
|
+
usage = message.get("usage") or {}
|
|
204
|
+
out["buckets"][bucket].add(
|
|
205
|
+
usage.get("input_tokens"),
|
|
206
|
+
usage.get("cache_read_input_tokens"),
|
|
207
|
+
usage.get("cache_creation_input_tokens"),
|
|
208
|
+
usage.get("output_tokens"),
|
|
209
|
+
)
|
|
210
|
+
if message.get("model"):
|
|
211
|
+
out["models"][message["model"]] += 1
|
|
212
|
+
if rec.get("sessionId"):
|
|
213
|
+
out["sessions"].add(rec["sessionId"])
|
|
214
|
+
|
|
215
|
+
|
|
216
|
+
|
|
217
|
+
def _collect_dispatches(message, out):
|
|
218
|
+
content = message.get("content")
|
|
219
|
+
if not isinstance(content, list):
|
|
220
|
+
return
|
|
221
|
+
for block in content:
|
|
222
|
+
if not isinstance(block, dict) or block.get("type") != "tool_use":
|
|
223
|
+
continue
|
|
224
|
+
if block.get("name") not in DISPATCH_TOOLS:
|
|
225
|
+
continue
|
|
226
|
+
args = block.get("input")
|
|
227
|
+
if not isinstance(args, dict):
|
|
228
|
+
continue
|
|
229
|
+
out["dispatches"].append({
|
|
230
|
+
"agent": args.get("subagent_type") or "-",
|
|
231
|
+
"model": args.get("model"),
|
|
232
|
+
"prompt_bytes": len((args.get("prompt") or "").encode("utf-8", "replace")),
|
|
233
|
+
})
|
|
234
|
+
|
|
235
|
+
|
|
236
|
+
def _agent_type(path):
|
|
237
|
+
"""agentType from the sidecar. 31 of 1231 transcripts have none, so the
|
|
238
|
+
absence is normal and must never raise."""
|
|
239
|
+
try:
|
|
240
|
+
with open(path[: -len(".jsonl")] + ".meta.json", encoding="utf-8") as fh:
|
|
241
|
+
return (json.load(fh) or {}).get("agentType") or "unknown"
|
|
242
|
+
except (OSError, ValueError):
|
|
243
|
+
return "unknown"
|
|
244
|
+
|
|
245
|
+
|
|
246
|
+
def scan_codex(since, root=None):
|
|
247
|
+
"""~/.codex/sessions/<Y>/<M>/<D>/rollout-*.jsonl -- token_count deltas."""
|
|
248
|
+
root = root or SOURCES["codex"]
|
|
249
|
+
if not os.path.isdir(root):
|
|
250
|
+
return None
|
|
251
|
+
out = {"buckets": _blank(), "models": {}, "sessions": 0, "subagent_events": 0}
|
|
252
|
+
files = glob.glob(os.path.join(root, "*", "*", "*", "rollout-*.jsonl"))
|
|
253
|
+
files += glob.glob(os.path.join(root, "rollout-*.jsonl"))
|
|
254
|
+
for path in files:
|
|
255
|
+
try:
|
|
256
|
+
if os.path.getmtime(path) < since:
|
|
257
|
+
continue
|
|
258
|
+
except OSError:
|
|
259
|
+
continue
|
|
260
|
+
out["sessions"] += 1
|
|
261
|
+
try:
|
|
262
|
+
handle = open(path, encoding="utf-8", errors="replace")
|
|
263
|
+
except OSError:
|
|
264
|
+
continue
|
|
265
|
+
with handle as fh:
|
|
266
|
+
for line in fh:
|
|
267
|
+
if "token_count" not in line and "sub_agent_activity" not in line:
|
|
268
|
+
continue
|
|
269
|
+
try:
|
|
270
|
+
rec = json.loads(line)
|
|
271
|
+
except ValueError:
|
|
272
|
+
continue
|
|
273
|
+
if not isinstance(rec, dict):
|
|
274
|
+
continue
|
|
275
|
+
stamp = _iso_epoch(rec.get("timestamp"))
|
|
276
|
+
if stamp is not None and stamp < since:
|
|
277
|
+
continue
|
|
278
|
+
payload = rec.get("payload") or {}
|
|
279
|
+
if not isinstance(payload, dict):
|
|
280
|
+
continue
|
|
281
|
+
if payload.get("type") == "sub_agent_activity":
|
|
282
|
+
out["subagent_events"] += 1
|
|
283
|
+
continue
|
|
284
|
+
if payload.get("type") != "token_count":
|
|
285
|
+
continue
|
|
286
|
+
# last_token_usage is the delta for this request; total_token_usage
|
|
287
|
+
# is cumulative and must never be summed.
|
|
288
|
+
last = ((payload.get("info") or {}).get("last_token_usage")) or {}
|
|
289
|
+
out["buckets"]["main"].add(
|
|
290
|
+
last.get("input_tokens"),
|
|
291
|
+
last.get("cached_input_tokens"),
|
|
292
|
+
last.get("cache_write_input_tokens"),
|
|
293
|
+
last.get("output_tokens"),
|
|
294
|
+
)
|
|
295
|
+
return out
|
|
296
|
+
|
|
297
|
+
|
|
298
|
+
def scan_opencode(since, db=None):
|
|
299
|
+
"""The one harness with a pre-aggregated per-session rollup, cost included."""
|
|
300
|
+
db = db or SOURCES["opencode"]
|
|
301
|
+
if not os.path.isfile(db):
|
|
302
|
+
return None
|
|
303
|
+
try:
|
|
304
|
+
import sqlite3
|
|
305
|
+
# Read-only URI: never take a write lock on a live harness's database.
|
|
306
|
+
conn = sqlite3.connect("file:%s?mode=ro" % db, uri=True, timeout=2.0)
|
|
307
|
+
except Exception as exc:
|
|
308
|
+
return {"error": "%s: %s" % (type(exc).__name__, exc)}
|
|
309
|
+
out = {"buckets": _blank(), "cost": 0.0, "sessions": 0, "models": collections.Counter()}
|
|
310
|
+
try:
|
|
311
|
+
rows = conn.execute(
|
|
312
|
+
"SELECT tokens_input, tokens_output, tokens_cache_read, tokens_cache_write, "
|
|
313
|
+
"cost, model, parent_id FROM session WHERE time_updated >= ?",
|
|
314
|
+
(int(since * 1000),),
|
|
315
|
+
).fetchall()
|
|
316
|
+
except Exception as exc:
|
|
317
|
+
conn.close()
|
|
318
|
+
return {"error": "%s: %s" % (type(exc).__name__, exc)}
|
|
319
|
+
conn.close()
|
|
320
|
+
for inp, output, cread, cwrite, cost, model, parent in rows:
|
|
321
|
+
out["sessions"] += 1
|
|
322
|
+
out["cost"] += cost or 0.0
|
|
323
|
+
out["buckets"]["subagent" if parent else "main"].add(inp, cread, cwrite, output)
|
|
324
|
+
if model:
|
|
325
|
+
out["models"][model] += 1
|
|
326
|
+
out["models"] = dict(out["models"])
|
|
327
|
+
return out
|
|
328
|
+
|
|
329
|
+
|
|
330
|
+
def scan_guard():
|
|
331
|
+
"""The guard's own record, and whether its blocks changed anything."""
|
|
332
|
+
try:
|
|
333
|
+
import dispatch_log
|
|
334
|
+
return dispatch_log.summarise(dispatch_log.read())
|
|
335
|
+
except Exception as exc:
|
|
336
|
+
return {"error": "%s: %s" % (type(exc).__name__, exc)}
|
|
337
|
+
|
|
338
|
+
|
|
339
|
+
def routing_compliance(dispatches):
|
|
340
|
+
"""Problem (c), quantified: how many dispatches named a tier, and how many
|
|
341
|
+
let the parent's model ride along."""
|
|
342
|
+
tiers = collections.Counter()
|
|
343
|
+
inherited_bytes = 0
|
|
344
|
+
for d in dispatches:
|
|
345
|
+
agent = d.get("agent") or "-"
|
|
346
|
+
if agent.startswith("leo-"):
|
|
347
|
+
tiers[agent] += 1
|
|
348
|
+
elif d.get("model"):
|
|
349
|
+
tiers["explicit model"] += 1
|
|
350
|
+
else:
|
|
351
|
+
tiers["inherited"] += 1
|
|
352
|
+
inherited_bytes += d.get("prompt_bytes") or 0
|
|
353
|
+
total = sum(tiers.values())
|
|
354
|
+
return {
|
|
355
|
+
"dispatches": total,
|
|
356
|
+
"tiers": dict(tiers),
|
|
357
|
+
"inherited_share": round(tiers["inherited"] / total, 3) if total else 0.0,
|
|
358
|
+
"inherited_brief_bytes": inherited_bytes,
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
|
|
362
|
+
def collect(since, only=None):
|
|
363
|
+
report = {"since": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime(since)), "harnesses": {}}
|
|
364
|
+
scanners = {"claude": scan_claude, "codex": scan_codex, "opencode": scan_opencode}
|
|
365
|
+
for name in ("claude", "codex", "opencode", "cursor", "hermes", "pi"):
|
|
366
|
+
if only and name != only:
|
|
367
|
+
continue
|
|
368
|
+
scanner = scanners.get(name)
|
|
369
|
+
data = scanner(since) if scanner else None
|
|
370
|
+
if data is None:
|
|
371
|
+
report["harnesses"][name] = {"status": "no data", "looked_in": SOURCES[name]}
|
|
372
|
+
continue
|
|
373
|
+
buckets = data.pop("buckets", None)
|
|
374
|
+
if buckets:
|
|
375
|
+
data["main"] = buckets["main"].as_dict()
|
|
376
|
+
data["subagent"] = buckets["subagent"].as_dict()
|
|
377
|
+
main, sub = data["main"]["effective"], data["subagent"]["effective"]
|
|
378
|
+
data["subagent_share"] = round(sub / (main + sub), 3) if (main + sub) else 0.0
|
|
379
|
+
data["status"] = "ok"
|
|
380
|
+
report["harnesses"][name] = data
|
|
381
|
+
|
|
382
|
+
claude = report["harnesses"].get("claude") or {}
|
|
383
|
+
report["routing"] = routing_compliance(claude.get("dispatches") or [])
|
|
384
|
+
claude.pop("dispatches", None)
|
|
385
|
+
report["guard"] = scan_guard()
|
|
386
|
+
return report
|
|
387
|
+
|
|
388
|
+
|
|
389
|
+
def render(report):
|
|
390
|
+
lines = ["leos-agent usage and effectiveness, since %s" % report["since"], ""]
|
|
391
|
+
for name, data in sorted(report["harnesses"].items()):
|
|
392
|
+
if data.get("status") != "ok":
|
|
393
|
+
lines.append("%-9s no data (%s)" % (name, data["looked_in"]))
|
|
394
|
+
continue
|
|
395
|
+
if data.get("error"):
|
|
396
|
+
lines.append("%-9s unreadable: %s" % (name, data["error"]))
|
|
397
|
+
continue
|
|
398
|
+
main, sub = data.get("main", {}), data.get("subagent", {})
|
|
399
|
+
lines.append("%-9s %d session(s) effective tokens: main %s, subagents %s (%.0f%% delegated)" % (
|
|
400
|
+
name, data.get("sessions", 0), "{:,}".format(main.get("effective", 0)),
|
|
401
|
+
"{:,}".format(sub.get("effective", 0)), 100 * data.get("subagent_share", 0.0)))
|
|
402
|
+
if main.get("cache_write"):
|
|
403
|
+
ratio = main["cache_read"] / main["cache_write"]
|
|
404
|
+
lines.append(" cache read/write ratio %.1f (higher is cheaper; a low ratio means cold prefixes)" % ratio)
|
|
405
|
+
if data.get("cost"):
|
|
406
|
+
lines.append(" provider-reported cost $%.2f" % data["cost"])
|
|
407
|
+
if data.get("compactions"):
|
|
408
|
+
lines.append(" %d compaction(s), %s tokens discarded" % (
|
|
409
|
+
data["compactions"], "{:,}".format(data["precompact_tokens"])))
|
|
410
|
+
if data.get("agent_types"):
|
|
411
|
+
top = sorted(data["agent_types"].items(), key=lambda kv: -kv[1])[:6]
|
|
412
|
+
lines.append(" subagents: " + ", ".join("%s %d" % kv for kv in top))
|
|
413
|
+
|
|
414
|
+
routing = report["routing"]
|
|
415
|
+
lines += ["", "Routing compliance (Claude Code dispatches seen in transcripts)"]
|
|
416
|
+
if not routing["dispatches"]:
|
|
417
|
+
lines.append(" none in this window")
|
|
418
|
+
else:
|
|
419
|
+
lines.append(" %d dispatch(es): %s" % (
|
|
420
|
+
routing["dispatches"], ", ".join("%s %d" % kv for kv in sorted(routing["tiers"].items()))))
|
|
421
|
+
lines.append(" %.0f%% named no model and inherited the parent's" % (100 * routing["inherited_share"]))
|
|
422
|
+
|
|
423
|
+
guard = report["guard"]
|
|
424
|
+
lines += ["", "Guard"]
|
|
425
|
+
if guard.get("error"):
|
|
426
|
+
lines.append(" log unreadable: %s" % guard["error"])
|
|
427
|
+
elif not guard.get("records"):
|
|
428
|
+
lines.append(" no dispatches recorded -- the guard may not be installed or approved on any harness")
|
|
429
|
+
else:
|
|
430
|
+
lines.append(" %d recorded, %d blocked, %d re-dispatched with a tier named" % (
|
|
431
|
+
guard["records"], guard.get("blocked", 0), guard.get("converted", 0)))
|
|
432
|
+
lines.append(" %d lone small spawn(s) (fan-outs excluded)" % guard.get("trivial_lone_spawns", 0))
|
|
433
|
+
if guard.get("errors"):
|
|
434
|
+
lines.append(" !! %d guard error(s): it failed open this many times" % guard["errors"])
|
|
435
|
+
missing = [h for h, d in report["harnesses"].items()
|
|
436
|
+
if d.get("status") == "ok" and h not in (guard.get("harnesses") or {})]
|
|
437
|
+
if missing:
|
|
438
|
+
lines.append(" no guard rows from: %s -- installed but not enforcing?" % ", ".join(sorted(missing)))
|
|
439
|
+
return "\n".join(lines)
|
|
440
|
+
|
|
441
|
+
|
|
442
|
+
def main(argv=None):
|
|
443
|
+
parser = argparse.ArgumentParser(prog="usage_scan.py", description=__doc__.splitlines()[0])
|
|
444
|
+
parser.add_argument("--since", default="7d", help="window, e.g. 24h, 7d, 2w (default 7d)")
|
|
445
|
+
parser.add_argument("--harness", choices=sorted(SOURCES), help="only this harness")
|
|
446
|
+
parser.add_argument("--json", action="store_true", help="machine-readable")
|
|
447
|
+
args = parser.parse_args(argv)
|
|
448
|
+
|
|
449
|
+
report = collect(parse_since(args.since), args.harness)
|
|
450
|
+
print(json.dumps(report, indent=1, sort_keys=True) if args.json else render(report))
|
|
451
|
+
return 0
|
|
452
|
+
|
|
453
|
+
|
|
454
|
+
if __name__ == "__main__":
|
|
455
|
+
sys.exit(main())
|