agent-log-verifier 0.3.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.
- agent_log_verifier/__init__.py +3 -0
- agent_log_verifier/cli.py +600 -0
- agent_log_verifier/detectors/__init__.py +0 -0
- agent_log_verifier/detectors/base.py +75 -0
- agent_log_verifier/detectors/completeness.py +157 -0
- agent_log_verifier/detectors/compliance.py +970 -0
- agent_log_verifier/detectors/incident.py +117 -0
- agent_log_verifier/detectors/liveness.py +387 -0
- agent_log_verifier/detectors/loops.py +315 -0
- agent_log_verifier/detectors/scope.py +190 -0
- agent_log_verifier/extraction/__init__.py +0 -0
- agent_log_verifier/extraction/constraints.py +374 -0
- agent_log_verifier/models/__init__.py +0 -0
- agent_log_verifier/models/events.py +140 -0
- agent_log_verifier/parser/__init__.py +0 -0
- agent_log_verifier/parser/claude_code.py +265 -0
- agent_log_verifier/report/__init__.py +0 -0
- agent_log_verifier/report/breakdown.py +129 -0
- agent_log_verifier/report/cli_output.py +56 -0
- agent_log_verifier/report/html_output.py +1640 -0
- agent_log_verifier/report/json_output.py +41 -0
- agent_log_verifier/report/markdown_output.py +52 -0
- agent_log_verifier/server.py +263 -0
- agent_log_verifier-0.3.0.dist-info/METADATA +423 -0
- agent_log_verifier-0.3.0.dist-info/RECORD +28 -0
- agent_log_verifier-0.3.0.dist-info/WHEEL +4 -0
- agent_log_verifier-0.3.0.dist-info/entry_points.txt +2 -0
- agent_log_verifier-0.3.0.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,600 @@
|
|
|
1
|
+
"""CLI entrypoint: scan / watch / report subcommands.
|
|
2
|
+
|
|
3
|
+
Design doc reference: 2026-07-11_agent-log-verifier_spec_desktop.md
|
|
4
|
+
section 5 (CLI spec) and section 3-2 (registration/execution flow):
|
|
5
|
+
|
|
6
|
+
cli.py
|
|
7
|
+
-> parser.parse_jsonl(path) -> list[NormalizedEvent]
|
|
8
|
+
-> registry.run_all(events, order_prompt=extracted_prompt)
|
|
9
|
+
-> report.render(findings_dict, format=cli_output|json|markdown)
|
|
10
|
+
|
|
11
|
+
argparse is used per design doc 7-1 ("標準ライブラリの argparse で
|
|
12
|
+
scan/watch/report のサブコマンドを賄えるため、追加依存を避けたい場合は
|
|
13
|
+
argparse を優先してよい") -- chosen here to avoid an extra dependency.
|
|
14
|
+
|
|
15
|
+
Review-fix note (python-reviewer HIGH, 2026-07-11): liveness previously
|
|
16
|
+
only ran ComplianceDetector-style single-transcript staleness checks via
|
|
17
|
+
DetectorRegistry, never the richer parent-session-correlated
|
|
18
|
+
infer_completion_status() path -- despite liveness.py's own docstring
|
|
19
|
+
claiming "cli.py orchestrates that richer check directly". This module
|
|
20
|
+
now actually does that orchestration: for each discovered subagent
|
|
21
|
+
JSONL, it (a) reads the sibling .meta.json into a SubagentMeta, (b)
|
|
22
|
+
locates the corresponding parent session JSONL by walking up past the
|
|
23
|
+
subagents/ directory component to find <project-dir>/<session-id>.jsonl,
|
|
24
|
+
(c) calls build_liveness_finding() (which calls infer_completion_status())
|
|
25
|
+
to get a Finding whose `judgement` carries the exact
|
|
26
|
+
running/completed/stopped_suspected/unknown 4-value status, and (d)
|
|
27
|
+
surfaces that in the same findings["liveness"] list the CLI/JSON/markdown
|
|
28
|
+
renderers already read from. Because this liveness path needs additional
|
|
29
|
+
context (parent_events, subagent_meta) DetectorRegistry's Detector
|
|
30
|
+
interface does not carry, liveness is intentionally NOT registered
|
|
31
|
+
through DetectorRegistry at all in run_scan(); it is computed directly
|
|
32
|
+
alongside registry.run_all() for the other (compliance) detectors, then
|
|
33
|
+
merged into the same findings dict. --detector liveness/compliance
|
|
34
|
+
filtering is preserved by simply skipping the liveness computation (or
|
|
35
|
+
the registry-based detectors) when excluded.
|
|
36
|
+
"""
|
|
37
|
+
|
|
38
|
+
from __future__ import annotations
|
|
39
|
+
|
|
40
|
+
import argparse
|
|
41
|
+
import contextlib
|
|
42
|
+
import sys
|
|
43
|
+
import time
|
|
44
|
+
from datetime import datetime, UTC
|
|
45
|
+
from pathlib import Path
|
|
46
|
+
from typing import Any
|
|
47
|
+
|
|
48
|
+
from agent_log_verifier.detectors.base import DetectorRegistry, Finding
|
|
49
|
+
from agent_log_verifier.detectors.compliance import ComplianceDetector, segment_main_session_into_turns
|
|
50
|
+
from agent_log_verifier.detectors.completeness import CompletenessDetector
|
|
51
|
+
from agent_log_verifier.detectors.incident import IncidentDetector
|
|
52
|
+
from agent_log_verifier.detectors.loops import LoopDetector
|
|
53
|
+
from agent_log_verifier.detectors.scope import ScopeDetector
|
|
54
|
+
from agent_log_verifier.detectors.liveness import (
|
|
55
|
+
STALE_THRESHOLD_MINUTES_DEFAULT,
|
|
56
|
+
STOPPED_THRESHOLD_MINUTES_DEFAULT,
|
|
57
|
+
build_liveness_finding,
|
|
58
|
+
)
|
|
59
|
+
from agent_log_verifier.models.events import NormalizedEvent, SubagentMeta
|
|
60
|
+
from agent_log_verifier.parser.claude_code import extract_order_prompt, parse_jsonl
|
|
61
|
+
from agent_log_verifier.report.cli_output import render_cli
|
|
62
|
+
from agent_log_verifier.report.breakdown import ErrorBreakdown, tally_error_breakdown
|
|
63
|
+
from agent_log_verifier.report.html_output import render_html
|
|
64
|
+
from agent_log_verifier.report.json_output import render_json
|
|
65
|
+
from agent_log_verifier.report.markdown_output import render_markdown
|
|
66
|
+
|
|
67
|
+
_DEFAULT_POLL_INTERVAL_SECONDS = 10
|
|
68
|
+
_DEFAULT_DASHBOARD_FILENAME = "alv-dashboard.html"
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def _discover_transcripts(target: Path) -> tuple[list[Path], list[Path]]:
|
|
72
|
+
"""Given a Claude Code project directory (or a single .jsonl file),
|
|
73
|
+
return (main_session_paths, subagent_paths).
|
|
74
|
+
|
|
75
|
+
This directory-walking scheme is not specified at the pseudocode
|
|
76
|
+
level by the design doc (5-1/5-2 describe the CLI surface, not the
|
|
77
|
+
filesystem discovery algorithm); implemented per the real project
|
|
78
|
+
layout confirmed in design doc section 1 and section 10 (top-level
|
|
79
|
+
session JSONL files, with a `subagents/` subdirectory per session
|
|
80
|
+
containing `agent-*.jsonl` and `agent-*.meta.json`, including nested
|
|
81
|
+
`subagents/workflows/wf_<id>/agent-*.meta.json` for workflow-subagent
|
|
82
|
+
patterns).
|
|
83
|
+
"""
|
|
84
|
+
if target.is_file():
|
|
85
|
+
return [target], []
|
|
86
|
+
|
|
87
|
+
main_sessions: list[Path] = []
|
|
88
|
+
subagent_sessions: list[Path] = []
|
|
89
|
+
|
|
90
|
+
for jsonl_path in sorted(target.glob("**/*.jsonl")):
|
|
91
|
+
if "subagents" in jsonl_path.parts:
|
|
92
|
+
# Workflow run directories keep an orchestration journal
|
|
93
|
+
# (subagents/workflows/wf_<id>/journal.jsonl) alongside the
|
|
94
|
+
# agent-*.jsonl transcripts. It is bookkeeping, not an agent
|
|
95
|
+
# -- counting it produced ghost "journal (unknown)" liveness
|
|
96
|
+
# rows on the dashboard (observed on the 2026-07-11 real
|
|
97
|
+
# scan: 9 such rows).
|
|
98
|
+
if jsonl_path.stem == "journal":
|
|
99
|
+
continue
|
|
100
|
+
subagent_sessions.append(jsonl_path)
|
|
101
|
+
else:
|
|
102
|
+
main_sessions.append(jsonl_path)
|
|
103
|
+
|
|
104
|
+
return main_sessions, subagent_sessions
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def _find_parent_session_jsonl(subagent_jsonl_path: Path) -> Path | None:
|
|
108
|
+
"""Given a subagent transcript path (e.g.
|
|
109
|
+
<project-dir>/<session-id>/subagents/agent-foo.jsonl, or the nested
|
|
110
|
+
.../subagents/workflows/wf_<id>/agent-foo.jsonl form), locate the
|
|
111
|
+
corresponding parent/main session transcript at
|
|
112
|
+
<project-dir>/<session-id>.jsonl.
|
|
113
|
+
|
|
114
|
+
This directory-walking scheme is likewise not specified at
|
|
115
|
+
pseudocode level by the design doc; implemented per the real project
|
|
116
|
+
layout confirmed in design doc sections 1 and 10, where a session's
|
|
117
|
+
subagents live under a directory named after that session's own
|
|
118
|
+
UUID, itself a sibling of <session-id>.jsonl.
|
|
119
|
+
"""
|
|
120
|
+
parts = subagent_jsonl_path.parts
|
|
121
|
+
if "subagents" not in parts:
|
|
122
|
+
return None
|
|
123
|
+
|
|
124
|
+
subagents_index = parts.index("subagents")
|
|
125
|
+
if subagents_index == 0:
|
|
126
|
+
return None
|
|
127
|
+
|
|
128
|
+
session_dir = Path(*parts[:subagents_index])
|
|
129
|
+
session_id = session_dir.name
|
|
130
|
+
candidate = session_dir.parent / f"{session_id}.jsonl"
|
|
131
|
+
if candidate.is_file():
|
|
132
|
+
return candidate
|
|
133
|
+
return None
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
def _find_subagent_meta_path(subagent_jsonl_path: Path) -> Path | None:
|
|
137
|
+
"""A subagent's .meta.json sibling file uses the same stem as its
|
|
138
|
+
.jsonl transcript (e.g. agent-foo.jsonl -> agent-foo.meta.json),
|
|
139
|
+
confirmed by design doc section 1-4 and section 10's real-data
|
|
140
|
+
examples (agent-<id>.jsonl alongside agent-<id>.meta.json)."""
|
|
141
|
+
candidate = subagent_jsonl_path.with_suffix("").with_suffix(".meta.json")
|
|
142
|
+
if candidate.is_file():
|
|
143
|
+
return candidate
|
|
144
|
+
return None
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
def _load_subagent_meta(meta_path: Path | None) -> SubagentMeta:
|
|
148
|
+
"""Read a .meta.json file into a SubagentMeta. If the file is
|
|
149
|
+
missing or unreadable, fall back to a minimal SubagentMeta with an
|
|
150
|
+
'unknown' agentType and no toolUseId -- this degrades gracefully to
|
|
151
|
+
the 'unknown' completion-status branch rather than crashing the scan
|
|
152
|
+
(consistent with the project's overall "never raise for malformed
|
|
153
|
+
input" policy, design doc 2-1/2-3)."""
|
|
154
|
+
if meta_path is None:
|
|
155
|
+
return SubagentMeta(agentType="unknown")
|
|
156
|
+
|
|
157
|
+
try:
|
|
158
|
+
import json
|
|
159
|
+
|
|
160
|
+
raw = json.loads(meta_path.read_text(encoding="utf-8"))
|
|
161
|
+
except (OSError, ValueError):
|
|
162
|
+
return SubagentMeta(agentType="unknown")
|
|
163
|
+
|
|
164
|
+
if not isinstance(raw, dict):
|
|
165
|
+
return SubagentMeta(agentType="unknown")
|
|
166
|
+
|
|
167
|
+
try:
|
|
168
|
+
return SubagentMeta.model_validate(raw)
|
|
169
|
+
except Exception:
|
|
170
|
+
return SubagentMeta(agentType="unknown")
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
def _latest_event_timestamp(events: list[NormalizedEvent]) -> datetime | None:
|
|
174
|
+
"""[Coordinator addition, 2026-07-11 UI-redesign request]: compliance
|
|
175
|
+
Findings carry no timestamp of their own (Finding's schema in
|
|
176
|
+
detectors/base.py has no such field, and this module must not modify
|
|
177
|
+
detectors/ or parser/ per the request's own constraint). The
|
|
178
|
+
dashboard's "直近24時間のみ表示" default for compliance findings needs
|
|
179
|
+
*some* notion of "when did this finding happen" to filter by, so this
|
|
180
|
+
reads it off the same event list the finding was computed from
|
|
181
|
+
instead: the latest timestamp among the turn-segment (main session)
|
|
182
|
+
or subagent transcript events that _scan_compliance() was just run
|
|
183
|
+
against. This is an approximation (a finding technically corresponds
|
|
184
|
+
to one order-prompt+response exchange, not literally the transcript's
|
|
185
|
+
very last event), but it is the only signal available without
|
|
186
|
+
touching Finding's schema, and is accurate enough for "is this recent
|
|
187
|
+
or old" bucketing.
|
|
188
|
+
"""
|
|
189
|
+
if not events:
|
|
190
|
+
return None
|
|
191
|
+
return max(e.timestamp for e in events)
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
def _tag_findings_with_reference_timestamp(
|
|
195
|
+
findings: dict[str, list[Finding]],
|
|
196
|
+
*,
|
|
197
|
+
reference_timestamp: datetime | None,
|
|
198
|
+
) -> dict[str, list[Finding]]:
|
|
199
|
+
"""Stamps every Finding in `findings` with a `reference_timestamp`
|
|
200
|
+
extra field (Finding.model_config allows extra fields; see
|
|
201
|
+
detectors/base.py) via model_copy(update=...), so
|
|
202
|
+
report/html_output.py can bucket compliance findings into "直近24時間"
|
|
203
|
+
vs "過去分" without any changes to the Finding schema itself or to
|
|
204
|
+
detectors/compliance.py. A None reference_timestamp (empty event
|
|
205
|
+
list, defensively) leaves findings untagged -- html_output.py treats
|
|
206
|
+
a missing reference_timestamp as "recent" (fail open toward
|
|
207
|
+
visibility rather than silently hiding an unattributable finding;
|
|
208
|
+
see html_output.py's _CompliancRow.is_recent).
|
|
209
|
+
"""
|
|
210
|
+
if reference_timestamp is None:
|
|
211
|
+
return findings
|
|
212
|
+
iso_ts = reference_timestamp.isoformat()
|
|
213
|
+
return {
|
|
214
|
+
detector_name: [f.model_copy(update={"reference_timestamp": iso_ts}) for f in detector_findings]
|
|
215
|
+
for detector_name, detector_findings in findings.items()
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
|
|
219
|
+
def _scan_compliance(
|
|
220
|
+
events: list[NormalizedEvent],
|
|
221
|
+
*,
|
|
222
|
+
order_prompt: str | None,
|
|
223
|
+
registry: DetectorRegistry,
|
|
224
|
+
) -> dict[str, list[Finding]]:
|
|
225
|
+
findings = registry.run_all(events, order_prompt=order_prompt)
|
|
226
|
+
return _tag_findings_with_reference_timestamp(findings, reference_timestamp=_latest_event_timestamp(events))
|
|
227
|
+
|
|
228
|
+
|
|
229
|
+
def _merge_findings(target: dict[str, list[Finding]], source: dict[str, list[Finding]]) -> None:
|
|
230
|
+
for detector_name, detector_findings in source.items():
|
|
231
|
+
target.setdefault(detector_name, [])
|
|
232
|
+
target[detector_name].extend(detector_findings)
|
|
233
|
+
|
|
234
|
+
|
|
235
|
+
def _build_registry(*, detector_filter: list[str] | None) -> DetectorRegistry:
|
|
236
|
+
"""Registers only ComplianceDetector -- liveness is deliberately
|
|
237
|
+
orchestrated separately in run_scan() (see module docstring) because
|
|
238
|
+
it needs parent_events/subagent_meta context the Detector interface
|
|
239
|
+
does not carry.
|
|
240
|
+
"""
|
|
241
|
+
registry = DetectorRegistry()
|
|
242
|
+
all_detectors: dict[str, Any] = {
|
|
243
|
+
"compliance": ComplianceDetector(),
|
|
244
|
+
"loop": LoopDetector(),
|
|
245
|
+
"incident": IncidentDetector(),
|
|
246
|
+
"completeness": CompletenessDetector(),
|
|
247
|
+
"scope": ScopeDetector(),
|
|
248
|
+
}
|
|
249
|
+
names_to_register = detector_filter if detector_filter else list(all_detectors.keys())
|
|
250
|
+
for name in names_to_register:
|
|
251
|
+
if name in all_detectors:
|
|
252
|
+
registry.register(all_detectors[name])
|
|
253
|
+
return registry
|
|
254
|
+
|
|
255
|
+
|
|
256
|
+
def run_scan(
|
|
257
|
+
target: Path,
|
|
258
|
+
*,
|
|
259
|
+
stale_minutes: float = STALE_THRESHOLD_MINUTES_DEFAULT,
|
|
260
|
+
stopped_minutes: float = STOPPED_THRESHOLD_MINUTES_DEFAULT,
|
|
261
|
+
detector_filter: list[str] | None = None,
|
|
262
|
+
include_subagents: bool = True,
|
|
263
|
+
) -> tuple[dict[str, int], dict[str, list[Finding]], ErrorBreakdown]:
|
|
264
|
+
"""Design doc 5-1: scan a project directory or single .jsonl file.
|
|
265
|
+
Returns (sessions_summary, findings_by_detector, error_breakdown).
|
|
266
|
+
|
|
267
|
+
error_breakdown is the tool_result error tally used by the dashboard
|
|
268
|
+
'内訳' tab (BACKLOG B-3). It is computed inside this same walk so
|
|
269
|
+
events are not re-parsed.
|
|
270
|
+
"""
|
|
271
|
+
detectors_wanted = (
|
|
272
|
+
detector_filter
|
|
273
|
+
if detector_filter
|
|
274
|
+
else ["compliance", "loop", "incident", "completeness", "scope", "liveness"]
|
|
275
|
+
)
|
|
276
|
+
registry = _build_registry(detector_filter=[d for d in detectors_wanted if d != "liveness"])
|
|
277
|
+
liveness_wanted = "liveness" in detectors_wanted
|
|
278
|
+
|
|
279
|
+
main_sessions, subagent_sessions = _discover_transcripts(target)
|
|
280
|
+
if not include_subagents:
|
|
281
|
+
subagent_sessions = []
|
|
282
|
+
|
|
283
|
+
all_findings: dict[str, list[Finding]] = {}
|
|
284
|
+
all_events: list[NormalizedEvent] = []
|
|
285
|
+
now = datetime.now(UTC)
|
|
286
|
+
|
|
287
|
+
# Pre-parse every main-session transcript once; subagent liveness
|
|
288
|
+
# correlation below re-uses these parsed events rather than
|
|
289
|
+
# re-reading a parent JSONL file per subagent.
|
|
290
|
+
#
|
|
291
|
+
# v0.2 design doc 5-3: a main-session transcript is segmented into
|
|
292
|
+
# independent user free-text turns (design doc 5-1's real-data finding
|
|
293
|
+
# that main sessions hold up to 245 such turns, not the single
|
|
294
|
+
# "1 prompt = 1 task" shape a subagent transcript has) and compliance
|
|
295
|
+
# is run independently per turn, so constraints in turn 2, 3, ... are
|
|
296
|
+
# not silently skipped by only ever looking at the first user event
|
|
297
|
+
# (which is what a single unsegmented extract_order_prompt() call
|
|
298
|
+
# would do). main_session_events[path] itself still stores the FULL,
|
|
299
|
+
# unsegmented event list (not per-turn segments) -- subagent parent-
|
|
300
|
+
# session correlation below (_find_parent_session_jsonl /
|
|
301
|
+
# infer_completion_status) needs the whole transcript to search across
|
|
302
|
+
# turn boundaries for a matching tool_result.
|
|
303
|
+
main_session_events: dict[Path, list[NormalizedEvent]] = {}
|
|
304
|
+
for path in main_sessions:
|
|
305
|
+
events, _warnings = parse_jsonl(path)
|
|
306
|
+
main_session_events[path] = events
|
|
307
|
+
all_events.extend(events)
|
|
308
|
+
|
|
309
|
+
turn_segments = segment_main_session_into_turns(events)
|
|
310
|
+
for segment in turn_segments:
|
|
311
|
+
order_prompt = extract_order_prompt(segment)
|
|
312
|
+
if not order_prompt:
|
|
313
|
+
continue
|
|
314
|
+
findings = _scan_compliance(segment, order_prompt=order_prompt, registry=registry)
|
|
315
|
+
_merge_findings(all_findings, findings)
|
|
316
|
+
|
|
317
|
+
for path in subagent_sessions:
|
|
318
|
+
subagent_events, _warnings = parse_jsonl(path)
|
|
319
|
+
all_events.extend(subagent_events)
|
|
320
|
+
|
|
321
|
+
parent_path = _find_parent_session_jsonl(path)
|
|
322
|
+
if parent_path is not None:
|
|
323
|
+
parent_events = main_session_events.get(parent_path)
|
|
324
|
+
if parent_events is None:
|
|
325
|
+
parent_events, _pw = parse_jsonl(parent_path)
|
|
326
|
+
main_session_events[parent_path] = parent_events
|
|
327
|
+
else:
|
|
328
|
+
parent_events = []
|
|
329
|
+
|
|
330
|
+
parent_tool_use_id = None
|
|
331
|
+
meta_path = _find_subagent_meta_path(path)
|
|
332
|
+
subagent_meta = _load_subagent_meta(meta_path)
|
|
333
|
+
if subagent_meta.tool_use_id is not None:
|
|
334
|
+
parent_tool_use_id = subagent_meta.tool_use_id
|
|
335
|
+
|
|
336
|
+
order_prompt = extract_order_prompt(
|
|
337
|
+
subagent_events,
|
|
338
|
+
parent_events=parent_events,
|
|
339
|
+
parent_tool_use_id=parent_tool_use_id,
|
|
340
|
+
)
|
|
341
|
+
findings = _scan_compliance(subagent_events, order_prompt=order_prompt, registry=registry)
|
|
342
|
+
_merge_findings(all_findings, findings)
|
|
343
|
+
|
|
344
|
+
if liveness_wanted:
|
|
345
|
+
liveness_finding = build_liveness_finding(
|
|
346
|
+
agent_label=path.stem,
|
|
347
|
+
subagent_events=subagent_events,
|
|
348
|
+
parent_events=parent_events,
|
|
349
|
+
subagent_meta=subagent_meta,
|
|
350
|
+
now=now,
|
|
351
|
+
stale_threshold_minutes=stale_minutes,
|
|
352
|
+
)
|
|
353
|
+
all_findings.setdefault("liveness", [])
|
|
354
|
+
all_findings["liveness"].append(liveness_finding)
|
|
355
|
+
|
|
356
|
+
# Presentation-level fold: LOOP/INCIDENT/COMPLETENESS findings ride
|
|
357
|
+
# in the same rule_id/judgement table as COMPLIANCE-00x (their
|
|
358
|
+
# judgement is 逸脱疑い, so the hero counter / amber banner /
|
|
359
|
+
# default filter all treat them as attention-worthy without any
|
|
360
|
+
# renderer changes); the rule_id column and its filter keep them
|
|
361
|
+
# distinguishable.
|
|
362
|
+
for extra_detector in ("loop", "incident", "completeness", "scope"):
|
|
363
|
+
if extra_detector in all_findings:
|
|
364
|
+
all_findings.setdefault("compliance", []).extend(all_findings.pop(extra_detector))
|
|
365
|
+
|
|
366
|
+
sessions = {"main": len(main_sessions), "subagents": len(subagent_sessions)}
|
|
367
|
+
breakdown = tally_error_breakdown(all_events)
|
|
368
|
+
return sessions, all_findings, breakdown
|
|
369
|
+
|
|
370
|
+
|
|
371
|
+
def _render(
|
|
372
|
+
*,
|
|
373
|
+
fmt: str,
|
|
374
|
+
scan_target: str,
|
|
375
|
+
sessions: dict[str, int],
|
|
376
|
+
findings: dict[str, list[Finding]],
|
|
377
|
+
) -> str:
|
|
378
|
+
if fmt == "json":
|
|
379
|
+
return render_json(scan_target=scan_target, sessions=sessions, findings=findings)
|
|
380
|
+
if fmt == "markdown":
|
|
381
|
+
return render_markdown(scan_target=scan_target, sessions=sessions, findings=findings)
|
|
382
|
+
return render_cli(scan_target=scan_target, sessions=sessions, findings=findings)
|
|
383
|
+
|
|
384
|
+
|
|
385
|
+
def _write_output(rendered: str, output_path: str | None) -> None:
|
|
386
|
+
if output_path:
|
|
387
|
+
Path(output_path).write_text(rendered, encoding="utf-8")
|
|
388
|
+
else:
|
|
389
|
+
print(rendered)
|
|
390
|
+
|
|
391
|
+
|
|
392
|
+
def _cmd_scan(args: argparse.Namespace) -> int:
|
|
393
|
+
target = Path(args.path)
|
|
394
|
+
detector_filter = args.detector.split(",") if args.detector else None
|
|
395
|
+
|
|
396
|
+
sessions, findings, _breakdown = run_scan(
|
|
397
|
+
target,
|
|
398
|
+
stale_minutes=args.stale_minutes,
|
|
399
|
+
stopped_minutes=args.stopped_minutes,
|
|
400
|
+
detector_filter=detector_filter,
|
|
401
|
+
include_subagents=args.include_subagents,
|
|
402
|
+
)
|
|
403
|
+
rendered = _render(fmt=args.format, scan_target=str(target), sessions=sessions, findings=findings)
|
|
404
|
+
_write_output(rendered, args.output)
|
|
405
|
+
return 0
|
|
406
|
+
|
|
407
|
+
|
|
408
|
+
def _cmd_report(args: argparse.Namespace) -> int:
|
|
409
|
+
# Design doc 5-1: report is scan's alias, output-to-file oriented.
|
|
410
|
+
return _cmd_scan(args)
|
|
411
|
+
|
|
412
|
+
|
|
413
|
+
def _cmd_watch(args: argparse.Namespace) -> int:
|
|
414
|
+
"""Design doc 5-1: watch polls scan on an interval and re-renders on
|
|
415
|
+
each tick. A simple sleep-based polling loop (no inotify/watchdog),
|
|
416
|
+
per design doc 4-3/5-1 ("標準ライブラリ中心" / "time.sleepベースの
|
|
417
|
+
ポーリングに留める").
|
|
418
|
+
"""
|
|
419
|
+
target = Path(args.path)
|
|
420
|
+
try:
|
|
421
|
+
while True:
|
|
422
|
+
sessions, findings, _breakdown = run_scan(
|
|
423
|
+
target,
|
|
424
|
+
stale_minutes=args.stale_minutes,
|
|
425
|
+
stopped_minutes=args.stopped_minutes,
|
|
426
|
+
detector_filter=["liveness"],
|
|
427
|
+
include_subagents=True,
|
|
428
|
+
)
|
|
429
|
+
rendered = _render(fmt="cli", scan_target=str(target), sessions=sessions, findings=findings)
|
|
430
|
+
print(f"\n--- {datetime.now(UTC).isoformat()} ---")
|
|
431
|
+
print(rendered)
|
|
432
|
+
time.sleep(args.poll_interval)
|
|
433
|
+
except KeyboardInterrupt:
|
|
434
|
+
return 0
|
|
435
|
+
|
|
436
|
+
|
|
437
|
+
def _cmd_dashboard(args: argparse.Namespace) -> int:
|
|
438
|
+
"""`alv dashboard <path>`: run a single scan and write a self-
|
|
439
|
+
contained, single-shot HTML report (report/html_output.py's
|
|
440
|
+
render_html()), then open it in the default browser unless
|
|
441
|
+
--no-open was passed. This is a one-shot snapshot for quick local
|
|
442
|
+
viewing/sharing -- see _cmd_serve below for the auto-refreshing live
|
|
443
|
+
view.
|
|
444
|
+
"""
|
|
445
|
+
import webbrowser
|
|
446
|
+
|
|
447
|
+
target = Path(args.path)
|
|
448
|
+
detector_filter = args.detector.split(",") if args.detector else None
|
|
449
|
+
|
|
450
|
+
sessions, findings, breakdown = run_scan(
|
|
451
|
+
target,
|
|
452
|
+
stale_minutes=args.stale_minutes,
|
|
453
|
+
stopped_minutes=args.stopped_minutes,
|
|
454
|
+
detector_filter=detector_filter,
|
|
455
|
+
include_subagents=args.include_subagents,
|
|
456
|
+
)
|
|
457
|
+
rendered = render_html(
|
|
458
|
+
scan_target=str(target), sessions=sessions, findings=findings, breakdown=breakdown
|
|
459
|
+
)
|
|
460
|
+
|
|
461
|
+
output_path = Path(args.output) if args.output else Path(_DEFAULT_DASHBOARD_FILENAME)
|
|
462
|
+
output_path.write_text(rendered, encoding="utf-8")
|
|
463
|
+
print(f"wrote {output_path}")
|
|
464
|
+
|
|
465
|
+
if args.open:
|
|
466
|
+
webbrowser.open(output_path.resolve().as_uri())
|
|
467
|
+
|
|
468
|
+
return 0
|
|
469
|
+
|
|
470
|
+
|
|
471
|
+
def _cmd_serve(args: argparse.Namespace) -> int:
|
|
472
|
+
"""`alv serve <path>`: start the live-mode local HTTP server
|
|
473
|
+
(server.py). Imported locally (not at module top level) to avoid a
|
|
474
|
+
cli.py <-> server.py import cycle -- server.py itself imports
|
|
475
|
+
run_scan from this module, since the live server's background scan
|
|
476
|
+
loop is the same run_scan() `alv scan`/`alv dashboard` already use.
|
|
477
|
+
"""
|
|
478
|
+
from agent_log_verifier.server import DEFAULT_PORT, serve_dashboard
|
|
479
|
+
|
|
480
|
+
target = Path(args.path)
|
|
481
|
+
detector_filter = args.detector.split(",") if args.detector else None
|
|
482
|
+
|
|
483
|
+
serve_dashboard(
|
|
484
|
+
target,
|
|
485
|
+
port=args.port if args.port is not None else DEFAULT_PORT,
|
|
486
|
+
poll_interval_seconds=args.interval,
|
|
487
|
+
stale_minutes=args.stale_minutes,
|
|
488
|
+
stopped_minutes=args.stopped_minutes,
|
|
489
|
+
detector_filter=detector_filter,
|
|
490
|
+
include_subagents=args.include_subagents,
|
|
491
|
+
open_browser=args.open,
|
|
492
|
+
)
|
|
493
|
+
return 0
|
|
494
|
+
|
|
495
|
+
|
|
496
|
+
def _build_parser() -> argparse.ArgumentParser:
|
|
497
|
+
parser = argparse.ArgumentParser(prog="alv", description="agent-log-verifier: post-hoc, read-only transcript verification")
|
|
498
|
+
subparsers = parser.add_subparsers(dest="command", required=True)
|
|
499
|
+
|
|
500
|
+
def add_common_scan_options(sp: argparse.ArgumentParser) -> None:
|
|
501
|
+
sp.add_argument("path", help="Claude Code project directory or a single .jsonl file")
|
|
502
|
+
sp.add_argument("--format", choices=["cli", "json", "markdown"], default="cli")
|
|
503
|
+
sp.add_argument("--output", default=None, help="Output file path (default: stdout)")
|
|
504
|
+
sp.add_argument("--detector", default=None, help="Comma-separated detector names (default: all)")
|
|
505
|
+
sp.add_argument("--stale-minutes", type=float, default=STALE_THRESHOLD_MINUTES_DEFAULT)
|
|
506
|
+
sp.add_argument("--stopped-minutes", type=float, default=STOPPED_THRESHOLD_MINUTES_DEFAULT)
|
|
507
|
+
# Review-fix note (python-reviewer [small], 2026-07-11): the
|
|
508
|
+
# previous `action="store_true", default=True` meant
|
|
509
|
+
# --include-subagents was a permanent no-op and there was no way
|
|
510
|
+
# to set it to False at all. BooleanOptionalAction provides both
|
|
511
|
+
# --include-subagents (explicit true, matching the design doc 5-2
|
|
512
|
+
# option table's flag name) and --no-include-subagents.
|
|
513
|
+
sp.add_argument("--include-subagents", action=argparse.BooleanOptionalAction, default=True)
|
|
514
|
+
|
|
515
|
+
scan_parser = subparsers.add_parser("scan", help="Analyze a project directory or JSONL file once")
|
|
516
|
+
add_common_scan_options(scan_parser)
|
|
517
|
+
scan_parser.set_defaults(func=_cmd_scan)
|
|
518
|
+
|
|
519
|
+
report_parser = subparsers.add_parser("report", help="Alias for scan, output-to-file oriented")
|
|
520
|
+
add_common_scan_options(report_parser)
|
|
521
|
+
report_parser.set_defaults(func=_cmd_report)
|
|
522
|
+
|
|
523
|
+
watch_parser = subparsers.add_parser("watch", help="Continuously poll and display liveness status")
|
|
524
|
+
watch_parser.add_argument("path", help="Claude Code project directory")
|
|
525
|
+
watch_parser.add_argument("--stale-minutes", type=float, default=STALE_THRESHOLD_MINUTES_DEFAULT)
|
|
526
|
+
watch_parser.add_argument("--stopped-minutes", type=float, default=STOPPED_THRESHOLD_MINUTES_DEFAULT)
|
|
527
|
+
watch_parser.add_argument("--poll-interval", type=float, default=_DEFAULT_POLL_INTERVAL_SECONDS)
|
|
528
|
+
watch_parser.set_defaults(func=_cmd_watch)
|
|
529
|
+
|
|
530
|
+
dashboard_parser = subparsers.add_parser(
|
|
531
|
+
"dashboard", help="Run one scan and write a self-contained HTML report"
|
|
532
|
+
)
|
|
533
|
+
dashboard_parser.add_argument("path", help="Claude Code project directory or a single .jsonl file")
|
|
534
|
+
dashboard_parser.add_argument(
|
|
535
|
+
"--output", default=None, help=f"Output HTML file path (default: ./{_DEFAULT_DASHBOARD_FILENAME})"
|
|
536
|
+
)
|
|
537
|
+
dashboard_parser.add_argument("--detector", default=None, help="Comma-separated detector names (default: all)")
|
|
538
|
+
dashboard_parser.add_argument("--stale-minutes", type=float, default=STALE_THRESHOLD_MINUTES_DEFAULT)
|
|
539
|
+
dashboard_parser.add_argument("--stopped-minutes", type=float, default=STOPPED_THRESHOLD_MINUTES_DEFAULT)
|
|
540
|
+
dashboard_parser.add_argument("--include-subagents", action=argparse.BooleanOptionalAction, default=True)
|
|
541
|
+
dashboard_parser.add_argument(
|
|
542
|
+
"--open", action=argparse.BooleanOptionalAction, default=True, help="Open the report in a browser (default: true)"
|
|
543
|
+
)
|
|
544
|
+
dashboard_parser.set_defaults(func=_cmd_dashboard)
|
|
545
|
+
|
|
546
|
+
serve_parser = subparsers.add_parser(
|
|
547
|
+
"serve", help="Start a local live-updating dashboard server (Ctrl+C to stop)"
|
|
548
|
+
)
|
|
549
|
+
serve_parser.add_argument("path", help="Claude Code project directory")
|
|
550
|
+
serve_parser.add_argument("--port", type=int, default=None, help="TCP port to bind on 127.0.0.1 (default: 8799)")
|
|
551
|
+
serve_parser.add_argument(
|
|
552
|
+
"--interval", type=float, default=_DEFAULT_POLL_INTERVAL_SECONDS, help="Re-scan interval in seconds (default: 10)"
|
|
553
|
+
)
|
|
554
|
+
serve_parser.add_argument("--detector", default=None, help="Comma-separated detector names (default: all)")
|
|
555
|
+
serve_parser.add_argument("--stale-minutes", type=float, default=STALE_THRESHOLD_MINUTES_DEFAULT)
|
|
556
|
+
serve_parser.add_argument("--stopped-minutes", type=float, default=STOPPED_THRESHOLD_MINUTES_DEFAULT)
|
|
557
|
+
serve_parser.add_argument("--include-subagents", action=argparse.BooleanOptionalAction, default=True)
|
|
558
|
+
serve_parser.add_argument(
|
|
559
|
+
"--open", action=argparse.BooleanOptionalAction, default=True, help="Open the browser on start (default: true)"
|
|
560
|
+
)
|
|
561
|
+
serve_parser.set_defaults(func=_cmd_serve)
|
|
562
|
+
|
|
563
|
+
return parser
|
|
564
|
+
|
|
565
|
+
|
|
566
|
+
def _tolerate_console_encoding() -> None:
|
|
567
|
+
"""Degrade unencodable output characters instead of crashing.
|
|
568
|
+
|
|
569
|
+
Windows consoles default to a legacy codepage (cp932 on Japanese
|
|
570
|
+
Windows) with strict error handling, and findings text is arbitrary
|
|
571
|
+
transcript content -- any single character outside that codepage
|
|
572
|
+
would otherwise abort the whole scan with UnicodeEncodeError (first
|
|
573
|
+
real-world hit 2026-07-14: the CLI banner's em dash under a piped
|
|
574
|
+
cp932 stdout). File output is unaffected: it is always written with
|
|
575
|
+
an explicit UTF-8 encoding.
|
|
576
|
+
"""
|
|
577
|
+
for stream in (sys.stdout, sys.stderr):
|
|
578
|
+
reconfigure = getattr(stream, "reconfigure", None)
|
|
579
|
+
if reconfigure is None:
|
|
580
|
+
continue
|
|
581
|
+
# A pseudo-console that rejects reconfiguration keeps its
|
|
582
|
+
# original behavior; not worth failing the command over.
|
|
583
|
+
with contextlib.suppress(OSError, ValueError):
|
|
584
|
+
reconfigure(errors="replace")
|
|
585
|
+
|
|
586
|
+
|
|
587
|
+
def main(argv: list[str] | None = None) -> int:
|
|
588
|
+
_tolerate_console_encoding()
|
|
589
|
+
parser = _build_parser()
|
|
590
|
+
args = parser.parse_args(argv)
|
|
591
|
+
# args.func is set dynamically via set_defaults(func=...) per
|
|
592
|
+
# subcommand (argparse.Namespace attributes are inherently untyped),
|
|
593
|
+
# so mypy sees this call as returning Any. Explicitly permitted by
|
|
594
|
+
# the review-fix instructions to silence via type: ignore rather than
|
|
595
|
+
# over-engineering a typed dispatch table for a 3-subcommand CLI.
|
|
596
|
+
return args.func(args) # type: ignore[no-any-return]
|
|
597
|
+
|
|
598
|
+
|
|
599
|
+
if __name__ == "__main__":
|
|
600
|
+
sys.exit(main())
|
|
File without changes
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
"""Common detector interface (Detector / Finding / DetectorRegistry).
|
|
2
|
+
|
|
3
|
+
Design doc reference: 2026-07-11_agent-log-verifier_spec_desktop.md
|
|
4
|
+
section 3-1. The design doc's Finding class used an intentionally
|
|
5
|
+
simplified `BaseModel := __import__("pydantic").BaseModel` sketch and
|
|
6
|
+
explicitly notes (footnote after the code block) that real implementation
|
|
7
|
+
should just `from pydantic import BaseModel` normally -- done below.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
from abc import ABC, abstractmethod
|
|
13
|
+
from enum import StrEnum
|
|
14
|
+
|
|
15
|
+
from pydantic import BaseModel, ConfigDict, Field
|
|
16
|
+
|
|
17
|
+
from agent_log_verifier.models.events import NormalizedEvent
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class Severity(StrEnum):
|
|
21
|
+
INFO = "info" # 遵守・正常(レポートのサマリー行のみに使う)
|
|
22
|
+
WARNING = "warning" # 逸脱疑い(3値のうち「逸脱疑い」に対応)
|
|
23
|
+
CRITICAL = "critical" # 逸脱確定に近い強い疑い(v0.1では未使用)
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class Finding(BaseModel):
|
|
27
|
+
"""検出器が出力する1件の所見。全検出器で共通のフォーマット。"""
|
|
28
|
+
|
|
29
|
+
model_config = ConfigDict(extra="allow")
|
|
30
|
+
|
|
31
|
+
rule_id: str # 例: "COMPLIANCE-001", "LIVENESS-001"
|
|
32
|
+
severity: Severity
|
|
33
|
+
message: str # 人間可読な説明(日本語)
|
|
34
|
+
evidence_event_uuids: list[str] = Field(default_factory=list)
|
|
35
|
+
judgement: str | None = None # "遵守"/"逸脱疑い"/"判定不能"
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
class Detector(ABC):
|
|
39
|
+
"""全検出器(ComplianceDetector / LoopDetector / LivenessDetector)が
|
|
40
|
+
実装する共通インターフェース。企画書4-1章「検出器プラグイン層」に対応。
|
|
41
|
+
"""
|
|
42
|
+
|
|
43
|
+
@property
|
|
44
|
+
@abstractmethod
|
|
45
|
+
def name(self) -> str:
|
|
46
|
+
"""検出器の識別名(例: "compliance", "liveness")。
|
|
47
|
+
CLI の --detector オプションに対応。
|
|
48
|
+
"""
|
|
49
|
+
...
|
|
50
|
+
|
|
51
|
+
@abstractmethod
|
|
52
|
+
def run(self, events: list[NormalizedEvent], *, order_prompt: str | None = None) -> list[Finding]:
|
|
53
|
+
"""イベント列を受け取り、Finding のリストを返す。
|
|
54
|
+
order_prompt: 発注文全文(指示遵守検証で使用。他の検出器は None のまま無視してよい)。
|
|
55
|
+
副作用(ファイル書き込み等)を持たない純粋関数的な実装にする(テスト容易性のため)。
|
|
56
|
+
"""
|
|
57
|
+
...
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
class DetectorRegistry:
|
|
61
|
+
"""検出器の登録・実行フローを一元管理する。
|
|
62
|
+
cli.py はこのレジストリ経由で検出器を呼び出し、個別 import を持たない。
|
|
63
|
+
"""
|
|
64
|
+
|
|
65
|
+
def __init__(self) -> None:
|
|
66
|
+
self._detectors: dict[str, Detector] = {}
|
|
67
|
+
|
|
68
|
+
def register(self, detector: Detector) -> None:
|
|
69
|
+
self._detectors[detector.name] = detector
|
|
70
|
+
|
|
71
|
+
def run_all(
|
|
72
|
+
self, events: list[NormalizedEvent], *, order_prompt: str | None = None
|
|
73
|
+
) -> dict[str, list[Finding]]:
|
|
74
|
+
"""登録済み全検出器を実行し、検出器名をキーにした結果辞書を返す。"""
|
|
75
|
+
return {name: d.run(events, order_prompt=order_prompt) for name, d in self._detectors.items()}
|