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.
@@ -0,0 +1,3 @@
1
+ """agent-log-verifier: post-hoc, read-only verification for AI agent transcript logs."""
2
+
3
+ __version__ = "0.3.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()}