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.
Files changed (112) hide show
  1. bmsdna_devtools-0.41.0/README.md → bmsdna_devtools-0.43.0/PKG-INFO +59 -1
  2. bmsdna_devtools-0.41.0/PKG-INFO → bmsdna_devtools-0.43.0/README.md +42 -15
  3. {bmsdna_devtools-0.41.0 → bmsdna_devtools-0.43.0}/bmsdna/devtools/cli.py +16 -3
  4. {bmsdna_devtools-0.41.0 → bmsdna_devtools-0.43.0}/bmsdna/devtools/cli_tools.py +43 -0
  5. {bmsdna_devtools-0.41.0 → bmsdna_devtools-0.43.0}/bmsdna/devtools/gh_pr.py +18 -3
  6. {bmsdna_devtools-0.41.0 → bmsdna_devtools-0.43.0}/bmsdna/devtools/pr_build.py +17 -3
  7. bmsdna_devtools-0.43.0/bmsdna/devtools/usage/__init__.py +5 -0
  8. bmsdna_devtools-0.43.0/bmsdna/devtools/usage/cli.py +122 -0
  9. bmsdna_devtools-0.43.0/bmsdna/devtools/usage/pipeline.py +255 -0
  10. bmsdna_devtools-0.43.0/bmsdna/devtools/usage/pricing.py +59 -0
  11. bmsdna_devtools-0.43.0/bmsdna/devtools/usage/report.py +334 -0
  12. bmsdna_devtools-0.43.0/bmsdna/devtools/usage/sql/00_schema.sql +18 -0
  13. bmsdna_devtools-0.43.0/bmsdna/devtools/usage/sql/01_ingest_claude.sql +83 -0
  14. bmsdna_devtools-0.43.0/bmsdna/devtools/usage/sql/01_ingest_codex.sql +92 -0
  15. bmsdna_devtools-0.43.0/bmsdna/devtools/usage/sql/02_messages.sql +105 -0
  16. bmsdna_devtools-0.43.0/bmsdna/devtools/usage/sql/03_markers.sql +67 -0
  17. bmsdna_devtools-0.43.0/bmsdna/devtools/usage/sql/04_facts.sql +78 -0
  18. bmsdna_devtools-0.43.0/bmsdna/devtools/usage/sql/05_views.sql +102 -0
  19. {bmsdna_devtools-0.41.0 → bmsdna_devtools-0.43.0}/pyproject.toml +5 -1
  20. {bmsdna_devtools-0.41.0 → bmsdna_devtools-0.43.0}/skills/bmsdna-devtools/SKILL.md +12 -1
  21. {bmsdna_devtools-0.41.0 → bmsdna_devtools-0.43.0}/tests/test_cli_pr_id.py +2 -2
  22. {bmsdna_devtools-0.41.0 → bmsdna_devtools-0.43.0}/tests/test_pr_choice.py +2 -2
  23. bmsdna_devtools-0.43.0/tests/test_usage.py +474 -0
  24. bmsdna_devtools-0.43.0/tests/test_wait_timeout.py +187 -0
  25. {bmsdna_devtools-0.41.0 → bmsdna_devtools-0.43.0}/uv.lock +45 -2
  26. {bmsdna_devtools-0.41.0 → bmsdna_devtools-0.43.0}/.github/workflows/auto-release.yml +0 -0
  27. {bmsdna_devtools-0.41.0 → bmsdna_devtools-0.43.0}/.github/workflows/python-publish.yml +0 -0
  28. {bmsdna_devtools-0.41.0 → bmsdna_devtools-0.43.0}/.github/workflows/python-test.yml +0 -0
  29. {bmsdna_devtools-0.41.0 → bmsdna_devtools-0.43.0}/.gitignore +0 -0
  30. {bmsdna_devtools-0.41.0 → bmsdna_devtools-0.43.0}/.python-version +0 -0
  31. {bmsdna_devtools-0.41.0 → bmsdna_devtools-0.43.0}/bmsdna/devtools/__init__.py +0 -0
  32. {bmsdna_devtools-0.41.0 → bmsdna_devtools-0.43.0}/bmsdna/devtools/_api_dump.py +0 -0
  33. {bmsdna_devtools-0.41.0 → bmsdna_devtools-0.43.0}/bmsdna/devtools/ado_auth.py +0 -0
  34. {bmsdna_devtools-0.41.0 → bmsdna_devtools-0.43.0}/bmsdna/devtools/ado_issue.py +0 -0
  35. {bmsdna_devtools-0.41.0 → bmsdna_devtools-0.43.0}/bmsdna/devtools/agent_mentions.py +0 -0
  36. {bmsdna_devtools-0.41.0 → bmsdna_devtools-0.43.0}/bmsdna/devtools/api_usage.py +0 -0
  37. {bmsdna_devtools-0.41.0 → bmsdna_devtools-0.43.0}/bmsdna/devtools/app_service_logs.py +0 -0
  38. {bmsdna_devtools-0.41.0 → bmsdna_devtools-0.43.0}/bmsdna/devtools/bdt_config.py +0 -0
  39. {bmsdna_devtools-0.41.0 → bmsdna_devtools-0.43.0}/bmsdna/devtools/commit.py +0 -0
  40. {bmsdna_devtools-0.41.0 → bmsdna_devtools-0.43.0}/bmsdna/devtools/dead_code.py +0 -0
  41. {bmsdna_devtools-0.41.0 → bmsdna_devtools-0.43.0}/bmsdna/devtools/env_config.py +0 -0
  42. {bmsdna_devtools-0.41.0 → bmsdna_devtools-0.43.0}/bmsdna/devtools/env_files.py +0 -0
  43. {bmsdna_devtools-0.41.0 → bmsdna_devtools-0.43.0}/bmsdna/devtools/find_injection.py +0 -0
  44. {bmsdna_devtools-0.41.0 → bmsdna_devtools-0.43.0}/bmsdna/devtools/find_repo.py +0 -0
  45. {bmsdna_devtools-0.41.0 → bmsdna_devtools-0.43.0}/bmsdna/devtools/gh_issue.py +0 -0
  46. {bmsdna_devtools-0.41.0 → bmsdna_devtools-0.43.0}/bmsdna/devtools/gitrepo.py +0 -0
  47. {bmsdna_devtools-0.41.0 → bmsdna_devtools-0.43.0}/bmsdna/devtools/injection_frontend.py +0 -0
  48. {bmsdna_devtools-0.41.0 → bmsdna_devtools-0.43.0}/bmsdna/devtools/injection_python.py +0 -0
  49. {bmsdna_devtools-0.41.0 → bmsdna_devtools-0.43.0}/bmsdna/devtools/issue_do.py +0 -0
  50. {bmsdna_devtools-0.41.0 → bmsdna_devtools-0.43.0}/bmsdna/devtools/issue_show.py +0 -0
  51. {bmsdna_devtools-0.41.0 → bmsdna_devtools-0.43.0}/bmsdna/devtools/issue_watch.py +0 -0
  52. {bmsdna_devtools-0.41.0 → bmsdna_devtools-0.43.0}/bmsdna/devtools/lint.py +0 -0
  53. {bmsdna_devtools-0.41.0 → bmsdna_devtools-0.43.0}/bmsdna/devtools/lint_findings.py +0 -0
  54. {bmsdna_devtools-0.41.0 → bmsdna_devtools-0.43.0}/bmsdna/devtools/lint_models.py +0 -0
  55. {bmsdna_devtools-0.41.0 → bmsdna_devtools-0.43.0}/bmsdna/devtools/lint_sql.py +0 -0
  56. {bmsdna_devtools-0.41.0 → bmsdna_devtools-0.43.0}/bmsdna/devtools/lint_sql_files.py +0 -0
  57. {bmsdna_devtools-0.41.0 → bmsdna_devtools-0.43.0}/bmsdna/devtools/lint_tooling.py +0 -0
  58. {bmsdna_devtools-0.41.0 → bmsdna_devtools-0.43.0}/bmsdna/devtools/lint_typescript.py +0 -0
  59. {bmsdna_devtools-0.41.0 → bmsdna_devtools-0.43.0}/bmsdna/devtools/logs.py +0 -0
  60. {bmsdna_devtools-0.41.0 → bmsdna_devtools-0.43.0}/bmsdna/devtools/pr_choice.py +0 -0
  61. {bmsdna_devtools-0.41.0 → bmsdna_devtools-0.43.0}/bmsdna/devtools/pr_info.py +0 -0
  62. {bmsdna_devtools-0.41.0 → bmsdna_devtools-0.43.0}/bmsdna/devtools/pr_issue_link.py +0 -0
  63. {bmsdna_devtools-0.41.0 → bmsdna_devtools-0.43.0}/bmsdna/devtools/pr_labels.py +0 -0
  64. {bmsdna_devtools-0.41.0 → bmsdna_devtools-0.43.0}/bmsdna/devtools/pr_markdown.py +0 -0
  65. {bmsdna_devtools-0.41.0 → bmsdna_devtools-0.43.0}/bmsdna/devtools/pull.py +0 -0
  66. {bmsdna_devtools-0.41.0 → bmsdna_devtools-0.43.0}/bmsdna/devtools/testdb.py +0 -0
  67. {bmsdna_devtools-0.41.0 → bmsdna_devtools-0.43.0}/bmsdna/devtools/translate.py +0 -0
  68. {bmsdna_devtools-0.41.0 → bmsdna_devtools-0.43.0}/bmsdna/devtools/worktree.py +0 -0
  69. {bmsdna_devtools-0.41.0 → bmsdna_devtools-0.43.0}/justfile +0 -0
  70. {bmsdna_devtools-0.41.0 → bmsdna_devtools-0.43.0}/tests/conftest.py +0 -0
  71. {bmsdna_devtools-0.41.0 → bmsdna_devtools-0.43.0}/tests/test_ado_auth.py +0 -0
  72. {bmsdna_devtools-0.41.0 → bmsdna_devtools-0.43.0}/tests/test_ado_issue.py +0 -0
  73. {bmsdna_devtools-0.41.0 → bmsdna_devtools-0.43.0}/tests/test_ado_issue_flow.py +0 -0
  74. {bmsdna_devtools-0.41.0 → bmsdna_devtools-0.43.0}/tests/test_agent_mentions.py +0 -0
  75. {bmsdna_devtools-0.41.0 → bmsdna_devtools-0.43.0}/tests/test_api_usage.py +0 -0
  76. {bmsdna_devtools-0.41.0 → bmsdna_devtools-0.43.0}/tests/test_app_service_logs.py +0 -0
  77. {bmsdna_devtools-0.41.0 → bmsdna_devtools-0.43.0}/tests/test_bdt_config.py +0 -0
  78. {bmsdna_devtools-0.41.0 → bmsdna_devtools-0.43.0}/tests/test_cli_cleanup.py +0 -0
  79. {bmsdna_devtools-0.41.0 → bmsdna_devtools-0.43.0}/tests/test_cli_commit.py +0 -0
  80. {bmsdna_devtools-0.41.0 → bmsdna_devtools-0.43.0}/tests/test_cli_issue.py +0 -0
  81. {bmsdna_devtools-0.41.0 → bmsdna_devtools-0.43.0}/tests/test_cli_pr_create.py +0 -0
  82. {bmsdna_devtools-0.41.0 → bmsdna_devtools-0.43.0}/tests/test_cli_pull.py +0 -0
  83. {bmsdna_devtools-0.41.0 → bmsdna_devtools-0.43.0}/tests/test_cli_tools.py +0 -0
  84. {bmsdna_devtools-0.41.0 → bmsdna_devtools-0.43.0}/tests/test_commit.py +0 -0
  85. {bmsdna_devtools-0.41.0 → bmsdna_devtools-0.43.0}/tests/test_env_config.py +0 -0
  86. {bmsdna_devtools-0.41.0 → bmsdna_devtools-0.43.0}/tests/test_env_files.py +0 -0
  87. {bmsdna_devtools-0.41.0 → bmsdna_devtools-0.43.0}/tests/test_find_injection.py +0 -0
  88. {bmsdna_devtools-0.41.0 → bmsdna_devtools-0.43.0}/tests/test_find_repo.py +0 -0
  89. {bmsdna_devtools-0.41.0 → bmsdna_devtools-0.43.0}/tests/test_gh_issue.py +0 -0
  90. {bmsdna_devtools-0.41.0 → bmsdna_devtools-0.43.0}/tests/test_gh_pr.py +0 -0
  91. {bmsdna_devtools-0.41.0 → bmsdna_devtools-0.43.0}/tests/test_gitrepo.py +0 -0
  92. {bmsdna_devtools-0.41.0 → bmsdna_devtools-0.43.0}/tests/test_issue_do.py +0 -0
  93. {bmsdna_devtools-0.41.0 → bmsdna_devtools-0.43.0}/tests/test_issue_for_me.py +0 -0
  94. {bmsdna_devtools-0.41.0 → bmsdna_devtools-0.43.0}/tests/test_issue_show.py +0 -0
  95. {bmsdna_devtools-0.41.0 → bmsdna_devtools-0.43.0}/tests/test_issue_watch.py +0 -0
  96. {bmsdna_devtools-0.41.0 → bmsdna_devtools-0.43.0}/tests/test_lint.py +0 -0
  97. {bmsdna_devtools-0.41.0 → bmsdna_devtools-0.43.0}/tests/test_lint_models.py +0 -0
  98. {bmsdna_devtools-0.41.0 → bmsdna_devtools-0.43.0}/tests/test_lint_sql.py +0 -0
  99. {bmsdna_devtools-0.41.0 → bmsdna_devtools-0.43.0}/tests/test_lint_sql_files.py +0 -0
  100. {bmsdna_devtools-0.41.0 → bmsdna_devtools-0.43.0}/tests/test_lint_tooling.py +0 -0
  101. {bmsdna_devtools-0.41.0 → bmsdna_devtools-0.43.0}/tests/test_lint_typescript.py +0 -0
  102. {bmsdna_devtools-0.41.0 → bmsdna_devtools-0.43.0}/tests/test_pr_build.py +0 -0
  103. {bmsdna_devtools-0.41.0 → bmsdna_devtools-0.43.0}/tests/test_pr_build_flow.py +0 -0
  104. {bmsdna_devtools-0.41.0 → bmsdna_devtools-0.43.0}/tests/test_pr_description_not_truncated.py +0 -0
  105. {bmsdna_devtools-0.41.0 → bmsdna_devtools-0.43.0}/tests/test_pr_info.py +0 -0
  106. {bmsdna_devtools-0.41.0 → bmsdna_devtools-0.43.0}/tests/test_pr_issue_link.py +0 -0
  107. {bmsdna_devtools-0.41.0 → bmsdna_devtools-0.43.0}/tests/test_pr_labels.py +0 -0
  108. {bmsdna_devtools-0.41.0 → bmsdna_devtools-0.43.0}/tests/test_pr_markdown.py +0 -0
  109. {bmsdna_devtools-0.41.0 → bmsdna_devtools-0.43.0}/tests/test_pull.py +0 -0
  110. {bmsdna_devtools-0.41.0 → bmsdna_devtools-0.43.0}/tests/test_testdb.py +0 -0
  111. {bmsdna_devtools-0.41.0 → bmsdna_devtools-0.43.0}/tests/test_translate.py +0 -0
  112. {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 CLI_TIMEOUT_SECS, EXIT_NEEDS_APPROVAL, PollHeartbeat, detect_agent_session, ensure_agent_session_note, is_claude_code
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
- time.sleep(30)
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
- time.sleep(30)
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
- time.sleep(30)
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)