leos-agent 10.6.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.
@@ -0,0 +1,317 @@
1
+ #!/usr/bin/env python3
2
+ """dispatch_guard: refuse a subagent dispatch that names no model.
3
+
4
+ WHY THIS EXISTS IN CODE RATHER THAN PROSE. The policy already said every dispatch
5
+ must name a model, and the policy was forgotten. Prose enforcement also costs
6
+ always-loaded bytes on every turn of every session, and the budget in
7
+ measure_context.py is nearly spent -- so the rule that a machine can check moved
8
+ into a hook, which costs nothing, and the payload kept only the judgment a hook
9
+ cannot make. Moving it out of the payload was what paid for the move.
10
+
11
+ WHAT IT REFUSES, AND WHAT IT DELIBERATELY DOES NOT. A dispatch that selects an
12
+ agent, carries a brief, names no model, and runs on a harness that can express
13
+ one per spawn is refused -- because on that path the harness silently inherits
14
+ the parent's expensive model, which is the failure this file exists to prevent.
15
+ Everything else is allowed. The guard NEVER picks a model: it cannot force a
16
+ cheap tier onto work that needed an expensive one, so it cannot cause a quality
17
+ regression, only an explicit choice. That is also why the block stays narrow. A
18
+ false block costs one re-dispatch turn; a caught inherited fan-out saves the cold
19
+ prefix of every child it would have spawned. The margin is wide precisely because
20
+ the rule refuses to make judgment calls, and every widening spends it.
21
+
22
+ SHAPE-BASED, NOT NAME-BASED. Only Claude Code's dispatch tool is verified; the
23
+ argument shapes on Codex, Cursor, Hermes and OpenCode are not. So the decision
24
+ turns on the arguments -- an agent-selection field plus a brief -- and an
25
+ unanticipated tool degrades to a no-op rather than a broken harness. The hook
26
+ manifests still carry a name matcher, but only as a cheap prefilter: without one,
27
+ every Read and Grep would pay a python3 spawn.
28
+
29
+ FAILS OPEN, LOUDLY. The harm here is money, not data loss. A guard that fails
30
+ closed on a broken interpreter wedges every dispatch on every harness, which is
31
+ far worse than the miss it prevents -- and on Claude Code a timed-out hook is
32
+ non-blocking anyway, so fail-closed is not even expressible there. Every internal
33
+ error allows the call and leaves a breadcrumb with decision "error", kept
34
+ rigorously distinct from a decision to allow.
35
+
36
+ LEOS_AGENT_DISPATCH_GUARD on (default) | warn (log, never block) | off | verbose
37
+ LEOS_AGENT_HARNESS overrides harness detection
38
+
39
+ Exit codes: 0 allow, 2 block (reason on stderr).
40
+ """
41
+ import collections
42
+ import json
43
+ import os
44
+ import re
45
+ import sys
46
+ import time
47
+
48
+ sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
49
+
50
+ Dispatch = collections.namedtuple(
51
+ "Dispatch",
52
+ "tool agent model prompt_bytes prompt_lines path_count prompt_hash prompt_head",
53
+ )
54
+
55
+ # The agent-selection field is mandatory, and that is the whole trick. A rule
56
+ # keyed on "has a prompt" would refuse unrelated tools -- spawn_task takes
57
+ # {prompt, title, tldr} and nothing else -- and "has a model" is weaker still.
58
+ # Only naming an agent means "I am choosing who runs this".
59
+ AGENT_KEYS = ("subagent_type", "subagentType", "agent_type", "agentType", "agent", "subagent", "profile")
60
+ PROMPT_KEYS = ("prompt", "brief", "instructions", "task", "message", "input")
61
+ MODEL_KEYS = ("model", "model_id", "modelId", "model_name")
62
+
63
+ TOOL_KEYS = ("tool_name", "toolName", "tool", "name")
64
+ INPUT_KEYS = ("tool_input", "toolInput", "arguments", "args", "input", "params", "parameters")
65
+
66
+ # Third-party MCP tools are not routed by this policy and never will be, and
67
+ # their argument namespaces are outside our control forever. One prefix test
68
+ # removes the entire class of false positives they would otherwise create.
69
+ SKIP_PREFIXES = ("mcp__",)
70
+
71
+ # Seeded empty on purpose: populate from field reports, not from guesses.
72
+ SKIP_TOOLS = frozenset()
73
+
74
+ ALLOW, BLOCK = "allow", "block"
75
+
76
+ # Path-ish tokens in a brief. Scanning is capped at the first 8 KiB -- the
77
+ # feature does not improve past that and a hot path should not read a novel.
78
+ PATH_SCAN_BYTES = 8192
79
+ PATH_RE = re.compile(r"[\w.-]+/[\w./-]+|[\w-]+\.[A-Za-z]{1,5}\b")
80
+
81
+
82
+ def _first_str(source, keys):
83
+ for key in keys:
84
+ value = source.get(key)
85
+ if isinstance(value, str) and value.strip():
86
+ return value
87
+ return ""
88
+
89
+
90
+ def _first_dict(source, keys):
91
+ for key in keys:
92
+ value = source.get(key)
93
+ if isinstance(value, dict):
94
+ return value
95
+ return None
96
+
97
+
98
+ def harness(event):
99
+ """Which harness this event came from. Best effort, and logged either way.
100
+
101
+ The env var is the override the in-process adapters set. Otherwise the
102
+ transcript path is the only hint a command hook gets; when it says nothing,
103
+ assume Claude Code, the one harness whose dispatch tool is verified.
104
+ """
105
+ named = os.environ.get("LEOS_AGENT_HARNESS", "").strip().lower()
106
+ if named:
107
+ return named
108
+ hint = ""
109
+ if isinstance(event, dict):
110
+ hint = _first_str(event, ("transcript_path", "transcriptPath", "cwd"))
111
+ for name in ("codex", "cursor", "hermes", "opencode"):
112
+ if "/." + name in hint or "/" + name + "/" in hint:
113
+ return name
114
+ return "claude"
115
+
116
+
117
+ def normalize(event, _harness=None):
118
+ """event -> Dispatch, or None when this is not a subagent dispatch.
119
+
120
+ Never raises. Five harnesses' envelopes are unverified, and a KeyError here
121
+ would turn a routing guard into an outage.
122
+ """
123
+ if not isinstance(event, dict):
124
+ return None
125
+ tool = _first_str(event, TOOL_KEYS)
126
+ if tool in SKIP_TOOLS or any(tool.startswith(p) for p in SKIP_PREFIXES):
127
+ return None
128
+ args = _first_dict(event, INPUT_KEYS)
129
+ if args is None:
130
+ return None
131
+
132
+ agent = _first_str(args, AGENT_KEYS)
133
+ prompt = _first_str(args, PROMPT_KEYS)
134
+ if not agent or not prompt:
135
+ return None
136
+
137
+ from dispatch_log import digest # local: a no-op call must not pay for this
138
+
139
+ head = prompt[:PATH_SCAN_BYTES]
140
+ return Dispatch(
141
+ tool=tool or "-",
142
+ agent=agent,
143
+ model=_first_str(args, MODEL_KEYS) or None,
144
+ prompt_bytes=len(prompt.encode("utf-8", "replace")),
145
+ prompt_lines=prompt.count("\n") + 1,
146
+ path_count=len(set(PATH_RE.findall(head))),
147
+ prompt_hash=digest(prompt),
148
+ prompt_head=prompt[:200],
149
+ )
150
+
151
+
152
+ def triviality(dispatch):
153
+ """0..3. A features-only score; it never changes the exit code.
154
+
155
+ Say plainly what this can and cannot see: a trivial spawn has small *work*,
156
+ and tool input only shows the *brief*. The two failure modes happen to
157
+ collapse -- a short brief is either work too small to deserve a cold context
158
+ or work that is under-briefed, and the policy forbids both -- but roughly one
159
+ in six legitimate runner dispatches will still score here. That rate is fine
160
+ for a log line and disqualifying for a block, which is why this is only ever
161
+ a log line.
162
+ """
163
+ if dispatch is None:
164
+ return 0
165
+ score = 0
166
+ if dispatch.prompt_bytes < 220:
167
+ score += 2
168
+ elif dispatch.prompt_bytes < 450:
169
+ score += 1
170
+ if dispatch.path_count == 1:
171
+ score += 1
172
+ if dispatch.prompt_lines == 1:
173
+ score += 1
174
+ return min(score, 3)
175
+
176
+
177
+ def routable(name):
178
+ """Can this harness name a model per spawn?
179
+
180
+ Claude Code's dispatch tool takes one, and any harness Leo has configured in
181
+ routing.json has a model to name. Everywhere else the payload itself says to
182
+ inherit and say so -- blocking there would demand something the harness
183
+ cannot do, a false positive by construction.
184
+ """
185
+ if name == "claude":
186
+ return True
187
+ try:
188
+ import routing
189
+ return name in routing.load()
190
+ except BaseException:
191
+ # routing.py exits on a malformed config. A hook must never inherit that.
192
+ return False
193
+
194
+
195
+ def decide(dispatch, name, is_routable):
196
+ """(action, reason). The entire policy, and deliberately four lines of it."""
197
+ if dispatch is None:
198
+ return ALLOW, "not-a-dispatch"
199
+ if dispatch.agent.startswith("leo-"):
200
+ return ALLOW, "leo-tier" # the agent definition carries the model
201
+ if dispatch.model:
202
+ return ALLOW, "explicit-model" # a choice was typed; cheap or not
203
+ if not is_routable:
204
+ return ALLOW, "harness-cannot-route"
205
+ return BLOCK, "no-model"
206
+
207
+
208
+ def render_block(dispatch):
209
+ """The refusal. It must name the remedies, or it costs a turn to discover them."""
210
+ return (
211
+ '[leo routing] BLOCKED - dispatch to agent "%s" names no model, so it would\n'
212
+ "silently inherit the parent's. Re-dispatch with one of:\n"
213
+ ' subagent_type "leo-runner" reading, search, tests, logs, codemods, fan-out\n'
214
+ ' subagent_type "leo-executor" an approved plan or a specified change\n'
215
+ ' model: "<name>" investigation/debugging - naming it IS the reason\n'
216
+ "Set LEOS_AGENT_DISPATCH_GUARD=off to disable, =warn to log only."
217
+ ) % dispatch.agent
218
+
219
+
220
+ def render_notice(dispatch):
221
+ return (
222
+ "[leo routing] %d-byte brief to %s: a fresh agent context is uncached and "
223
+ "costs more than an inline read. Inline it when one file answers it."
224
+ ) % (dispatch.prompt_bytes, dispatch.agent)
225
+
226
+
227
+ def _log(entry):
228
+ """Best effort, always. A breadcrumb that cannot be written must not break a
229
+ dispatch, so every failure here is swallowed -- including a missing module."""
230
+ try:
231
+ import dispatch_log
232
+ dispatch_log.append(entry)
233
+ except Exception:
234
+ pass
235
+
236
+
237
+ def evaluate(event, name=None):
238
+ """(action, reason, dispatch, trivial) for one event. Shared by every adapter.
239
+
240
+ An in-process adapter knows which harness it is and passes `name`; detection
241
+ exists only for the command hooks, which get no say.
242
+ """
243
+ name = name or harness(event)
244
+ dispatch = normalize(event, name)
245
+ action, reason = decide(dispatch, name, routable(name))
246
+ return action, reason, dispatch, triviality(dispatch)
247
+
248
+
249
+ def main(argv=None):
250
+ mode = os.environ.get("LEOS_AGENT_DISPATCH_GUARD", "on").strip().lower()
251
+ if mode == "off":
252
+ return 0
253
+
254
+ try:
255
+ raw = sys.stdin.buffer.read().decode("utf-8", "replace")
256
+ event = json.loads(raw) if raw.strip() else None
257
+ except Exception:
258
+ return 0
259
+
260
+ name = harness(event if isinstance(event, dict) else {})
261
+ try:
262
+ action, reason, dispatch, trivial = evaluate(event)
263
+ except Exception as exc:
264
+ # The guard broke. Allow, and say so distinctly -- an "error" row is the
265
+ # only way a dead guard is ever noticed.
266
+ _breadcrumb(name, exc)
267
+ return 0
268
+
269
+ if dispatch is None:
270
+ return 0
271
+
272
+ session = _first_str(event, ("session_id", "sessionId")) if isinstance(event, dict) else ""
273
+ cwd = _first_str(event, ("cwd", "workspace", "directory")) if isinstance(event, dict) else ""
274
+
275
+ try:
276
+ import dispatch_log
277
+ entry = dispatch_log.record(dispatch, action, reason, name, session, cwd, trivial)
278
+ _log(entry)
279
+ except Exception:
280
+ pass
281
+
282
+ if action == BLOCK and mode != "warn":
283
+ sys.stderr.write(render_block(dispatch) + "\n")
284
+ return 2
285
+
286
+ # A notice is user-facing only. systemMessage reaches Leo's transcript and
287
+ # never the model's context, so a false positive here costs zero tokens.
288
+ # verbose additionally spends ~60 tokens putting it in front of the model;
289
+ # it stays off until the log says the problem is frequent enough to be worth
290
+ # paying for.
291
+ if trivial >= 2:
292
+ payload = {"systemMessage": render_notice(dispatch)}
293
+ if mode == "verbose":
294
+ payload["hookSpecificOutput"] = {
295
+ "hookEventName": "PreToolUse",
296
+ "additionalContext": render_notice(dispatch),
297
+ }
298
+ sys.stdout.write(json.dumps(payload) + "\n")
299
+ return 0
300
+
301
+
302
+ def _breadcrumb(name, exc):
303
+ try:
304
+ import dispatch_log
305
+ dispatch_log.append({
306
+ "v": dispatch_log.RECORD_VERSION,
307
+ "ts": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()),
308
+ "harness": name,
309
+ "decision": "error",
310
+ "reason": "%s: %s" % (type(exc).__name__, exc),
311
+ })
312
+ except Exception:
313
+ pass
314
+
315
+
316
+ if __name__ == "__main__":
317
+ sys.exit(main())
@@ -0,0 +1,240 @@
1
+ #!/usr/bin/env python3
2
+ """dispatch_log: the append-only record of every subagent dispatch the guard saw.
3
+
4
+ WHY A LOG AT ALL. dispatch_guard.py decides one call at a time and can never see
5
+ a fan-out, so the signals that need a second dispatch to interpret -- was a block
6
+ followed by a compliant re-dispatch, was a small brief one of five siblings --
7
+ are recorded here and resolved at read time. Judgment that lives in the reader
8
+ costs nothing on the hot path and can be revised without a Codex /hooks
9
+ re-approval.
10
+
11
+ NEVER PROMPT TEXT. This file sits in a home directory forever and would otherwise
12
+ accumulate briefs about whatever Leo works on. Prompts and working directories
13
+ are stored as truncated SHA-256, which is enough to notice the same brief
14
+ re-dispatched after a block and useless to anyone reading the file. Raw text only
15
+ under LEOS_AGENT_DISPATCH_LOG_PROMPTS=1, truncated, and documented as debug-only.
16
+
17
+ The file is ${LEOS_AGENT_LOCAL_PATH:-$HOME/.leos-agent-local}/dispatch.jsonl,
18
+ beside routing.json and the handoffs -- data lives with the data, never inside
19
+ the plugin, so an upgrade or an uninstall cannot take it.
20
+
21
+ dispatch_log.py report [--limit N] [--json] what the guard has been seeing
22
+ dispatch_log.py path the log's path
23
+
24
+ Exit codes: 0 ok, 2 on bad usage.
25
+ """
26
+ import argparse
27
+ import collections
28
+ import hashlib
29
+ import json
30
+ import os
31
+ import sys
32
+ import time
33
+
34
+ sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
35
+ # Same data root and the same lock discipline as every other machine-local file.
36
+ # _locked takes any path (it locks a sibling .lock), which is why a .jsonl can
37
+ # use it even though state_file() would force a .json suffix.
38
+ from state import _data_root, _locked # noqa: E402
39
+
40
+ LOG_NAME = "dispatch.jsonl"
41
+
42
+ # One megabyte, one generation. ~230 bytes per record is roughly 4,500 dispatches
43
+ # per file and 9,000 retained -- months of history, bounded at 2 MiB forever, with
44
+ # no cron job and nothing to configure.
45
+ MAX_BYTES = 1 << 20
46
+
47
+ RECORD_VERSION = 1
48
+
49
+
50
+ def path():
51
+ return os.path.join(_data_root(), LOG_NAME)
52
+
53
+
54
+ def digest(text):
55
+ """A short, irreversible stand-in for text we refuse to store."""
56
+ if not text:
57
+ return None
58
+ return hashlib.sha256(text.encode("utf-8", "replace")).hexdigest()[:12]
59
+
60
+
61
+ def _keep_prompts():
62
+ return os.environ.get("LEOS_AGENT_DISPATCH_LOG_PROMPTS") == "1"
63
+
64
+
65
+ def record(dispatch, decision, reason, harness, session=None, cwd=None, trivial=0):
66
+ """The on-disk shape for one dispatch. Pure; writes nothing."""
67
+ entry = {
68
+ "v": RECORD_VERSION,
69
+ "ts": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()),
70
+ "harness": harness,
71
+ "session": digest(session),
72
+ # Parallel dispatches from one assistant message land in the same
73
+ # two-second bucket, which is how `report` tells a fan-out from a
74
+ # sequence of lone spawns without the hot path ever reading the log.
75
+ "burst": "%s:%d" % (digest(session) or "-", int(time.time() // 2)),
76
+ "project": digest(cwd),
77
+ "decision": decision,
78
+ "reason": reason,
79
+ }
80
+ if dispatch is not None:
81
+ entry.update({
82
+ "tool": dispatch.tool,
83
+ "agent": dispatch.agent,
84
+ "model": dispatch.model,
85
+ "trivial": trivial,
86
+ "prompt_bytes": dispatch.prompt_bytes,
87
+ "prompt_lines": dispatch.prompt_lines,
88
+ "paths": dispatch.path_count,
89
+ "prompt": dispatch.prompt_hash,
90
+ })
91
+ if _keep_prompts():
92
+ entry["prompt_text"] = dispatch.prompt_head
93
+ return entry
94
+
95
+
96
+ def append(entry):
97
+ """Append one record. Rotates at MAX_BYTES. Raises only on real I/O trouble.
98
+
99
+ Callers must treat a failure here as cosmetic: the guard's decision is made
100
+ before this is called and emitted after it, so a read-only home or a full
101
+ disk costs a log line, never a dispatch.
102
+ """
103
+ target = path()
104
+ with _locked(target):
105
+ try:
106
+ if os.path.getsize(target) > MAX_BYTES:
107
+ os.replace(target, target + ".1")
108
+ except OSError:
109
+ pass # absent, or unstattable; either way there is nothing to rotate
110
+ # 0600 explicitly: open("a") yields 0644 under a default umask, which is
111
+ # the wrong mode for a file that indexes Leo's projects even in hashes.
112
+ fd = os.open(target, os.O_WRONLY | os.O_CREAT | os.O_APPEND, 0o600)
113
+ with os.fdopen(fd, "a") as fh:
114
+ fh.write(json.dumps(entry, sort_keys=True) + "\n")
115
+
116
+
117
+ def read(limit=None, target=None):
118
+ """Records, oldest first. Tolerates a truncated final line from a crash."""
119
+ out = []
120
+ for candidate in ((target,) if target else (path() + ".1", path())):
121
+ try:
122
+ with open(candidate, encoding="utf-8", errors="replace") as fh:
123
+ for line in fh:
124
+ line = line.strip()
125
+ if not line:
126
+ continue
127
+ try:
128
+ entry = json.loads(line)
129
+ except ValueError:
130
+ continue # a half-written last line, or a foreign line
131
+ if isinstance(entry, dict):
132
+ out.append(entry)
133
+ except FileNotFoundError:
134
+ continue
135
+ except OSError as exc:
136
+ sys.exit("dispatch_log: %s: %s" % (candidate, exc.strerror or exc))
137
+ return out[-limit:] if limit else out
138
+
139
+
140
+ def summarise(entries):
141
+ """The read-time judgment: burst collapse, tiers, and block conversion."""
142
+ # Only dispatches that actually ran count toward a burst. A block and the
143
+ # re-dispatch it forced land in the same two-second bucket, and counting
144
+ # both would let every blocked retry pose as a fan-out of two.
145
+ bursts = collections.Counter(
146
+ e.get("burst") for e in entries if e.get("decision") not in ("block", "error")
147
+ )
148
+
149
+ tiers = collections.Counter()
150
+ for entry in entries:
151
+ agent = entry.get("agent") or ""
152
+ if agent.startswith("leo-"):
153
+ tiers[agent] += 1
154
+ elif entry.get("model"):
155
+ tiers["explicit model"] += 1
156
+ elif entry.get("decision") == "block":
157
+ tiers["blocked"] += 1
158
+ else:
159
+ tiers["inherited"] += 1
160
+
161
+ # A trivial-looking brief that was one of several in the same burst is a
162
+ # fan-out, which the policy wants. Only a lone small spawn is a finding --
163
+ # and only one that actually ran: a blocked dispatch spent nothing, so it
164
+ # cannot also be an over-delegation.
165
+ trivial = [
166
+ e for e in entries
167
+ if e.get("trivial", 0) >= 2
168
+ and e.get("decision") not in ("block", "error")
169
+ and bursts.get(e.get("burst"), 0) < 2
170
+ ]
171
+
172
+ # Did a block actually change anything? A blocked brief whose hash comes back
173
+ # naming a tier is the guard working; one that never returns was abandoned.
174
+ blocked = {e.get("prompt") for e in entries if e.get("decision") == "block"}
175
+ blocked.discard(None)
176
+ converted = {
177
+ e.get("prompt") for e in entries
178
+ if e.get("prompt") in blocked and e.get("decision") != "block"
179
+ }
180
+
181
+ return {
182
+ "records": len(entries),
183
+ "window": [entries[0].get("ts"), entries[-1].get("ts")] if entries else [],
184
+ "harnesses": dict(collections.Counter(e.get("harness") for e in entries)),
185
+ "decisions": dict(collections.Counter(e.get("decision") for e in entries)),
186
+ "tiers": dict(tiers),
187
+ "errors": sum(1 for e in entries if e.get("decision") == "error"),
188
+ "blocked": len(blocked),
189
+ "converted": len(converted),
190
+ "trivial_lone_spawns": len(trivial),
191
+ "agents": dict(collections.Counter(
192
+ "%s @ %s" % (e.get("agent") or "-", e.get("model") or "inherited")
193
+ for e in entries
194
+ )),
195
+ }
196
+
197
+
198
+ def render(summary):
199
+ lines = []
200
+ # Errors lead. Conflating "the guard crashed" with "the guard allowed" is how
201
+ # a dead guard goes unnoticed for months, so a nonzero count is the headline.
202
+ if summary["errors"]:
203
+ lines.append("!! %d guard error(s) -- the guard failed open this many times" % summary["errors"])
204
+ if not summary["records"]:
205
+ lines.append("no dispatches recorded yet (%s)" % path())
206
+ return "\n".join(lines)
207
+
208
+ lines.append("%d dispatch(es) %s .. %s" % (summary["records"], summary["window"][0], summary["window"][1]))
209
+ lines.append(" harnesses " + ", ".join("%s %d" % kv for kv in sorted(summary["harnesses"].items())))
210
+ lines.append(" tiers " + ", ".join("%s %d" % kv for kv in sorted(summary["tiers"].items())))
211
+ if summary["blocked"]:
212
+ lines.append(" blocks %d, of which %d re-dispatched with a tier named" % (
213
+ summary["blocked"], summary["converted"]))
214
+ lines.append(" lone small spawns %d (fan-outs excluded)" % summary["trivial_lone_spawns"])
215
+ lines.append(" agent @ model:")
216
+ for name, count in sorted(summary["agents"].items(), key=lambda kv: (-kv[1], kv[0])):
217
+ lines.append(" %-44s %d" % (name, count))
218
+ return "\n".join(lines)
219
+
220
+
221
+ def main(argv=None):
222
+ parser = argparse.ArgumentParser(prog="dispatch_log.py", description=__doc__.splitlines()[0])
223
+ sub = parser.add_subparsers(dest="command", required=True)
224
+ report = sub.add_parser("report", help="what the guard has been seeing")
225
+ report.add_argument("--limit", type=int, default=None, help="only the most recent N records")
226
+ report.add_argument("--json", action="store_true", help="machine-readable summary")
227
+ sub.add_parser("path", help="the log file's path")
228
+
229
+ args = parser.parse_args(argv)
230
+ if args.command == "path":
231
+ print(path())
232
+ return 0
233
+
234
+ summary = summarise(read(limit=args.limit))
235
+ print(json.dumps(summary, indent=1, sort_keys=True) if args.json else render(summary))
236
+ return 0
237
+
238
+
239
+ if __name__ == "__main__":
240
+ sys.exit(main())