codex-agent-hour-tracker 0.1.0__tar.gz

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,16 @@
1
+ /reports/
2
+ /dist/
3
+ /build/
4
+ /docs/superpowers/
5
+ *.egg-info/
6
+ __pycache__/
7
+ *.py[cod]
8
+ .DS_Store
9
+ .venv/
10
+ .pytest_cache/
11
+ .coverage
12
+ .env
13
+ .env.*
14
+ !.env.example
15
+ .dev.vars
16
+ .wrangler/
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Tim Osterhus
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,123 @@
1
+ Metadata-Version: 2.5
2
+ Name: codex-agent-hour-tracker
3
+ Version: 0.1.0
4
+ Summary: Measure cumulative Codex agent runtime without decoding conversation content.
5
+ Project-URL: Homepage, https://agenthours.dev
6
+ Project-URL: Repository, https://github.com/tim-osterhus/codex-agent-hour-tracker
7
+ Project-URL: Issues, https://github.com/tim-osterhus/codex-agent-hour-tracker/issues
8
+ Author-email: Tim Osterhus <tim@millrace.ai>
9
+ License-Expression: MIT
10
+ License-File: LICENSE
11
+ Keywords: agents,analytics,codex,privacy
12
+ Classifier: License :: OSI Approved :: MIT License
13
+ Classifier: Operating System :: OS Independent
14
+ Classifier: Programming Language :: Python :: 3
15
+ Classifier: Programming Language :: Python :: 3 :: Only
16
+ Classifier: Programming Language :: Python :: 3.11
17
+ Classifier: Programming Language :: Python :: 3.12
18
+ Classifier: Programming Language :: Python :: 3.13
19
+ Classifier: Programming Language :: Python :: 3.14
20
+ Classifier: Programming Language :: Python :: Implementation :: CPython
21
+ Classifier: Programming Language :: Python :: Implementation :: PyPy
22
+ Requires-Python: >=3.11
23
+ Requires-Dist: tzdata>=2025.2; platform_system == 'Windows'
24
+ Requires-Dist: tzlocal<6,>=5.3
25
+ Description-Content-Type: text/markdown
26
+
27
+ # Agent Hour Tracker
28
+
29
+ Agent Hour Tracker is a local CLI that measures cumulative Codex agent-hours from completed session turns.
30
+
31
+ If you inspect your own local Codex activity, start with the sanitized Archive Score card:
32
+
33
+ ```bash
34
+ uvx codex-agent-hour-tracker --share
35
+ ```
36
+
37
+ The command reads local session JSONL files and reports completed root and delegated turns. It does not decode conversation content for timing. Use explicit date bounds when you need a reproducible custom report.
38
+
39
+ ## Synthetic Archive Score example
40
+
41
+ The following card uses fabricated dates and values. It is an example, not a report from a person or machine:
42
+
43
+ ```text
44
+ CODEX AGENT-HOUR SCORE
45
+
46
+ 30 complete calendar days | 2025-01-01 to 2025-01-30
47
+ -----------------------------------------------------
48
+ Agent-hours/day: 2.00
49
+ Total agent-hours: 60.00
50
+ Peak day: 4.00
51
+ Completed turns: 12
52
+ Active days: 10/30
53
+
54
+ Archive Score | methodology v1 | tracker v0.1.0
55
+ Calculated locally. No conversation content uploaded.
56
+ ```
57
+
58
+ Agent-hours are cumulative completed-turn durations. If turns overlap, the tracker sums each turn independently. The total measures cumulative runtime, not elapsed wall-clock time.
59
+
60
+ ## Install
61
+
62
+ Install the published package with either tool:
63
+
64
+ ```bash
65
+ uv tool install codex-agent-hour-tracker
66
+ codex-agent-hour-tracker --share
67
+ ```
68
+
69
+ ```bash
70
+ pipx install codex-agent-hour-tracker
71
+ codex-agent-hour-tracker --share
72
+ ```
73
+
74
+ The `agent-hours` command is also installed as a short alias. The CLI reads the local Codex session directory by default. Use `--sessions-dir PATH` to select another directory.
75
+
76
+ ## Custom reports
77
+
78
+ The default report is text. Explicit `--start` and `--end` values define an inclusive local-calendar date range:
79
+
80
+ ```bash
81
+ mkdir -p reports
82
+ agent-hours --start 2025-01-01 --end 2025-01-30 \
83
+ > reports/january-summary.txt
84
+ ```
85
+
86
+ CSV output has one row per calendar day. Save generated reports only under the ignored `reports/` directory:
87
+
88
+ ```bash
89
+ agent-hours --start 2025-01-01 --end 2025-01-30 --format csv \
90
+ > reports/january-summary.csv
91
+ ```
92
+
93
+ Git ignores the `reports/` directory. Do not redirect generated output to tracked files.
94
+
95
+ ## Default window and methodology
96
+
97
+ The canonical Archive Score and a report without date bounds cover the 30 most recent completed local calendar days ending yesterday. Calendar days include days with zero use. If today is March 1, the window is January 30 through February 28.
98
+
99
+ The scanner bounds each event gap at 30 minutes and uses bounded duration fallbacks. It attributes turns to their payload `started_at` date, excludes batch files, and deduplicates turns globally. See the [methodology](docs/methodology.md) for exact rules and limitations.
100
+
101
+ ## Privacy boundary
102
+
103
+ `--share` emits intentionally sanitized aggregate output and is the safe default for sharing. The card contains counts, durations, and dates, so sharing it remains a deliberate aggregate disclosure.
104
+
105
+ CSV and full text reports contain semi-sensitive activity patterns. Do not commit or share those files accidentally. Keep generated files under the ignored `reports/` directory and review any output before distribution.
106
+
107
+ The scanner retains compact timing metadata only. It does not decode conversation content or send session data over the network. Diagnostics are separate from report output and may include counts and, outside `--share`, bounded local file paths.
108
+
109
+ ## Optional Codex skill
110
+
111
+ The optional Codex skill is available with this repository under [`skills/codex-agent-hour-tracker/`](https://github.com/tim-osterhus/codex-agent-hour-tracker/tree/main/skills/codex-agent-hour-tracker/). Use the repository path when you want the tracker workflow in Codex.
112
+
113
+ ## Development and tests
114
+
115
+ The package requires Python 3.11 or newer. From a source checkout, run the synthetic test suite with:
116
+
117
+ ```bash
118
+ uv run python -m unittest discover -s tests -v
119
+ ```
120
+
121
+ The command exposes `agent-hours` and `codex-agent-hour-tracker` entry points. Packaging metadata and the test suite are the source of truth for supported commands.
122
+
123
+ Read the [security policy](SECURITY.md), [methodology](docs/methodology.md), and [MIT license](LICENSE) before deploying or sharing reports.
@@ -0,0 +1,97 @@
1
+ # Agent Hour Tracker
2
+
3
+ Agent Hour Tracker is a local CLI that measures cumulative Codex agent-hours from completed session turns.
4
+
5
+ If you inspect your own local Codex activity, start with the sanitized Archive Score card:
6
+
7
+ ```bash
8
+ uvx codex-agent-hour-tracker --share
9
+ ```
10
+
11
+ The command reads local session JSONL files and reports completed root and delegated turns. It does not decode conversation content for timing. Use explicit date bounds when you need a reproducible custom report.
12
+
13
+ ## Synthetic Archive Score example
14
+
15
+ The following card uses fabricated dates and values. It is an example, not a report from a person or machine:
16
+
17
+ ```text
18
+ CODEX AGENT-HOUR SCORE
19
+
20
+ 30 complete calendar days | 2025-01-01 to 2025-01-30
21
+ -----------------------------------------------------
22
+ Agent-hours/day: 2.00
23
+ Total agent-hours: 60.00
24
+ Peak day: 4.00
25
+ Completed turns: 12
26
+ Active days: 10/30
27
+
28
+ Archive Score | methodology v1 | tracker v0.1.0
29
+ Calculated locally. No conversation content uploaded.
30
+ ```
31
+
32
+ Agent-hours are cumulative completed-turn durations. If turns overlap, the tracker sums each turn independently. The total measures cumulative runtime, not elapsed wall-clock time.
33
+
34
+ ## Install
35
+
36
+ Install the published package with either tool:
37
+
38
+ ```bash
39
+ uv tool install codex-agent-hour-tracker
40
+ codex-agent-hour-tracker --share
41
+ ```
42
+
43
+ ```bash
44
+ pipx install codex-agent-hour-tracker
45
+ codex-agent-hour-tracker --share
46
+ ```
47
+
48
+ The `agent-hours` command is also installed as a short alias. The CLI reads the local Codex session directory by default. Use `--sessions-dir PATH` to select another directory.
49
+
50
+ ## Custom reports
51
+
52
+ The default report is text. Explicit `--start` and `--end` values define an inclusive local-calendar date range:
53
+
54
+ ```bash
55
+ mkdir -p reports
56
+ agent-hours --start 2025-01-01 --end 2025-01-30 \
57
+ > reports/january-summary.txt
58
+ ```
59
+
60
+ CSV output has one row per calendar day. Save generated reports only under the ignored `reports/` directory:
61
+
62
+ ```bash
63
+ agent-hours --start 2025-01-01 --end 2025-01-30 --format csv \
64
+ > reports/january-summary.csv
65
+ ```
66
+
67
+ Git ignores the `reports/` directory. Do not redirect generated output to tracked files.
68
+
69
+ ## Default window and methodology
70
+
71
+ The canonical Archive Score and a report without date bounds cover the 30 most recent completed local calendar days ending yesterday. Calendar days include days with zero use. If today is March 1, the window is January 30 through February 28.
72
+
73
+ The scanner bounds each event gap at 30 minutes and uses bounded duration fallbacks. It attributes turns to their payload `started_at` date, excludes batch files, and deduplicates turns globally. See the [methodology](docs/methodology.md) for exact rules and limitations.
74
+
75
+ ## Privacy boundary
76
+
77
+ `--share` emits intentionally sanitized aggregate output and is the safe default for sharing. The card contains counts, durations, and dates, so sharing it remains a deliberate aggregate disclosure.
78
+
79
+ CSV and full text reports contain semi-sensitive activity patterns. Do not commit or share those files accidentally. Keep generated files under the ignored `reports/` directory and review any output before distribution.
80
+
81
+ The scanner retains compact timing metadata only. It does not decode conversation content or send session data over the network. Diagnostics are separate from report output and may include counts and, outside `--share`, bounded local file paths.
82
+
83
+ ## Optional Codex skill
84
+
85
+ The optional Codex skill is available with this repository under [`skills/codex-agent-hour-tracker/`](https://github.com/tim-osterhus/codex-agent-hour-tracker/tree/main/skills/codex-agent-hour-tracker/). Use the repository path when you want the tracker workflow in Codex.
86
+
87
+ ## Development and tests
88
+
89
+ The package requires Python 3.11 or newer. From a source checkout, run the synthetic test suite with:
90
+
91
+ ```bash
92
+ uv run python -m unittest discover -s tests -v
93
+ ```
94
+
95
+ The command exposes `agent-hours` and `codex-agent-hour-tracker` entry points. Packaging metadata and the test suite are the source of truth for supported commands.
96
+
97
+ Read the [security policy](SECURITY.md), [methodology](docs/methodology.md), and [MIT license](LICENSE) before deploying or sharing reports.
@@ -0,0 +1,17 @@
1
+ # Security
2
+
3
+ ## Supported versions
4
+
5
+ The latest version is the supported version. Update to the latest published version before investigating or reporting a security issue. Older versions may not contain current fixes.
6
+
7
+ ## Private reports
8
+
9
+ Report vulnerabilities privately through [GitHub Security Advisories](https://github.com/tim-osterhus/codex-agent-hour-tracker/security/advisories) and GitHub's private vulnerability reporting workflow. Do not use a public issue for undisclosed vulnerabilities.
10
+
11
+ Never attach Codex session files, transcripts, or full generated reports to a public issue. Those files can contain sensitive activity data even when the report looks operational.
12
+
13
+ The `--share` command emits a bounded aggregate designed for sharing. Sharing its dates, counts, or durations is still an intentional disclosure. Review the card before publishing it.
14
+
15
+ ## What to include
16
+
17
+ Describe the affected version, the command or input shape involved, and the observed impact. Provide a minimal reproduction that contains synthetic data. Do not include private session content.
@@ -0,0 +1,53 @@
1
+ # Methodology
2
+
3
+ Agent Hour Tracker scans local Codex session JSONL files and retains completed-turn timing metadata. The implementation and tests in `src/agent_hour_tracker/` and `tests/` define the behavior described here.
4
+
5
+ ## Timing
6
+
7
+ For an open turn, each valid top-level event timestamp advances active time from the previous timestamp. Each event gap contributes nonnegative elapsed time capped at 30 minutes. The cap prevents an idle gap from inflating active runtime.
8
+
9
+ When the event timeline has no positive duration, the scanner uses the completion's `duration_ms`, bounded to 30 minutes. If that field is unavailable, it uses `completed_at - started_at`, also bounded to 30 minutes. These are fallback durations. Diagnostics count event-timing fallbacks, and timestamp subtraction also increments duration fallbacks.
10
+
11
+ ## Calendar-day attribution
12
+
13
+ The payload `started_at` value supplies the turn start timestamp. The selected IANA timezone converts that timestamp to a local calendar date. The full turn duration belongs to that start date. The tracker does not split one turn across midnight.
14
+
15
+ An explicit `--start` or `--end` date defines an inclusive range. Turns whose local start date falls outside the range do not contribute. The metrics builder creates one row for every date in the range, including zero-use days. Calendar-day means, medians, percentiles, and the daily histogram use those zero-use rows.
16
+
17
+ ## Archive Score window
18
+
19
+ The canonical Archive Score and an unbounded report use the 30 most recent completed local calendar days ending yesterday. The range is inclusive. If today is March 1, the range is January 30 through February 28. `--share` always uses this window and rejects explicit date bounds and report formats.
20
+
21
+ ## Session selection and batch exclusion
22
+
23
+ The scanner includes interactive root and delegated subagent turns. The scanner classifies a session file as batch when its session metadata identifies `exec`, `batch`, or `codex_exec`, or a direct source mapping contains an `exec` or `batch` key. A batch classification excludes every completed turn found in that file.
24
+
25
+ Unknown source types remain eligible for aggregation. The normal report emits a bounded fingerprint label for an unknown source. Share diagnostics omit that label.
26
+
27
+ ## Global deduplication and ranking
28
+
29
+ The scanner excludes batch files before deduplicating observations globally by `turn_id` across all session files. One observation remains for each ID. The ranking uses event-timing usability first:
30
+
31
+ 1. Prefer an observation with usable event timing.
32
+ 2. Prefer the greater duration.
33
+ 3. Prefer the earlier start timestamp when the first two values tie.
34
+
35
+ The scanner then sorts retained turns by start timestamp, turn-ID type and representation, and duration. Duplicate observations increment the duplicate diagnostic count.
36
+
37
+ ## Diagnostics
38
+
39
+ The CLI reports malformed candidate lines and affected file counts. It reports incomplete turns, unmatched completions, duration fallbacks, event-timing fallbacks, duplicate observations, and excluded batch turns when those counts are nonzero. A normal report also lists unknown source fingerprints. `--share` keeps diagnostics aggregate-only and omits source labels and file paths.
40
+
41
+ ## Limitations
42
+
43
+ - The scanner uses recognized top-level envelopes and bounded direct metadata scalars. It does not decode transcript, reasoning, base instructions, or tool payloads.
44
+
45
+ - The tracker assigns a turn's full duration to its local start date. Long turns can therefore make one day appear busier than the elapsed timeline suggests.
46
+
47
+ - The tracker sums overlapping turns independently. Agent-hours measure cumulative runtime, not wall-clock occupancy.
48
+
49
+ - The 30-minute event-gap cap and fallback durations are estimates when session timestamps are sparse or incomplete.
50
+
51
+ - Batch detection is file-level. One batch classification excludes all completed turns in that file.
52
+
53
+ - Malformed records, incomplete turns, unmatched completions, and invalid metadata do not contribute to totals. Diagnostics expose counts, not the discarded content.
@@ -0,0 +1,54 @@
1
+ [build-system]
2
+ requires = ["hatchling>=1.27"]
3
+ build-backend = "hatchling.build"
4
+
5
+ [project]
6
+ name = "codex-agent-hour-tracker"
7
+ version = "0.1.0"
8
+ description = "Measure cumulative Codex agent runtime without decoding conversation content."
9
+ readme = "README.md"
10
+ requires-python = ">=3.11"
11
+ license = "MIT"
12
+ authors = [
13
+ { name = "Tim Osterhus", email = "tim@millrace.ai" },
14
+ ]
15
+ keywords = ["codex", "agents", "analytics", "privacy"]
16
+ dependencies = [
17
+ "tzlocal>=5.3,<6",
18
+ "tzdata>=2025.2; platform_system == 'Windows'",
19
+ ]
20
+ classifiers = [
21
+ "License :: OSI Approved :: MIT License",
22
+ "Operating System :: OS Independent",
23
+ "Programming Language :: Python :: 3",
24
+ "Programming Language :: Python :: 3 :: Only",
25
+ "Programming Language :: Python :: 3.11",
26
+ "Programming Language :: Python :: 3.12",
27
+ "Programming Language :: Python :: 3.13",
28
+ "Programming Language :: Python :: 3.14",
29
+ "Programming Language :: Python :: Implementation :: CPython",
30
+ "Programming Language :: Python :: Implementation :: PyPy",
31
+ ]
32
+
33
+ [project.urls]
34
+ Homepage = "https://agenthours.dev"
35
+ Repository = "https://github.com/tim-osterhus/codex-agent-hour-tracker"
36
+ Issues = "https://github.com/tim-osterhus/codex-agent-hour-tracker/issues"
37
+
38
+ [project.scripts]
39
+ agent-hours = "agent_hour_tracker.cli:main"
40
+ codex-agent-hour-tracker = "agent_hour_tracker.cli:main"
41
+
42
+ [tool.hatch.build.targets.wheel]
43
+ packages = ["src/agent_hour_tracker"]
44
+
45
+ [tool.hatch.build.targets.sdist]
46
+ include = [
47
+ "/src",
48
+ "/tests",
49
+ "/README.md",
50
+ "/LICENSE",
51
+ "/SECURITY.md",
52
+ "/docs",
53
+ "/pyproject.toml",
54
+ ]
@@ -0,0 +1,6 @@
1
+ """Privacy-preserving duration tracking for Codex session logs."""
2
+
3
+ __all__ = ["METHODOLOGY_VERSION", "__version__"]
4
+
5
+ __version__ = "0.1.0"
6
+ METHODOLOGY_VERSION = "1"
@@ -0,0 +1,3 @@
1
+ from .cli import main
2
+
3
+ raise SystemExit(main())
@@ -0,0 +1,281 @@
1
+ """Command-line interface for the local agent-hour report."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import argparse
6
+ import json
7
+ import sys
8
+ from datetime import date, datetime, timedelta
9
+ from pathlib import Path
10
+ from typing import TextIO
11
+ from zoneinfo import ZoneInfo, ZoneInfoNotFoundError
12
+
13
+ import tzlocal
14
+
15
+ from . import METHODOLOGY_VERSION, __version__
16
+ from .metrics import build_report_metrics
17
+ from .report import render_csv, render_share, render_text
18
+ from .scanner import ScanDiagnostics, scan_sessions
19
+
20
+ __all__ = ["main"]
21
+
22
+ _MAX_MALFORMED_FILE_PATHS = 20
23
+ _MAX_DISPLAYED_PATH_LENGTH = 120
24
+
25
+
26
+ def main(argv: list[str] | None = None) -> int:
27
+ """Run the agent-hour tracker CLI and return a process exit code."""
28
+
29
+ parser = _build_parser()
30
+ try:
31
+ arguments = parser.parse_args(argv)
32
+ except SystemExit as error:
33
+ return int(error.code)
34
+
35
+ if arguments.share and (
36
+ arguments.start is not None
37
+ or arguments.end is not None
38
+ or arguments.format is not None
39
+ ):
40
+ print(
41
+ "error: --share cannot be combined with --start, --end, or --format",
42
+ file=sys.stderr,
43
+ )
44
+ return 2
45
+
46
+ timezone = _resolve_timezone(arguments.timezone, sys.stderr)
47
+ if timezone is None:
48
+ return 2
49
+ report_range = _resolve_report_range(
50
+ None if arguments.share else arguments.start,
51
+ None if arguments.share else arguments.end,
52
+ _today_in_timezone(timezone),
53
+ sys.stderr,
54
+ )
55
+ if report_range is None:
56
+ return 2
57
+ start, end = report_range
58
+
59
+ sessions_dir = arguments.sessions_dir
60
+ if not sessions_dir.is_dir():
61
+ if arguments.share:
62
+ print(
63
+ "error: sessions directory is missing or not a directory",
64
+ file=sys.stderr,
65
+ )
66
+ else:
67
+ print(
68
+ f"error: sessions directory is missing or not a directory: {sessions_dir}",
69
+ file=sys.stderr,
70
+ )
71
+ return 2
72
+
73
+ scan_result = scan_sessions(sessions_dir)
74
+ report = build_report_metrics(scan_result.turns, start, end, timezone)
75
+ if arguments.share:
76
+ sys.stdout.write(render_share(report, __version__, METHODOLOGY_VERSION))
77
+ elif arguments.format == "csv":
78
+ sys.stdout.write(render_csv(report))
79
+ else:
80
+ sys.stdout.write(render_text(report))
81
+ _write_diagnostics(scan_result.diagnostics, sys.stderr, share_safe=arguments.share)
82
+ return 0
83
+
84
+
85
+ def _build_parser() -> argparse.ArgumentParser:
86
+ parser = argparse.ArgumentParser(
87
+ prog="agent-hours",
88
+ description="Report cumulative Codex agent-hours from local session metadata.",
89
+ )
90
+ parser.add_argument(
91
+ "--sessions-dir",
92
+ type=Path,
93
+ default=Path.home() / ".codex" / "sessions",
94
+ help="Codex sessions directory (default: ~/.codex/sessions)",
95
+ )
96
+ parser.add_argument(
97
+ "--start",
98
+ default=None,
99
+ metavar="YYYY-MM-DD",
100
+ help="first report date, inclusive (default: 30 completed days)",
101
+ )
102
+ parser.add_argument(
103
+ "--end",
104
+ default=None,
105
+ metavar="YYYY-MM-DD",
106
+ help="last report date, inclusive (default: yesterday)",
107
+ )
108
+ parser.add_argument(
109
+ "--format",
110
+ choices=("text", "csv"),
111
+ default=None,
112
+ help="report format (default: text; unavailable with --share)",
113
+ )
114
+ parser.add_argument(
115
+ "--share",
116
+ action="store_true",
117
+ help="render a sanitized Archive Score card for sharing (canonical 30-day range)",
118
+ )
119
+ parser.add_argument(
120
+ "--timezone",
121
+ default=None,
122
+ metavar="IANA_ZONE",
123
+ help="IANA timezone (default: discovered local timezone)",
124
+ )
125
+ return parser
126
+
127
+
128
+ def _parse_date(value: str, label: str, stderr: TextIO) -> date | None:
129
+ try:
130
+ return date.fromisoformat(value)
131
+ except ValueError:
132
+ print(f"error: invalid {label} date: {value!r}", file=stderr)
133
+ return None
134
+
135
+
136
+ def _resolve_report_range(
137
+ start_value: str | None,
138
+ end_value: str | None,
139
+ today: date,
140
+ stderr: TextIO,
141
+ ) -> tuple[date, date] | None:
142
+ start = (
143
+ _parse_date(start_value, "start", stderr)
144
+ if start_value is not None
145
+ else None
146
+ )
147
+ if start_value is not None and start is None:
148
+ return None
149
+
150
+ end = (
151
+ _parse_date(end_value, "end", stderr)
152
+ if end_value is not None
153
+ else None
154
+ )
155
+ if end_value is not None and end is None:
156
+ return None
157
+
158
+ if end is None:
159
+ end = today - timedelta(days=1)
160
+ if start is None:
161
+ start = end - timedelta(days=29)
162
+
163
+ if end < start:
164
+ print("error: end date must not precede start date", file=stderr)
165
+ return None
166
+ return start, end
167
+
168
+
169
+ def _resolve_timezone(value: str | None, stderr: TextIO) -> ZoneInfo | None:
170
+ if value is not None:
171
+ try:
172
+ return ZoneInfo(value)
173
+ except (ZoneInfoNotFoundError, ValueError):
174
+ print(f"error: invalid timezone: {value!r}", file=stderr)
175
+ return None
176
+
177
+ timezone_name, warning = _discover_timezone_name()
178
+ if warning:
179
+ print(
180
+ f"warning: using {timezone_name} for the local timezone",
181
+ file=stderr,
182
+ )
183
+ try:
184
+ return ZoneInfo(timezone_name)
185
+ except (ZoneInfoNotFoundError, TypeError, ValueError):
186
+ print("warning: local timezone is unavailable; using UTC", file=stderr)
187
+ return ZoneInfo("UTC")
188
+
189
+
190
+ def _discover_timezone_name() -> tuple[str, bool]:
191
+ try:
192
+ timezone_name = tzlocal.get_localzone_name()
193
+ except Exception:
194
+ return "UTC", True
195
+ if not isinstance(timezone_name, str) or not timezone_name:
196
+ return "UTC", True
197
+ return timezone_name, False
198
+
199
+
200
+ def _today_in_timezone(timezone: ZoneInfo) -> date:
201
+ return datetime.now(timezone).date()
202
+
203
+
204
+ def _write_diagnostics(
205
+ diagnostics: ScanDiagnostics,
206
+ stderr: TextIO,
207
+ share_safe: bool = False,
208
+ ) -> None:
209
+ if share_safe:
210
+ if diagnostics.malformed_lines:
211
+ print(f"Malformed lines: {diagnostics.malformed_lines}", file=stderr)
212
+ if diagnostics.malformed_files:
213
+ print(f"Malformed files: {len(diagnostics.malformed_files)}", file=stderr)
214
+ if diagnostics.incomplete_turns:
215
+ print(f"Incomplete turns: {diagnostics.incomplete_turns}", file=stderr)
216
+ if diagnostics.unmatched_completions:
217
+ print(
218
+ f"Unmatched completions: {diagnostics.unmatched_completions}",
219
+ file=stderr,
220
+ )
221
+ if diagnostics.duration_fallbacks:
222
+ print(f"Duration fallbacks: {diagnostics.duration_fallbacks}", file=stderr)
223
+ if diagnostics.event_timing_fallbacks:
224
+ print(
225
+ f"Event timing fallbacks: {diagnostics.event_timing_fallbacks}",
226
+ file=stderr,
227
+ )
228
+ if diagnostics.duplicate_turns:
229
+ print(f"Duplicate turns: {diagnostics.duplicate_turns}", file=stderr)
230
+ if diagnostics.excluded_batch_turns:
231
+ print(
232
+ f"Excluded batch turns: {diagnostics.excluded_batch_turns}",
233
+ file=stderr,
234
+ )
235
+ return
236
+
237
+ if diagnostics.malformed_lines:
238
+ print(f"Malformed lines: {diagnostics.malformed_lines}", file=stderr)
239
+ if diagnostics.malformed_files:
240
+ malformed_files = sorted(diagnostics.malformed_files)
241
+ print(f"Malformed files: {len(malformed_files)}", file=stderr)
242
+ for path in malformed_files[:_MAX_MALFORMED_FILE_PATHS]:
243
+ print(f" {_display_path(path)}", file=stderr)
244
+ omitted = len(malformed_files) - _MAX_MALFORMED_FILE_PATHS
245
+ if omitted > 0:
246
+ print(
247
+ f" ... {omitted} malformed file paths omitted",
248
+ file=stderr,
249
+ )
250
+ if diagnostics.incomplete_turns:
251
+ print(f"Incomplete turns: {diagnostics.incomplete_turns}", file=stderr)
252
+ if diagnostics.unmatched_completions:
253
+ print(
254
+ f"Unmatched completions: {diagnostics.unmatched_completions}",
255
+ file=stderr,
256
+ )
257
+ if diagnostics.duration_fallbacks:
258
+ print(f"Duration fallbacks: {diagnostics.duration_fallbacks}", file=stderr)
259
+ if diagnostics.event_timing_fallbacks:
260
+ print(
261
+ f"Event timing fallbacks: {diagnostics.event_timing_fallbacks}",
262
+ file=stderr,
263
+ )
264
+ if diagnostics.duplicate_turns:
265
+ print(f"Duplicate turns: {diagnostics.duplicate_turns}", file=stderr)
266
+ if diagnostics.excluded_batch_turns:
267
+ print(
268
+ f"Excluded batch turns: {diagnostics.excluded_batch_turns}",
269
+ file=stderr,
270
+ )
271
+ if diagnostics.unknown_sources:
272
+ print("Unknown sources:", file=stderr)
273
+ for source, count in sorted(diagnostics.unknown_sources.items()):
274
+ print(f" {source}: {count}", file=stderr)
275
+
276
+
277
+ def _display_path(path: Path) -> str:
278
+ escaped = json.dumps(str(path), ensure_ascii=True)
279
+ if len(escaped) <= _MAX_DISPLAYED_PATH_LENGTH:
280
+ return escaped
281
+ return escaped[: _MAX_DISPLAYED_PATH_LENGTH - 4] + '..."'