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.
- codex_agent_hour_tracker-0.1.0/.gitignore +16 -0
- codex_agent_hour_tracker-0.1.0/LICENSE +21 -0
- codex_agent_hour_tracker-0.1.0/PKG-INFO +123 -0
- codex_agent_hour_tracker-0.1.0/README.md +97 -0
- codex_agent_hour_tracker-0.1.0/SECURITY.md +17 -0
- codex_agent_hour_tracker-0.1.0/docs/methodology.md +53 -0
- codex_agent_hour_tracker-0.1.0/pyproject.toml +54 -0
- codex_agent_hour_tracker-0.1.0/src/agent_hour_tracker/__init__.py +6 -0
- codex_agent_hour_tracker-0.1.0/src/agent_hour_tracker/__main__.py +3 -0
- codex_agent_hour_tracker-0.1.0/src/agent_hour_tracker/cli.py +281 -0
- codex_agent_hour_tracker-0.1.0/src/agent_hour_tracker/metrics.py +159 -0
- codex_agent_hour_tracker-0.1.0/src/agent_hour_tracker/report.py +82 -0
- codex_agent_hour_tracker-0.1.0/src/agent_hour_tracker/scanner.py +984 -0
- codex_agent_hour_tracker-0.1.0/tests/test_cli.py +581 -0
- codex_agent_hour_tracker-0.1.0/tests/test_metrics.py +185 -0
- codex_agent_hour_tracker-0.1.0/tests/test_public_files.py +783 -0
- codex_agent_hour_tracker-0.1.0/tests/test_report.py +128 -0
- codex_agent_hour_tracker-0.1.0/tests/test_scanner.py +960 -0
|
@@ -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,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] + '..."'
|