bmsdna-devtools 0.41.0__tar.gz → 0.43.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.
- bmsdna_devtools-0.41.0/README.md → bmsdna_devtools-0.43.0/PKG-INFO +59 -1
- bmsdna_devtools-0.41.0/PKG-INFO → bmsdna_devtools-0.43.0/README.md +42 -15
- {bmsdna_devtools-0.41.0 → bmsdna_devtools-0.43.0}/bmsdna/devtools/cli.py +16 -3
- {bmsdna_devtools-0.41.0 → bmsdna_devtools-0.43.0}/bmsdna/devtools/cli_tools.py +43 -0
- {bmsdna_devtools-0.41.0 → bmsdna_devtools-0.43.0}/bmsdna/devtools/gh_pr.py +18 -3
- {bmsdna_devtools-0.41.0 → bmsdna_devtools-0.43.0}/bmsdna/devtools/pr_build.py +17 -3
- bmsdna_devtools-0.43.0/bmsdna/devtools/usage/__init__.py +5 -0
- bmsdna_devtools-0.43.0/bmsdna/devtools/usage/cli.py +122 -0
- bmsdna_devtools-0.43.0/bmsdna/devtools/usage/pipeline.py +255 -0
- bmsdna_devtools-0.43.0/bmsdna/devtools/usage/pricing.py +59 -0
- bmsdna_devtools-0.43.0/bmsdna/devtools/usage/report.py +334 -0
- bmsdna_devtools-0.43.0/bmsdna/devtools/usage/sql/00_schema.sql +18 -0
- bmsdna_devtools-0.43.0/bmsdna/devtools/usage/sql/01_ingest_claude.sql +83 -0
- bmsdna_devtools-0.43.0/bmsdna/devtools/usage/sql/01_ingest_codex.sql +92 -0
- bmsdna_devtools-0.43.0/bmsdna/devtools/usage/sql/02_messages.sql +105 -0
- bmsdna_devtools-0.43.0/bmsdna/devtools/usage/sql/03_markers.sql +67 -0
- bmsdna_devtools-0.43.0/bmsdna/devtools/usage/sql/04_facts.sql +78 -0
- bmsdna_devtools-0.43.0/bmsdna/devtools/usage/sql/05_views.sql +102 -0
- {bmsdna_devtools-0.41.0 → bmsdna_devtools-0.43.0}/pyproject.toml +5 -1
- {bmsdna_devtools-0.41.0 → bmsdna_devtools-0.43.0}/skills/bmsdna-devtools/SKILL.md +12 -1
- {bmsdna_devtools-0.41.0 → bmsdna_devtools-0.43.0}/tests/test_cli_pr_id.py +2 -2
- {bmsdna_devtools-0.41.0 → bmsdna_devtools-0.43.0}/tests/test_pr_choice.py +2 -2
- bmsdna_devtools-0.43.0/tests/test_usage.py +474 -0
- bmsdna_devtools-0.43.0/tests/test_wait_timeout.py +187 -0
- {bmsdna_devtools-0.41.0 → bmsdna_devtools-0.43.0}/uv.lock +45 -2
- {bmsdna_devtools-0.41.0 → bmsdna_devtools-0.43.0}/.github/workflows/auto-release.yml +0 -0
- {bmsdna_devtools-0.41.0 → bmsdna_devtools-0.43.0}/.github/workflows/python-publish.yml +0 -0
- {bmsdna_devtools-0.41.0 → bmsdna_devtools-0.43.0}/.github/workflows/python-test.yml +0 -0
- {bmsdna_devtools-0.41.0 → bmsdna_devtools-0.43.0}/.gitignore +0 -0
- {bmsdna_devtools-0.41.0 → bmsdna_devtools-0.43.0}/.python-version +0 -0
- {bmsdna_devtools-0.41.0 → bmsdna_devtools-0.43.0}/bmsdna/devtools/__init__.py +0 -0
- {bmsdna_devtools-0.41.0 → bmsdna_devtools-0.43.0}/bmsdna/devtools/_api_dump.py +0 -0
- {bmsdna_devtools-0.41.0 → bmsdna_devtools-0.43.0}/bmsdna/devtools/ado_auth.py +0 -0
- {bmsdna_devtools-0.41.0 → bmsdna_devtools-0.43.0}/bmsdna/devtools/ado_issue.py +0 -0
- {bmsdna_devtools-0.41.0 → bmsdna_devtools-0.43.0}/bmsdna/devtools/agent_mentions.py +0 -0
- {bmsdna_devtools-0.41.0 → bmsdna_devtools-0.43.0}/bmsdna/devtools/api_usage.py +0 -0
- {bmsdna_devtools-0.41.0 → bmsdna_devtools-0.43.0}/bmsdna/devtools/app_service_logs.py +0 -0
- {bmsdna_devtools-0.41.0 → bmsdna_devtools-0.43.0}/bmsdna/devtools/bdt_config.py +0 -0
- {bmsdna_devtools-0.41.0 → bmsdna_devtools-0.43.0}/bmsdna/devtools/commit.py +0 -0
- {bmsdna_devtools-0.41.0 → bmsdna_devtools-0.43.0}/bmsdna/devtools/dead_code.py +0 -0
- {bmsdna_devtools-0.41.0 → bmsdna_devtools-0.43.0}/bmsdna/devtools/env_config.py +0 -0
- {bmsdna_devtools-0.41.0 → bmsdna_devtools-0.43.0}/bmsdna/devtools/env_files.py +0 -0
- {bmsdna_devtools-0.41.0 → bmsdna_devtools-0.43.0}/bmsdna/devtools/find_injection.py +0 -0
- {bmsdna_devtools-0.41.0 → bmsdna_devtools-0.43.0}/bmsdna/devtools/find_repo.py +0 -0
- {bmsdna_devtools-0.41.0 → bmsdna_devtools-0.43.0}/bmsdna/devtools/gh_issue.py +0 -0
- {bmsdna_devtools-0.41.0 → bmsdna_devtools-0.43.0}/bmsdna/devtools/gitrepo.py +0 -0
- {bmsdna_devtools-0.41.0 → bmsdna_devtools-0.43.0}/bmsdna/devtools/injection_frontend.py +0 -0
- {bmsdna_devtools-0.41.0 → bmsdna_devtools-0.43.0}/bmsdna/devtools/injection_python.py +0 -0
- {bmsdna_devtools-0.41.0 → bmsdna_devtools-0.43.0}/bmsdna/devtools/issue_do.py +0 -0
- {bmsdna_devtools-0.41.0 → bmsdna_devtools-0.43.0}/bmsdna/devtools/issue_show.py +0 -0
- {bmsdna_devtools-0.41.0 → bmsdna_devtools-0.43.0}/bmsdna/devtools/issue_watch.py +0 -0
- {bmsdna_devtools-0.41.0 → bmsdna_devtools-0.43.0}/bmsdna/devtools/lint.py +0 -0
- {bmsdna_devtools-0.41.0 → bmsdna_devtools-0.43.0}/bmsdna/devtools/lint_findings.py +0 -0
- {bmsdna_devtools-0.41.0 → bmsdna_devtools-0.43.0}/bmsdna/devtools/lint_models.py +0 -0
- {bmsdna_devtools-0.41.0 → bmsdna_devtools-0.43.0}/bmsdna/devtools/lint_sql.py +0 -0
- {bmsdna_devtools-0.41.0 → bmsdna_devtools-0.43.0}/bmsdna/devtools/lint_sql_files.py +0 -0
- {bmsdna_devtools-0.41.0 → bmsdna_devtools-0.43.0}/bmsdna/devtools/lint_tooling.py +0 -0
- {bmsdna_devtools-0.41.0 → bmsdna_devtools-0.43.0}/bmsdna/devtools/lint_typescript.py +0 -0
- {bmsdna_devtools-0.41.0 → bmsdna_devtools-0.43.0}/bmsdna/devtools/logs.py +0 -0
- {bmsdna_devtools-0.41.0 → bmsdna_devtools-0.43.0}/bmsdna/devtools/pr_choice.py +0 -0
- {bmsdna_devtools-0.41.0 → bmsdna_devtools-0.43.0}/bmsdna/devtools/pr_info.py +0 -0
- {bmsdna_devtools-0.41.0 → bmsdna_devtools-0.43.0}/bmsdna/devtools/pr_issue_link.py +0 -0
- {bmsdna_devtools-0.41.0 → bmsdna_devtools-0.43.0}/bmsdna/devtools/pr_labels.py +0 -0
- {bmsdna_devtools-0.41.0 → bmsdna_devtools-0.43.0}/bmsdna/devtools/pr_markdown.py +0 -0
- {bmsdna_devtools-0.41.0 → bmsdna_devtools-0.43.0}/bmsdna/devtools/pull.py +0 -0
- {bmsdna_devtools-0.41.0 → bmsdna_devtools-0.43.0}/bmsdna/devtools/testdb.py +0 -0
- {bmsdna_devtools-0.41.0 → bmsdna_devtools-0.43.0}/bmsdna/devtools/translate.py +0 -0
- {bmsdna_devtools-0.41.0 → bmsdna_devtools-0.43.0}/bmsdna/devtools/worktree.py +0 -0
- {bmsdna_devtools-0.41.0 → bmsdna_devtools-0.43.0}/justfile +0 -0
- {bmsdna_devtools-0.41.0 → bmsdna_devtools-0.43.0}/tests/conftest.py +0 -0
- {bmsdna_devtools-0.41.0 → bmsdna_devtools-0.43.0}/tests/test_ado_auth.py +0 -0
- {bmsdna_devtools-0.41.0 → bmsdna_devtools-0.43.0}/tests/test_ado_issue.py +0 -0
- {bmsdna_devtools-0.41.0 → bmsdna_devtools-0.43.0}/tests/test_ado_issue_flow.py +0 -0
- {bmsdna_devtools-0.41.0 → bmsdna_devtools-0.43.0}/tests/test_agent_mentions.py +0 -0
- {bmsdna_devtools-0.41.0 → bmsdna_devtools-0.43.0}/tests/test_api_usage.py +0 -0
- {bmsdna_devtools-0.41.0 → bmsdna_devtools-0.43.0}/tests/test_app_service_logs.py +0 -0
- {bmsdna_devtools-0.41.0 → bmsdna_devtools-0.43.0}/tests/test_bdt_config.py +0 -0
- {bmsdna_devtools-0.41.0 → bmsdna_devtools-0.43.0}/tests/test_cli_cleanup.py +0 -0
- {bmsdna_devtools-0.41.0 → bmsdna_devtools-0.43.0}/tests/test_cli_commit.py +0 -0
- {bmsdna_devtools-0.41.0 → bmsdna_devtools-0.43.0}/tests/test_cli_issue.py +0 -0
- {bmsdna_devtools-0.41.0 → bmsdna_devtools-0.43.0}/tests/test_cli_pr_create.py +0 -0
- {bmsdna_devtools-0.41.0 → bmsdna_devtools-0.43.0}/tests/test_cli_pull.py +0 -0
- {bmsdna_devtools-0.41.0 → bmsdna_devtools-0.43.0}/tests/test_cli_tools.py +0 -0
- {bmsdna_devtools-0.41.0 → bmsdna_devtools-0.43.0}/tests/test_commit.py +0 -0
- {bmsdna_devtools-0.41.0 → bmsdna_devtools-0.43.0}/tests/test_env_config.py +0 -0
- {bmsdna_devtools-0.41.0 → bmsdna_devtools-0.43.0}/tests/test_env_files.py +0 -0
- {bmsdna_devtools-0.41.0 → bmsdna_devtools-0.43.0}/tests/test_find_injection.py +0 -0
- {bmsdna_devtools-0.41.0 → bmsdna_devtools-0.43.0}/tests/test_find_repo.py +0 -0
- {bmsdna_devtools-0.41.0 → bmsdna_devtools-0.43.0}/tests/test_gh_issue.py +0 -0
- {bmsdna_devtools-0.41.0 → bmsdna_devtools-0.43.0}/tests/test_gh_pr.py +0 -0
- {bmsdna_devtools-0.41.0 → bmsdna_devtools-0.43.0}/tests/test_gitrepo.py +0 -0
- {bmsdna_devtools-0.41.0 → bmsdna_devtools-0.43.0}/tests/test_issue_do.py +0 -0
- {bmsdna_devtools-0.41.0 → bmsdna_devtools-0.43.0}/tests/test_issue_for_me.py +0 -0
- {bmsdna_devtools-0.41.0 → bmsdna_devtools-0.43.0}/tests/test_issue_show.py +0 -0
- {bmsdna_devtools-0.41.0 → bmsdna_devtools-0.43.0}/tests/test_issue_watch.py +0 -0
- {bmsdna_devtools-0.41.0 → bmsdna_devtools-0.43.0}/tests/test_lint.py +0 -0
- {bmsdna_devtools-0.41.0 → bmsdna_devtools-0.43.0}/tests/test_lint_models.py +0 -0
- {bmsdna_devtools-0.41.0 → bmsdna_devtools-0.43.0}/tests/test_lint_sql.py +0 -0
- {bmsdna_devtools-0.41.0 → bmsdna_devtools-0.43.0}/tests/test_lint_sql_files.py +0 -0
- {bmsdna_devtools-0.41.0 → bmsdna_devtools-0.43.0}/tests/test_lint_tooling.py +0 -0
- {bmsdna_devtools-0.41.0 → bmsdna_devtools-0.43.0}/tests/test_lint_typescript.py +0 -0
- {bmsdna_devtools-0.41.0 → bmsdna_devtools-0.43.0}/tests/test_pr_build.py +0 -0
- {bmsdna_devtools-0.41.0 → bmsdna_devtools-0.43.0}/tests/test_pr_build_flow.py +0 -0
- {bmsdna_devtools-0.41.0 → bmsdna_devtools-0.43.0}/tests/test_pr_description_not_truncated.py +0 -0
- {bmsdna_devtools-0.41.0 → bmsdna_devtools-0.43.0}/tests/test_pr_info.py +0 -0
- {bmsdna_devtools-0.41.0 → bmsdna_devtools-0.43.0}/tests/test_pr_issue_link.py +0 -0
- {bmsdna_devtools-0.41.0 → bmsdna_devtools-0.43.0}/tests/test_pr_labels.py +0 -0
- {bmsdna_devtools-0.41.0 → bmsdna_devtools-0.43.0}/tests/test_pr_markdown.py +0 -0
- {bmsdna_devtools-0.41.0 → bmsdna_devtools-0.43.0}/tests/test_pull.py +0 -0
- {bmsdna_devtools-0.41.0 → bmsdna_devtools-0.43.0}/tests/test_testdb.py +0 -0
- {bmsdna_devtools-0.41.0 → bmsdna_devtools-0.43.0}/tests/test_translate.py +0 -0
- {bmsdna_devtools-0.41.0 → bmsdna_devtools-0.43.0}/tests/test_worktree_clean.py +0 -0
|
@@ -1,3 +1,20 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: bmsdna-devtools
|
|
3
|
+
Version: 0.43.0
|
|
4
|
+
Summary: Shared Azure DevOps / GitHub / git / Azure Monitor developer tooling for BMS projects
|
|
5
|
+
Requires-Python: >=3.14
|
|
6
|
+
Requires-Dist: pgdevkit[db]>=0.7.1
|
|
7
|
+
Requires-Dist: requests>=2.32.0
|
|
8
|
+
Requires-Dist: rich>=13.0.0
|
|
9
|
+
Requires-Dist: tomli-w>=1.0.0
|
|
10
|
+
Requires-Dist: typer>=0.26.7
|
|
11
|
+
Provides-Extra: lint
|
|
12
|
+
Requires-Dist: sqlglot>=27.0.0; extra == 'lint'
|
|
13
|
+
Provides-Extra: usage
|
|
14
|
+
Requires-Dist: duckdb>=1.3.0; extra == 'usage'
|
|
15
|
+
Requires-Dist: plotly>=5.24.0; extra == 'usage'
|
|
16
|
+
Description-Content-Type: text/markdown
|
|
17
|
+
|
|
1
18
|
# bmsdna-devtools
|
|
2
19
|
|
|
3
20
|
Shared developer tooling for BMS projects: PR build/check status and a
|
|
@@ -29,6 +46,8 @@ uv tool install bmsdna-devtools
|
|
|
29
46
|
|
|
30
47
|
Or as a project dependency: `uv add bmsdna-devtools`.
|
|
31
48
|
|
|
49
|
+
Optional extras: `bmsdna-devtools[lint]` (`bdt lint` SQL rules), `bmsdna-devtools[usage]` (`bdt usage`, below).
|
|
50
|
+
|
|
32
51
|
## `bdt pr status`
|
|
33
52
|
|
|
34
53
|
Find the PR opened from the current branch and report build/check status
|
|
@@ -36,7 +55,7 @@ Find the PR opened from the current branch and report build/check status
|
|
|
36
55
|
GitHub — whichever `origin` points at.
|
|
37
56
|
|
|
38
57
|
```bash
|
|
39
|
-
bdt pr status [--target-branch BRANCH] [--wait]
|
|
58
|
+
bdt pr status [--target-branch BRANCH] [--pr-id N] [--wait [--timeout SECONDS]]
|
|
40
59
|
```
|
|
41
60
|
|
|
42
61
|
Every command that acts on an existing PR (`pr status/info/retry/publish/update/comment`, `issue take` without
|
|
@@ -61,6 +80,12 @@ stage/check needs a reviewer plus a link to act on it, and exits **3**
|
|
|
61
80
|
build/check has already failed, that's reported instead (exit 1) even when
|
|
62
81
|
one is also waiting on approval.
|
|
63
82
|
|
|
83
|
+
`--wait --timeout SECONDS` gives up after that long when the checks are still running: it prints the current
|
|
84
|
+
state of every pipeline/check, ends with `Still running after N s. Re-run ... to keep waiting.` (the command pins `--pr-id` to the PR it watched) and exits **4**
|
|
85
|
+
(like `issue watch`). Use it where the caller's own limit is shorter than CI, e.g. an agent's shell tool, which
|
|
86
|
+
kills the command after a few minutes and discards its output; re-run the same command to keep waiting.
|
|
87
|
+
Without `--timeout` (or with `0`) it waits until everything is done, as before. `--timeout` needs `--wait`.
|
|
88
|
+
|
|
64
89
|
On Azure DevOps, `--wait` on a PR with no builds yet also checks that the target branch has an enabled
|
|
65
90
|
Build validation policy; if it doesn't, no build will ever run, so it exits 1 with an error instead of
|
|
66
91
|
polling forever (a failed policy lookup is ignored and the wait continues).
|
|
@@ -1018,6 +1043,39 @@ such as `'assets/**/*.js'`) or `[tool.bdt.lint] exclude_dirs = [...]` in `pyproj
|
|
|
1018
1043
|
Identical findings on the same line are reported once. Silence a confirmed-safe finding with
|
|
1019
1044
|
`# bdt-lint: ignore <rule>` (Python) or `// bdt-lint: ignore <rule>` (TS/JS) on, or directly above, the line.
|
|
1020
1045
|
|
|
1046
|
+
## `bdt usage analyze` / `bdt usage sql`
|
|
1047
|
+
|
|
1048
|
+
Where do the tokens of your Claude Code and codex sessions go, per step of the `dev-workflow`? Needs the extra:
|
|
1049
|
+
`uv tool install 'bmsdna-devtools[usage]'` (DuckDB reads the logs, plotly draws the report; neither is imported by other commands).
|
|
1050
|
+
|
|
1051
|
+
```bash
|
|
1052
|
+
bdt usage analyze [--scope workflow|all] [--since 2026-10-01] [--price gpt-6-sol=1.25,10,0.125] [--no-codex] [--rebuild]
|
|
1053
|
+
bdt usage sql "select label, round(pct_cost, 1) from v_steps" # ad hoc SQL on the last analysis
|
|
1054
|
+
```
|
|
1055
|
+
|
|
1056
|
+
It reads `~/.claude/projects` (`--logs`) and `~/.codex/sessions` (`--codex-logs`, skipped if missing), prints a summary and
|
|
1057
|
+
writes `report.html` (charts plus a table view, light/dark) with CSV / Parquet exports next to the database
|
|
1058
|
+
(`~/.cache/bdt/usage/`, or `--db` / `--out`). The report is self-contained and contains your session titles and agent
|
|
1059
|
+
descriptions, so look at it before sharing it.
|
|
1060
|
+
|
|
1061
|
+
- **Steps:** setup, plan, plan review, implement, test, screenshots, design review, quick review, full review, publish / CI.
|
|
1062
|
+
Every model turn is assigned to one by markers in its tool calls: skill calls (`bms-code-review --quick|--full`,
|
|
1063
|
+
`design-review`, `bms-plan-review`; for codex, reading the skill's `SKILL.md`), `bdt` commands, edits (`apply_patch` for
|
|
1064
|
+
codex), pytest / lint, screenshot tooling, image reads. A step stays until the next marker; test runs only count for
|
|
1065
|
+
the turn that runs them. Subagents inherit the step of the turn that spawned them. Only threads that follow the workflow
|
|
1066
|
+
(and what they spawn) are counted, `--scope all` takes every session. It is a heuristic: read differences of a few percent
|
|
1067
|
+
as noise. The rules are SQL in `bmsdna/devtools/usage/sql/03_markers.sql`.
|
|
1068
|
+
- **Numbers:** cost is the API list price (cache writes 1.25x / 2x, cache reads, output, per model), not your actual bill;
|
|
1069
|
+
"% tokens" counts cache reads too, "% fresh tokens" does not. The report also estimates what screenshots cost by sitting in
|
|
1070
|
+
the context of every later turn.
|
|
1071
|
+
- **codex:** a codex run that Claude started with `codex exec` / `codex review` (same directory, within 15 minutes) is counted
|
|
1072
|
+
in that Claude session and step, so reviews run by codex show up under "Review" even though Claude's own log doesn't contain
|
|
1073
|
+
them; codex subagents follow their parent thread. codex models have no built-in price: give one with
|
|
1074
|
+
`--price MODEL=INPUT,OUTPUT,CACHED` (USD per million tokens), repeatable. Turns without a price are counted in tokens
|
|
1075
|
+
but missing from every cost number, and the summary says how many.
|
|
1076
|
+
- **Cache:** parsed records are kept in the database, so the next run only parses new or changed log files (a few seconds).
|
|
1077
|
+
`--rebuild` re-reads everything. Message text is not stored.
|
|
1078
|
+
|
|
1021
1079
|
## Releasing
|
|
1022
1080
|
|
|
1023
1081
|
Bump `version` in `pyproject.toml` as part of your PR, same as any other
|
|
@@ -1,17 +1,3 @@
|
|
|
1
|
-
Metadata-Version: 2.5
|
|
2
|
-
Name: bmsdna-devtools
|
|
3
|
-
Version: 0.41.0
|
|
4
|
-
Summary: Shared Azure DevOps / GitHub / git / Azure Monitor developer tooling for BMS projects
|
|
5
|
-
Requires-Python: >=3.14
|
|
6
|
-
Requires-Dist: pgdevkit[db]>=0.7.1
|
|
7
|
-
Requires-Dist: requests>=2.32.0
|
|
8
|
-
Requires-Dist: rich>=13.0.0
|
|
9
|
-
Requires-Dist: tomli-w>=1.0.0
|
|
10
|
-
Requires-Dist: typer>=0.26.7
|
|
11
|
-
Provides-Extra: lint
|
|
12
|
-
Requires-Dist: sqlglot>=27.0.0; extra == 'lint'
|
|
13
|
-
Description-Content-Type: text/markdown
|
|
14
|
-
|
|
15
1
|
# bmsdna-devtools
|
|
16
2
|
|
|
17
3
|
Shared developer tooling for BMS projects: PR build/check status and a
|
|
@@ -43,6 +29,8 @@ uv tool install bmsdna-devtools
|
|
|
43
29
|
|
|
44
30
|
Or as a project dependency: `uv add bmsdna-devtools`.
|
|
45
31
|
|
|
32
|
+
Optional extras: `bmsdna-devtools[lint]` (`bdt lint` SQL rules), `bmsdna-devtools[usage]` (`bdt usage`, below).
|
|
33
|
+
|
|
46
34
|
## `bdt pr status`
|
|
47
35
|
|
|
48
36
|
Find the PR opened from the current branch and report build/check status
|
|
@@ -50,7 +38,7 @@ Find the PR opened from the current branch and report build/check status
|
|
|
50
38
|
GitHub — whichever `origin` points at.
|
|
51
39
|
|
|
52
40
|
```bash
|
|
53
|
-
bdt pr status [--target-branch BRANCH] [--wait]
|
|
41
|
+
bdt pr status [--target-branch BRANCH] [--pr-id N] [--wait [--timeout SECONDS]]
|
|
54
42
|
```
|
|
55
43
|
|
|
56
44
|
Every command that acts on an existing PR (`pr status/info/retry/publish/update/comment`, `issue take` without
|
|
@@ -75,6 +63,12 @@ stage/check needs a reviewer plus a link to act on it, and exits **3**
|
|
|
75
63
|
build/check has already failed, that's reported instead (exit 1) even when
|
|
76
64
|
one is also waiting on approval.
|
|
77
65
|
|
|
66
|
+
`--wait --timeout SECONDS` gives up after that long when the checks are still running: it prints the current
|
|
67
|
+
state of every pipeline/check, ends with `Still running after N s. Re-run ... to keep waiting.` (the command pins `--pr-id` to the PR it watched) and exits **4**
|
|
68
|
+
(like `issue watch`). Use it where the caller's own limit is shorter than CI, e.g. an agent's shell tool, which
|
|
69
|
+
kills the command after a few minutes and discards its output; re-run the same command to keep waiting.
|
|
70
|
+
Without `--timeout` (or with `0`) it waits until everything is done, as before. `--timeout` needs `--wait`.
|
|
71
|
+
|
|
78
72
|
On Azure DevOps, `--wait` on a PR with no builds yet also checks that the target branch has an enabled
|
|
79
73
|
Build validation policy; if it doesn't, no build will ever run, so it exits 1 with an error instead of
|
|
80
74
|
polling forever (a failed policy lookup is ignored and the wait continues).
|
|
@@ -1032,6 +1026,39 @@ such as `'assets/**/*.js'`) or `[tool.bdt.lint] exclude_dirs = [...]` in `pyproj
|
|
|
1032
1026
|
Identical findings on the same line are reported once. Silence a confirmed-safe finding with
|
|
1033
1027
|
`# bdt-lint: ignore <rule>` (Python) or `// bdt-lint: ignore <rule>` (TS/JS) on, or directly above, the line.
|
|
1034
1028
|
|
|
1029
|
+
## `bdt usage analyze` / `bdt usage sql`
|
|
1030
|
+
|
|
1031
|
+
Where do the tokens of your Claude Code and codex sessions go, per step of the `dev-workflow`? Needs the extra:
|
|
1032
|
+
`uv tool install 'bmsdna-devtools[usage]'` (DuckDB reads the logs, plotly draws the report; neither is imported by other commands).
|
|
1033
|
+
|
|
1034
|
+
```bash
|
|
1035
|
+
bdt usage analyze [--scope workflow|all] [--since 2026-10-01] [--price gpt-6-sol=1.25,10,0.125] [--no-codex] [--rebuild]
|
|
1036
|
+
bdt usage sql "select label, round(pct_cost, 1) from v_steps" # ad hoc SQL on the last analysis
|
|
1037
|
+
```
|
|
1038
|
+
|
|
1039
|
+
It reads `~/.claude/projects` (`--logs`) and `~/.codex/sessions` (`--codex-logs`, skipped if missing), prints a summary and
|
|
1040
|
+
writes `report.html` (charts plus a table view, light/dark) with CSV / Parquet exports next to the database
|
|
1041
|
+
(`~/.cache/bdt/usage/`, or `--db` / `--out`). The report is self-contained and contains your session titles and agent
|
|
1042
|
+
descriptions, so look at it before sharing it.
|
|
1043
|
+
|
|
1044
|
+
- **Steps:** setup, plan, plan review, implement, test, screenshots, design review, quick review, full review, publish / CI.
|
|
1045
|
+
Every model turn is assigned to one by markers in its tool calls: skill calls (`bms-code-review --quick|--full`,
|
|
1046
|
+
`design-review`, `bms-plan-review`; for codex, reading the skill's `SKILL.md`), `bdt` commands, edits (`apply_patch` for
|
|
1047
|
+
codex), pytest / lint, screenshot tooling, image reads. A step stays until the next marker; test runs only count for
|
|
1048
|
+
the turn that runs them. Subagents inherit the step of the turn that spawned them. Only threads that follow the workflow
|
|
1049
|
+
(and what they spawn) are counted, `--scope all` takes every session. It is a heuristic: read differences of a few percent
|
|
1050
|
+
as noise. The rules are SQL in `bmsdna/devtools/usage/sql/03_markers.sql`.
|
|
1051
|
+
- **Numbers:** cost is the API list price (cache writes 1.25x / 2x, cache reads, output, per model), not your actual bill;
|
|
1052
|
+
"% tokens" counts cache reads too, "% fresh tokens" does not. The report also estimates what screenshots cost by sitting in
|
|
1053
|
+
the context of every later turn.
|
|
1054
|
+
- **codex:** a codex run that Claude started with `codex exec` / `codex review` (same directory, within 15 minutes) is counted
|
|
1055
|
+
in that Claude session and step, so reviews run by codex show up under "Review" even though Claude's own log doesn't contain
|
|
1056
|
+
them; codex subagents follow their parent thread. codex models have no built-in price: give one with
|
|
1057
|
+
`--price MODEL=INPUT,OUTPUT,CACHED` (USD per million tokens), repeatable. Turns without a price are counted in tokens
|
|
1058
|
+
but missing from every cost number, and the summary says how many.
|
|
1059
|
+
- **Cache:** parsed records are kept in the database, so the next run only parses new or changed log files (a few seconds).
|
|
1060
|
+
`--rebuild` re-reads everything. Message text is not stored.
|
|
1061
|
+
|
|
1035
1062
|
## Releasing
|
|
1036
1063
|
|
|
1037
1064
|
Bump `version` in `pyproject.toml` as part of your PR, same as any other
|
|
@@ -34,7 +34,8 @@ from . import translate as translate_mod
|
|
|
34
34
|
from . import pr_build, pr_info as pr_info_mod, pr_issue_link, pr_labels, pull as pull_mod, worktree as worktree_mod
|
|
35
35
|
from .ado_auth import auth_header
|
|
36
36
|
from .bdt_config import find_pyproject, load_bdt_table
|
|
37
|
-
from .cli_tools import CLI_TIMEOUT_SECS, detect_agent_session, require_az, require_gh
|
|
37
|
+
from .cli_tools import CLI_TIMEOUT_SECS, EXIT_WAIT_TIMEOUT, detect_agent_session, require_az, require_gh
|
|
38
|
+
from .usage.cli import usage_app
|
|
38
39
|
from .gitrepo import (
|
|
39
40
|
AdoRemote,
|
|
40
41
|
GitHubRemote,
|
|
@@ -103,6 +104,8 @@ issue_app.add_typer(issue_comment_app, name="comment")
|
|
|
103
104
|
logs_app = typer.Typer(name="logs", help="Application Insights / Log Analytics queries")
|
|
104
105
|
app.add_typer(logs_app, name="logs")
|
|
105
106
|
|
|
107
|
+
app.add_typer(usage_app, name="usage")
|
|
108
|
+
|
|
106
109
|
cleanup_app = typer.Typer(
|
|
107
110
|
name="cleanup",
|
|
108
111
|
help="Prune merged git worktrees and their orphaned pgdevkit Postgres test databases",
|
|
@@ -468,18 +471,28 @@ def pr_publish(
|
|
|
468
471
|
def pr_status(
|
|
469
472
|
target_branch: str | None = typer.Option(None, "--target-branch", help=_LOOKUP_TARGET_HELP),
|
|
470
473
|
wait: bool = typer.Option(False, "--wait", help="Poll until all pipelines/checks are completed; stops early and reports status if one needs manual approval"),
|
|
474
|
+
timeout: float = typer.Option(
|
|
475
|
+
0,
|
|
476
|
+
"--timeout",
|
|
477
|
+
help="With --wait: stop after this many seconds if the checks are still running, print their current state and exit "
|
|
478
|
+
f"{EXIT_WAIT_TIMEOUT} (0 = wait forever, the default). Use it where the caller's own time limit is shorter than CI, "
|
|
479
|
+
"e.g. an agent's shell tool, then re-run the same command to keep waiting",
|
|
480
|
+
),
|
|
471
481
|
pr_id: int | None = typer.Option(None, "--pr-id", help=_PR_ID_HELP),
|
|
472
482
|
pat: str | None = typer.Option(None, "--pat", envvar=["AZURE_DEVOPS_EXT_PAT", "AZURE_DEVOPS_PAT"], help="Azure DevOps PAT (else falls back to `az` login)"),
|
|
473
483
|
) -> None:
|
|
474
484
|
"""Show build/check status for the PR opened from the current branch, or for --pr-id
|
|
475
485
|
directly (Azure DevOps or GitHub, auto-detected).
|
|
476
486
|
"""
|
|
487
|
+
if timeout and not wait:
|
|
488
|
+
raise typer.BadParameter("--timeout only works together with --wait", param_hint="--timeout")
|
|
489
|
+
_check_poll_options(None, timeout)
|
|
477
490
|
remote = current_remote()
|
|
478
491
|
if isinstance(remote, GitHubRemote):
|
|
479
492
|
gh = require_gh()
|
|
480
|
-
gh_pr.run(gh, wait, pr_id=gh_pr.resolve_pr_id(gh, pr_id, target_branch))
|
|
493
|
+
gh_pr.run(gh, wait, pr_id=gh_pr.resolve_pr_id(gh, pr_id, target_branch), timeout=timeout)
|
|
481
494
|
return
|
|
482
|
-
pr_build.run(remote, pat, target_branch, wait, pr_id=pr_id)
|
|
495
|
+
pr_build.run(remote, pat, target_branch, wait, pr_id=pr_id, timeout=timeout)
|
|
483
496
|
|
|
484
497
|
|
|
485
498
|
@pr_app.command("info")
|
|
@@ -13,7 +13,9 @@ import os
|
|
|
13
13
|
import shutil
|
|
14
14
|
import sys
|
|
15
15
|
import time
|
|
16
|
+
from collections.abc import Callable
|
|
16
17
|
from dataclasses import dataclass
|
|
18
|
+
from typing import NoReturn
|
|
17
19
|
|
|
18
20
|
AZ_INSTALL_HINT = "Install the Azure CLI: https://learn.microsoft.com/cli/azure/install-azure-cli"
|
|
19
21
|
GH_INSTALL_HINT = "Install the GitHub CLI: https://cli.github.com"
|
|
@@ -26,6 +28,11 @@ PSQL_INSTALL_HINT = "Install the PostgreSQL client tools (psql): https://www.pos
|
|
|
26
28
|
# this tool), and callers branching on exit code shouldn't confuse the two.
|
|
27
29
|
EXIT_NEEDS_APPROVAL = 3
|
|
28
30
|
|
|
31
|
+
# `pr status --wait --timeout N` exits with this code when the checks are still running after N seconds
|
|
32
|
+
# (same code as `issue watch` / `issue search --wait` use for "gave up waiting"). The caller learns the
|
|
33
|
+
# current state from the output and can simply run the same command again to keep waiting.
|
|
34
|
+
EXIT_WAIT_TIMEOUT = 4
|
|
35
|
+
|
|
29
36
|
# How long a `--wait` poll loop may go without printing anything before `PollHeartbeat`
|
|
30
37
|
# forces a line out anyway. Every `--wait` loop in this tool polls every 30s but used to
|
|
31
38
|
# print only when the status text changed -- for a check/build that sits in the same
|
|
@@ -73,6 +80,42 @@ class PollHeartbeat:
|
|
|
73
80
|
self._last_print = now
|
|
74
81
|
|
|
75
82
|
|
|
83
|
+
class WaitDeadline:
|
|
84
|
+
"""Optional overall time limit of a `--wait` poll loop (`timeout_secs` of 0 / None = wait forever).
|
|
85
|
+
|
|
86
|
+
Exists because a caller often can't wait as long as CI takes: an agent's shell tool kills a command
|
|
87
|
+
after a few minutes and throws its output away. Giving up on our own terms lets us report where things
|
|
88
|
+
stand and exit with `EXIT_WAIT_TIMEOUT` so the caller knows to re-run.
|
|
89
|
+
"""
|
|
90
|
+
|
|
91
|
+
def __init__(self, timeout_secs: float | None) -> None:
|
|
92
|
+
self.timeout_secs = timeout_secs or 0.0
|
|
93
|
+
self._end = time.monotonic() + self.timeout_secs if self.timeout_secs else None
|
|
94
|
+
|
|
95
|
+
def expired(self) -> bool:
|
|
96
|
+
return self._end is not None and time.monotonic() >= self._end
|
|
97
|
+
|
|
98
|
+
def sleep(self, secs: float) -> None:
|
|
99
|
+
"""`time.sleep(secs)`, cut short so the loop wakes up right at the deadline."""
|
|
100
|
+
time.sleep(secs if self._end is None else max(0.0, min(secs, self._end - time.monotonic())))
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def status_rerun_cmd(pr_id: int | None, timeout_secs: float) -> str:
|
|
104
|
+
"""The command that keeps waiting where a timed-out `pr status --wait` stopped. Pins `--pr-id` to the PR that
|
|
105
|
+
was being watched, so re-running can't resolve to a different PR (e.g. another branch checked out since)."""
|
|
106
|
+
return f"bdt pr status --pr-id {pr_id} --wait --timeout {timeout_secs:g}" if pr_id else f"bdt pr status --wait --timeout {timeout_secs:g}"
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def exit_wait_timeout(msg: str, print_details: Callable[[], object], deadline: WaitDeadline, rerun_cmd: str) -> NoReturn:
|
|
110
|
+
"""Report where a `--wait` loop stands when its `deadline` ran out (status line, per-item details), then
|
|
111
|
+
exit `EXIT_WAIT_TIMEOUT`. The last line tells the caller what to run to keep waiting."""
|
|
112
|
+
print(msg)
|
|
113
|
+
print("\nDetails:")
|
|
114
|
+
print_details()
|
|
115
|
+
print(f"\nStill running after {deadline.timeout_secs:g} s. Re-run `{rerun_cmd}` to keep waiting.")
|
|
116
|
+
sys.exit(EXIT_WAIT_TIMEOUT)
|
|
117
|
+
|
|
118
|
+
|
|
76
119
|
def is_claude_code() -> bool:
|
|
77
120
|
"""True if this process is running as a subprocess of Claude Code.
|
|
78
121
|
|
|
@@ -20,7 +20,17 @@ from pathlib import Path
|
|
|
20
20
|
from typing import Any
|
|
21
21
|
|
|
22
22
|
from . import pr_issue_link
|
|
23
|
-
from .cli_tools import
|
|
23
|
+
from .cli_tools import (
|
|
24
|
+
CLI_TIMEOUT_SECS,
|
|
25
|
+
EXIT_NEEDS_APPROVAL,
|
|
26
|
+
PollHeartbeat,
|
|
27
|
+
WaitDeadline,
|
|
28
|
+
detect_agent_session,
|
|
29
|
+
ensure_agent_session_note,
|
|
30
|
+
exit_wait_timeout,
|
|
31
|
+
is_claude_code,
|
|
32
|
+
status_rerun_cmd,
|
|
33
|
+
)
|
|
24
34
|
from .gitrepo import current_branch
|
|
25
35
|
from .pr_choice import PrCandidate, choose_pr
|
|
26
36
|
from .pr_markdown import build_attachments_section, build_comment_content, build_screenshots_section
|
|
@@ -308,8 +318,11 @@ def exit_needs_approval(msg: str, item_lines: list[str], rerun_cmd: str) -> None
|
|
|
308
318
|
sys.exit(EXIT_NEEDS_APPROVAL)
|
|
309
319
|
|
|
310
320
|
|
|
311
|
-
def run(gh: str, wait: bool, pr_id: int | None = None) -> None:
|
|
321
|
+
def run(gh: str, wait: bool, pr_id: int | None = None, timeout: float = 0) -> None:
|
|
322
|
+
"""`timeout` (seconds, only with `wait`): stop polling after that long and exit `EXIT_WAIT_TIMEOUT`
|
|
323
|
+
with the current state; 0 = wait until every check is done."""
|
|
312
324
|
heartbeat = PollHeartbeat()
|
|
325
|
+
deadline = WaitDeadline(timeout)
|
|
313
326
|
draft_notice_shown = False
|
|
314
327
|
while True:
|
|
315
328
|
pr = get_pr(gh, pr_id)
|
|
@@ -368,7 +381,9 @@ def run(gh: str, wait: bool, pr_id: int | None = None) -> None:
|
|
|
368
381
|
# must not keep polling forever waiting for it to become unstuck.
|
|
369
382
|
if "pending" in buckets and wait and not waiting_approval:
|
|
370
383
|
heartbeat.show(msg)
|
|
371
|
-
|
|
384
|
+
if deadline.expired():
|
|
385
|
+
exit_wait_timeout(msg, lambda: [print_check(c) for c in checks], deadline, status_rerun_cmd(pr_number, timeout))
|
|
386
|
+
deadline.sleep(30)
|
|
372
387
|
continue
|
|
373
388
|
|
|
374
389
|
print(msg)
|
|
@@ -6,12 +6,13 @@ import re
|
|
|
6
6
|
import sys
|
|
7
7
|
import time
|
|
8
8
|
from pathlib import Path
|
|
9
|
+
from typing import NoReturn
|
|
9
10
|
from urllib.parse import quote
|
|
10
11
|
|
|
11
12
|
import requests
|
|
12
13
|
|
|
13
14
|
from .ado_auth import auth_header
|
|
14
|
-
from .cli_tools import EXIT_NEEDS_APPROVAL, HTTP_TIMEOUT_SECS, PollHeartbeat, detect_agent_session, ensure_agent_session_note, is_claude_code
|
|
15
|
+
from .cli_tools import EXIT_NEEDS_APPROVAL, HTTP_TIMEOUT_SECS, PollHeartbeat, WaitDeadline, detect_agent_session, ensure_agent_session_note, exit_wait_timeout, is_claude_code, status_rerun_cmd
|
|
15
16
|
from .gitrepo import AdoRemote, current_branch
|
|
16
17
|
from .pr_choice import PrCandidate, choose_pr
|
|
17
18
|
from .pr_markdown import build_attachments_section, build_comment_content, build_screenshots_section
|
|
@@ -766,7 +767,10 @@ def run(
|
|
|
766
767
|
wait: bool,
|
|
767
768
|
source_branch: str | None = None,
|
|
768
769
|
pr_id: int | None = None,
|
|
770
|
+
timeout: float = 0,
|
|
769
771
|
) -> None:
|
|
772
|
+
"""`timeout` (seconds, only with `wait`): stop polling after that long and exit `EXIT_WAIT_TIMEOUT`
|
|
773
|
+
with the current state; 0 = wait until every pipeline is done."""
|
|
770
774
|
session = requests.Session()
|
|
771
775
|
session.headers.update(auth_header(pat))
|
|
772
776
|
|
|
@@ -804,8 +808,14 @@ def run(
|
|
|
804
808
|
draft_notice_shown = False
|
|
805
809
|
policy_checked = False
|
|
806
810
|
heartbeat = PollHeartbeat()
|
|
811
|
+
deadline = WaitDeadline(timeout)
|
|
812
|
+
|
|
813
|
+
def time_out(msg: str, builds: list, pr_number: int) -> NoReturn:
|
|
814
|
+
exit_wait_timeout(msg, lambda: [print_build(session, remote, b) for b in builds], deadline, status_rerun_cmd(pr_number, timeout))
|
|
815
|
+
|
|
807
816
|
while True:
|
|
808
817
|
pr = _poll_or_exit(fetch_pr)
|
|
818
|
+
pipeline_builds: list = []
|
|
809
819
|
if not draft_notice_shown:
|
|
810
820
|
draft_msg = draft_notice(pr)
|
|
811
821
|
if draft_msg:
|
|
@@ -827,7 +837,9 @@ def run(
|
|
|
827
837
|
b.get("definition", {}).get("name", "?") for b in stale
|
|
828
838
|
)
|
|
829
839
|
heartbeat.show(msg)
|
|
830
|
-
|
|
840
|
+
if deadline.expired():
|
|
841
|
+
time_out(msg, [b for b in pipeline_builds if b not in stale], pr_number)
|
|
842
|
+
deadline.sleep(30)
|
|
831
843
|
continue
|
|
832
844
|
msg += " | " + ", ".join(
|
|
833
845
|
f"{b.get('definition', {}).get('name', '?')} #{b['id']} {b.get('status')} ({b.get('result') or '—'})"
|
|
@@ -889,7 +901,9 @@ def run(
|
|
|
889
901
|
heartbeat.show(msg)
|
|
890
902
|
|
|
891
903
|
if wait:
|
|
892
|
-
|
|
904
|
+
if deadline.expired():
|
|
905
|
+
time_out(msg, pipeline_builds, pr_number)
|
|
906
|
+
deadline.sleep(30)
|
|
893
907
|
|
|
894
908
|
|
|
895
909
|
def run_watch_deploy(remote: AdoRemote, pat: str | None, target_branch: str, wait: bool) -> None:
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
"""`bdt usage`: where do the tokens of Claude Code / codex sessions go, per step of the dev-workflow?
|
|
2
|
+
|
|
3
|
+
Reads the local session logs with DuckDB, attributes every model turn to a workflow step and prices it.
|
|
4
|
+
The heavy dependencies (duckdb, plotly) are the optional `usage` extra and only imported when a command runs.
|
|
5
|
+
"""
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
"""`bdt usage`: profile Claude Code / codex session logs per step of the dev-workflow."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import os
|
|
6
|
+
import sys
|
|
7
|
+
from datetime import date
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
|
|
10
|
+
import typer
|
|
11
|
+
|
|
12
|
+
from . import pricing
|
|
13
|
+
|
|
14
|
+
usage_app = typer.Typer(
|
|
15
|
+
name="usage",
|
|
16
|
+
help="Where do the tokens of Claude Code / codex sessions go, per step of the dev-workflow? "
|
|
17
|
+
"(needs the `usage` extra: `uv tool install 'bmsdna-devtools\\[usage]'`)",
|
|
18
|
+
)
|
|
19
|
+
|
|
20
|
+
INSTALL_HINT = "bdt usage needs duckdb and plotly: install the extra, e.g. `uv tool install --reinstall 'bmsdna-devtools[usage]'`"
|
|
21
|
+
DEFAULT_CLAUDE_LOGS = "~/.claude/projects"
|
|
22
|
+
DEFAULT_CODEX_LOGS = "~/.codex/sessions"
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def default_data_dir() -> Path:
|
|
26
|
+
cache = Path(os.environ.get("XDG_CACHE_HOME") or "~/.cache").expanduser()
|
|
27
|
+
return cache / "bdt" / "usage"
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def require_duckdb():
|
|
31
|
+
try:
|
|
32
|
+
import duckdb
|
|
33
|
+
except ImportError:
|
|
34
|
+
print(INSTALL_HINT, file=sys.stderr)
|
|
35
|
+
raise typer.Exit(1) from None
|
|
36
|
+
return duckdb
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
@usage_app.command("analyze")
|
|
40
|
+
def analyze(
|
|
41
|
+
logs: str = typer.Option(DEFAULT_CLAUDE_LOGS, "--logs", help="Claude Code projects directory"),
|
|
42
|
+
codex_logs: str = typer.Option(DEFAULT_CODEX_LOGS, "--codex-logs", help="codex sessions directory (skipped if it does not exist)"),
|
|
43
|
+
no_codex: bool = typer.Option(False, "--no-codex", help="Ignore codex logs"),
|
|
44
|
+
db: str | None = typer.Option(None, "--db", help="DuckDB file (default: <cache dir>/bdt/usage/profile.duckdb). Doubles as the parse cache"),
|
|
45
|
+
out: str | None = typer.Option(None, "--out", help="Directory for report.html and the CSV / Parquet exports (default: next to the database)"),
|
|
46
|
+
scope: str = typer.Option("workflow", "--scope", help="workflow: only threads that follow dev-workflow (and what they spawn); all: every session"),
|
|
47
|
+
since: str = typer.Option("1970-01-01", "--since", help="Only messages from this date on (YYYY-MM-DD)"),
|
|
48
|
+
rebuild: bool = typer.Option(False, "--rebuild", help="Ignore the parse cache in the database and re-read every log file"),
|
|
49
|
+
no_report: bool = typer.Option(False, "--no-report", help="Only print the summary"),
|
|
50
|
+
price: list[str] = typer.Option(
|
|
51
|
+
[], "--price", metavar="MODEL=IN,OUT,CACHED",
|
|
52
|
+
help="USD per million tokens for a model without a built-in price (codex / OpenAI models), repeatable. "
|
|
53
|
+
"Example: --price gpt-6-sol=1.25,10,0.125",
|
|
54
|
+
),
|
|
55
|
+
) -> None:
|
|
56
|
+
"""Load the session logs, attribute every model turn to a workflow step, price it, and print / render the result.
|
|
57
|
+
|
|
58
|
+
The first run parses every log file; later runs only parse new or changed ones. Costs are API list prices
|
|
59
|
+
(not your actual bill); turns of a model without a price are counted in tokens but not in cost.
|
|
60
|
+
"""
|
|
61
|
+
if scope not in ("workflow", "all"):
|
|
62
|
+
raise typer.BadParameter("Must be 'workflow' or 'all'", param_hint="--scope")
|
|
63
|
+
try:
|
|
64
|
+
date.fromisoformat(since)
|
|
65
|
+
except ValueError:
|
|
66
|
+
raise typer.BadParameter("Must be a date like 2026-10-01", param_hint="--since") from None
|
|
67
|
+
try:
|
|
68
|
+
prices = dict(pricing.parse_price(p) for p in price)
|
|
69
|
+
except ValueError as e:
|
|
70
|
+
raise typer.BadParameter(str(e), param_hint="--price") from None
|
|
71
|
+
duckdb = require_duckdb()
|
|
72
|
+
from . import pipeline
|
|
73
|
+
|
|
74
|
+
data_dir = default_data_dir()
|
|
75
|
+
db_path = Path(db).expanduser() if db else data_dir / "profile.duckdb"
|
|
76
|
+
db_path.parent.mkdir(parents=True, exist_ok=True)
|
|
77
|
+
con = duckdb.connect(str(db_path))
|
|
78
|
+
print(f"Reading {logs}" + ("" if no_codex else f" and {codex_logs}") + " ...", file=sys.stderr)
|
|
79
|
+
stats = pipeline.build(
|
|
80
|
+
con, Path(logs), None if no_codex else Path(codex_logs), scope=scope, since=since, rebuild=rebuild, prices=prices
|
|
81
|
+
)
|
|
82
|
+
print(f"{stats.files} log files, {stats.ingested} parsed ({stats.files - stats.ingested} from cache), {stats.removed} removed", file=sys.stderr)
|
|
83
|
+
|
|
84
|
+
summary = con.execute("SELECT * FROM v_summary").fetchone()
|
|
85
|
+
if not summary or not summary[2]:
|
|
86
|
+
print("No sessions in scope. Nothing used dev-workflow in these logs (try --scope all)?", file=sys.stderr)
|
|
87
|
+
raise typer.Exit(1)
|
|
88
|
+
con.sql("SELECT * EXCLUDE (unpriced_msgs, unpriced_models) FROM v_summary").show(max_width=200)
|
|
89
|
+
con.sql("""
|
|
90
|
+
SELECT label AS step, msgs AS turns, sessions, round(cost, 2) AS usd,
|
|
91
|
+
round(pct_cost, 1) AS "% cost", round(pct_tokens, 1) AS "% tokens",
|
|
92
|
+
round(pct_fresh_tokens, 1) AS "% fresh tokens",
|
|
93
|
+
round(pct_of_step_via_subagents) AS "% via subagents"
|
|
94
|
+
FROM v_steps""").show(max_width=200)
|
|
95
|
+
con.sql("SELECT provider, msgs AS turns, tokens, round(cost, 2) AS usd, round(pct_tokens, 1) AS \"% tokens\" FROM v_providers").show(max_width=200)
|
|
96
|
+
unpriced = con.execute("SELECT unpriced_msgs, unpriced_models FROM v_summary").fetchone()
|
|
97
|
+
if unpriced and unpriced[0]:
|
|
98
|
+
print(
|
|
99
|
+
f"Note: {unpriced[0]} turns of {unpriced[1]} have no price, so they are missing from the cost columns. "
|
|
100
|
+
"Add one with --price MODEL=IN,OUT,CACHED (USD per million tokens).",
|
|
101
|
+
file=sys.stderr,
|
|
102
|
+
)
|
|
103
|
+
if not no_report:
|
|
104
|
+
from . import report
|
|
105
|
+
|
|
106
|
+
out_dir = Path(out).expanduser() if out else db_path.parent
|
|
107
|
+
print(f"Report: {report.write_report(con, out_dir)}", file=sys.stderr)
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
@usage_app.command("sql")
|
|
111
|
+
def sql(
|
|
112
|
+
query: str = typer.Argument(..., help="SQL to run against a database built by `bdt usage analyze` (tables: facts, msgs, threads, v_*)"),
|
|
113
|
+
db: str | None = typer.Option(None, "--db", help="DuckDB file (default: the one `analyze` writes)"),
|
|
114
|
+
) -> None:
|
|
115
|
+
"""Run ad-hoc SQL on the last analysis, e.g. `bdt usage sql "select label, round(pct_cost, 1) from v_steps"`."""
|
|
116
|
+
duckdb = require_duckdb()
|
|
117
|
+
db_path = Path(db).expanduser() if db else default_data_dir() / "profile.duckdb"
|
|
118
|
+
if not db_path.exists():
|
|
119
|
+
print(f"{db_path} does not exist yet: run `bdt usage analyze` first.", file=sys.stderr)
|
|
120
|
+
raise typer.Exit(1)
|
|
121
|
+
con = duckdb.connect(str(db_path), read_only=True)
|
|
122
|
+
con.sql(query).show(max_width=200, max_rows=100)
|