bmsdna-devtools 0.29.0__tar.gz → 0.30.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 (78) hide show
  1. {bmsdna_devtools-0.29.0 → bmsdna_devtools-0.30.0}/PKG-INFO +50 -8
  2. {bmsdna_devtools-0.29.0 → bmsdna_devtools-0.30.0}/README.md +49 -7
  3. {bmsdna_devtools-0.29.0 → bmsdna_devtools-0.30.0}/bmsdna/devtools/ado_issue.py +19 -2
  4. {bmsdna_devtools-0.29.0 → bmsdna_devtools-0.30.0}/bmsdna/devtools/cli.py +124 -11
  5. {bmsdna_devtools-0.29.0 → bmsdna_devtools-0.30.0}/bmsdna/devtools/gh_issue.py +22 -2
  6. {bmsdna_devtools-0.29.0 → bmsdna_devtools-0.30.0}/bmsdna/devtools/gh_pr.py +6 -2
  7. {bmsdna_devtools-0.29.0 → bmsdna_devtools-0.30.0}/bmsdna/devtools/issue_do.py +23 -5
  8. bmsdna_devtools-0.30.0/bmsdna/devtools/pr_info.py +138 -0
  9. {bmsdna_devtools-0.29.0 → bmsdna_devtools-0.30.0}/pyproject.toml +1 -1
  10. {bmsdna_devtools-0.29.0 → bmsdna_devtools-0.30.0}/skills/bmsdna-devtools/SKILL.md +30 -5
  11. {bmsdna_devtools-0.29.0 → bmsdna_devtools-0.30.0}/tests/test_issue_do.py +11 -0
  12. bmsdna_devtools-0.30.0/tests/test_pr_info.py +422 -0
  13. {bmsdna_devtools-0.29.0 → bmsdna_devtools-0.30.0}/uv.lock +1 -1
  14. {bmsdna_devtools-0.29.0 → bmsdna_devtools-0.30.0}/.github/workflows/auto-release.yml +0 -0
  15. {bmsdna_devtools-0.29.0 → bmsdna_devtools-0.30.0}/.github/workflows/python-publish.yml +0 -0
  16. {bmsdna_devtools-0.29.0 → bmsdna_devtools-0.30.0}/.github/workflows/python-test.yml +0 -0
  17. {bmsdna_devtools-0.29.0 → bmsdna_devtools-0.30.0}/.gitignore +0 -0
  18. {bmsdna_devtools-0.29.0 → bmsdna_devtools-0.30.0}/.python-version +0 -0
  19. {bmsdna_devtools-0.29.0 → bmsdna_devtools-0.30.0}/bmsdna/devtools/__init__.py +0 -0
  20. {bmsdna_devtools-0.29.0 → bmsdna_devtools-0.30.0}/bmsdna/devtools/ado_auth.py +0 -0
  21. {bmsdna_devtools-0.29.0 → bmsdna_devtools-0.30.0}/bmsdna/devtools/app_service_logs.py +0 -0
  22. {bmsdna_devtools-0.29.0 → bmsdna_devtools-0.30.0}/bmsdna/devtools/bdt_config.py +0 -0
  23. {bmsdna_devtools-0.29.0 → bmsdna_devtools-0.30.0}/bmsdna/devtools/cli_tools.py +0 -0
  24. {bmsdna_devtools-0.29.0 → bmsdna_devtools-0.30.0}/bmsdna/devtools/commit.py +0 -0
  25. {bmsdna_devtools-0.29.0 → bmsdna_devtools-0.30.0}/bmsdna/devtools/env_config.py +0 -0
  26. {bmsdna_devtools-0.29.0 → bmsdna_devtools-0.30.0}/bmsdna/devtools/find_injection.py +0 -0
  27. {bmsdna_devtools-0.29.0 → bmsdna_devtools-0.30.0}/bmsdna/devtools/find_repo.py +0 -0
  28. {bmsdna_devtools-0.29.0 → bmsdna_devtools-0.30.0}/bmsdna/devtools/gitrepo.py +0 -0
  29. {bmsdna_devtools-0.29.0 → bmsdna_devtools-0.30.0}/bmsdna/devtools/injection_frontend.py +0 -0
  30. {bmsdna_devtools-0.29.0 → bmsdna_devtools-0.30.0}/bmsdna/devtools/injection_python.py +0 -0
  31. {bmsdna_devtools-0.29.0 → bmsdna_devtools-0.30.0}/bmsdna/devtools/lint.py +0 -0
  32. {bmsdna_devtools-0.29.0 → bmsdna_devtools-0.30.0}/bmsdna/devtools/lint_findings.py +0 -0
  33. {bmsdna_devtools-0.29.0 → bmsdna_devtools-0.30.0}/bmsdna/devtools/lint_models.py +0 -0
  34. {bmsdna_devtools-0.29.0 → bmsdna_devtools-0.30.0}/bmsdna/devtools/lint_sql.py +0 -0
  35. {bmsdna_devtools-0.29.0 → bmsdna_devtools-0.30.0}/bmsdna/devtools/lint_tooling.py +0 -0
  36. {bmsdna_devtools-0.29.0 → bmsdna_devtools-0.30.0}/bmsdna/devtools/lint_typescript.py +0 -0
  37. {bmsdna_devtools-0.29.0 → bmsdna_devtools-0.30.0}/bmsdna/devtools/logs.py +0 -0
  38. {bmsdna_devtools-0.29.0 → bmsdna_devtools-0.30.0}/bmsdna/devtools/pr_build.py +0 -0
  39. {bmsdna_devtools-0.29.0 → bmsdna_devtools-0.30.0}/bmsdna/devtools/pr_issue_link.py +0 -0
  40. {bmsdna_devtools-0.29.0 → bmsdna_devtools-0.30.0}/bmsdna/devtools/pr_labels.py +0 -0
  41. {bmsdna_devtools-0.29.0 → bmsdna_devtools-0.30.0}/bmsdna/devtools/pr_markdown.py +0 -0
  42. {bmsdna_devtools-0.29.0 → bmsdna_devtools-0.30.0}/bmsdna/devtools/pull.py +0 -0
  43. {bmsdna_devtools-0.29.0 → bmsdna_devtools-0.30.0}/bmsdna/devtools/testdb.py +0 -0
  44. {bmsdna_devtools-0.29.0 → bmsdna_devtools-0.30.0}/bmsdna/devtools/worktree.py +0 -0
  45. {bmsdna_devtools-0.29.0 → bmsdna_devtools-0.30.0}/justfile +0 -0
  46. {bmsdna_devtools-0.29.0 → bmsdna_devtools-0.30.0}/tests/conftest.py +0 -0
  47. {bmsdna_devtools-0.29.0 → bmsdna_devtools-0.30.0}/tests/test_ado_auth.py +0 -0
  48. {bmsdna_devtools-0.29.0 → bmsdna_devtools-0.30.0}/tests/test_ado_issue.py +0 -0
  49. {bmsdna_devtools-0.29.0 → bmsdna_devtools-0.30.0}/tests/test_ado_issue_flow.py +0 -0
  50. {bmsdna_devtools-0.29.0 → bmsdna_devtools-0.30.0}/tests/test_app_service_logs.py +0 -0
  51. {bmsdna_devtools-0.29.0 → bmsdna_devtools-0.30.0}/tests/test_bdt_config.py +0 -0
  52. {bmsdna_devtools-0.29.0 → bmsdna_devtools-0.30.0}/tests/test_cli_cleanup.py +0 -0
  53. {bmsdna_devtools-0.29.0 → bmsdna_devtools-0.30.0}/tests/test_cli_commit.py +0 -0
  54. {bmsdna_devtools-0.29.0 → bmsdna_devtools-0.30.0}/tests/test_cli_issue.py +0 -0
  55. {bmsdna_devtools-0.29.0 → bmsdna_devtools-0.30.0}/tests/test_cli_pr_create.py +0 -0
  56. {bmsdna_devtools-0.29.0 → bmsdna_devtools-0.30.0}/tests/test_cli_pr_id.py +0 -0
  57. {bmsdna_devtools-0.29.0 → bmsdna_devtools-0.30.0}/tests/test_cli_pull.py +0 -0
  58. {bmsdna_devtools-0.29.0 → bmsdna_devtools-0.30.0}/tests/test_cli_tools.py +0 -0
  59. {bmsdna_devtools-0.29.0 → bmsdna_devtools-0.30.0}/tests/test_commit.py +0 -0
  60. {bmsdna_devtools-0.29.0 → bmsdna_devtools-0.30.0}/tests/test_env_config.py +0 -0
  61. {bmsdna_devtools-0.29.0 → bmsdna_devtools-0.30.0}/tests/test_find_injection.py +0 -0
  62. {bmsdna_devtools-0.29.0 → bmsdna_devtools-0.30.0}/tests/test_find_repo.py +0 -0
  63. {bmsdna_devtools-0.29.0 → bmsdna_devtools-0.30.0}/tests/test_gh_issue.py +0 -0
  64. {bmsdna_devtools-0.29.0 → bmsdna_devtools-0.30.0}/tests/test_gh_pr.py +0 -0
  65. {bmsdna_devtools-0.29.0 → bmsdna_devtools-0.30.0}/tests/test_gitrepo.py +0 -0
  66. {bmsdna_devtools-0.29.0 → bmsdna_devtools-0.30.0}/tests/test_lint.py +0 -0
  67. {bmsdna_devtools-0.29.0 → bmsdna_devtools-0.30.0}/tests/test_lint_models.py +0 -0
  68. {bmsdna_devtools-0.29.0 → bmsdna_devtools-0.30.0}/tests/test_lint_sql.py +0 -0
  69. {bmsdna_devtools-0.29.0 → bmsdna_devtools-0.30.0}/tests/test_lint_tooling.py +0 -0
  70. {bmsdna_devtools-0.29.0 → bmsdna_devtools-0.30.0}/tests/test_lint_typescript.py +0 -0
  71. {bmsdna_devtools-0.29.0 → bmsdna_devtools-0.30.0}/tests/test_pr_build.py +0 -0
  72. {bmsdna_devtools-0.29.0 → bmsdna_devtools-0.30.0}/tests/test_pr_build_flow.py +0 -0
  73. {bmsdna_devtools-0.29.0 → bmsdna_devtools-0.30.0}/tests/test_pr_issue_link.py +0 -0
  74. {bmsdna_devtools-0.29.0 → bmsdna_devtools-0.30.0}/tests/test_pr_labels.py +0 -0
  75. {bmsdna_devtools-0.29.0 → bmsdna_devtools-0.30.0}/tests/test_pr_markdown.py +0 -0
  76. {bmsdna_devtools-0.29.0 → bmsdna_devtools-0.30.0}/tests/test_pull.py +0 -0
  77. {bmsdna_devtools-0.29.0 → bmsdna_devtools-0.30.0}/tests/test_testdb.py +0 -0
  78. {bmsdna_devtools-0.29.0 → bmsdna_devtools-0.30.0}/tests/test_worktree_clean.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: bmsdna-devtools
3
- Version: 0.29.0
3
+ Version: 0.30.0
4
4
  Summary: Shared Azure DevOps / GitHub / git / Azure Monitor developer tooling for BMS projects
5
5
  Requires-Python: >=3.14
6
6
  Requires-Dist: pgdevkit[db]>=0.7.1
@@ -13,8 +13,8 @@ Description-Content-Type: text/markdown
13
13
 
14
14
  # bmsdna-devtools
15
15
 
16
- Shared developer tooling for BMS projects: PR build/check status, PR
17
- creation, issue/work item creation and comments, git worktrees (creation and
16
+ Shared developer tooling for BMS projects: PR build/check status and a
17
+ one-shot PR summary (`bdt pr info`), PR creation, claiming an issue (`bdt issue take`), issue/work item creation and comments, git worktrees (creation and
18
18
  merged-worktree/orphaned-test-DB cleanup), a commit-and-push helper with
19
19
  pre-flight checks, Azure log queries, and static checks (`bdt lint`) for
20
20
  postgres/psycopg SQL rules, pydantic-model placement, hand-wired HTTP access in
@@ -48,9 +48,12 @@ Find the PR opened from the current branch and report build/check status
48
48
  GitHub — whichever `origin` points at.
49
49
 
50
50
  ```bash
51
- bdt pr status [--target-branch main] [--wait]
51
+ bdt pr status [--target-branch dev] [--wait]
52
52
  ```
53
53
 
54
+ Every `--target` / `--target-branch` option in bdt (`pr create/status/info/retry/publish/update/comment/watch-deploy`,
55
+ `issue take`, `commit`) defaults to `dev`; pass `--target main` (or whatever your PRs go into) to override.
56
+
54
57
  If the PR can't be merged, that's reported immediately instead of polling
55
58
  for builds/checks that will never run — e.g. on Azure DevOps:
56
59
  `PR #42 ('feat: widgets') has merge conflicts with the target branch (mergeStatus=conflicts)`;
@@ -85,6 +88,25 @@ locally at all. `bdt pr retry`, `pr publish`, `pr update`, and `pr comment`
85
88
  all accept the same option, for the same reason. (`pr watch-deploy` doesn't:
86
89
  it watches a branch-triggered build/workflow run, not any particular PR.)
87
90
 
91
+ ## `bdt pr info`
92
+
93
+ ```bash
94
+ bdt pr info [--json] [--pr-id N] [--target-branch dev]
95
+ ```
96
+
97
+ One-shot summary of the current branch's PR: link, state (open/merged/closed, draft), aggregate
98
+ build state (`passing`, `failing`, `pending`, `waiting` for a manual approval, `none`) and the
99
+ issue(s)/work item(s) it closes, with links. Exits 1 when the branch has no PR. `--json` is what
100
+ a status integration reads (e.g. the Claude Code mod planned in bmsuisse/skills#61). Build state is judged the way `bdt pr status` does: on GitHub from
101
+ the check rollup (only a failed check fails it; skipped/cancelled ones don't count), on Azure DevOps
102
+ from the latest build of each pipeline (any failed result fails it; unfinished builds are pending, or
103
+ `waiting` when a stage needs approval). `unknown` if Azure DevOps can't be asked.
104
+
105
+ Azure DevOps limits: the PR is found by the current branch into `--target-branch` (default `dev`),
106
+ a PR with merge conflicts is reported as an error (exit 1) like `pr status` does, and the closed
107
+ work items are only those whose URL is in the PR description -- not ones linked via
108
+ `bdt pr create --issue`.
109
+
88
110
  ## `bdt pr retry`
89
111
 
90
112
  Retry only the **failed** job(s)/stage(s) of the most recent build/run for the
@@ -92,7 +114,7 @@ PR opened from the current branch — not a whole new build/run. Whenever `bdt
92
114
  pr status` reports a failure, it prints a hint to run this.
93
115
 
94
116
  ```bash
95
- bdt pr retry [--target-branch main]
117
+ bdt pr retry [--target-branch dev]
96
118
  ```
97
119
 
98
120
  No build/run ID needed — like `bdt pr status`, it resolves the PR (and its
@@ -125,7 +147,7 @@ workflow run triggered *directly* on `--target-branch` and reports its
125
147
  status the same way `pr status` does (failed steps print their logs inline).
126
148
 
127
149
  ```bash
128
- bdt pr watch-deploy [--target-branch main] [--wait]
150
+ bdt pr watch-deploy [--target-branch dev] [--wait]
129
151
  ```
130
152
 
131
153
  After `bdt pr status` reports the PR's build/checks succeeded, if a build/
@@ -146,7 +168,7 @@ branch. Failed steps are printed via `gh run view <id> --log-failed`.
146
168
  ## `bdt pr create`
147
169
 
148
170
  ```bash
149
- bdt pr create --target main # or --target test
171
+ bdt pr create --target dev # or --target main / test
150
172
  ```
151
173
 
152
174
  Creates a PR from the current branch into `--target`. On Azure DevOps,
@@ -154,7 +176,7 @@ a thin wrapper around `az repos pr create` (org/project/repo inferred by
154
176
  `az` itself from the git remote). On GitHub, `gh pr create --fill` (autofills
155
177
  title/body from commit info so it never blocks on an interactive prompt).
156
178
  Extra arguments pass through either way, e.g.
157
- `bdt pr create --target main -- --title "..."`.
179
+ `bdt pr create --target dev -- --title "..."`.
158
180
 
159
181
  PRs are created as **drafts by default**; a successful create always prints
160
182
  the PR's link plus `bdt pr publish` (which abstracts over the host) to mark
@@ -298,6 +320,26 @@ description and runs `claude -p "<prompt>" --name "60: <title>"`, so the session
298
320
  with `claude --resume`. `--agent` picks another executable (it then gets just the prompt as its
299
321
  last argument); extra args are passed through; `--dry-run` prints the command.
300
322
 
323
+ It takes the issue first (like `bdt issue take`): before the agent starts it comments `Taken by <you>`
324
+ on the issue, naming the session it is about to start -- for `claude` a `--session-id` is generated and
325
+ passed, so the comment says `claude --resume <id>`; the prompt tells the agent not to take it again. For
326
+ another `--agent` the comment just says `(via <agent>)`. `--dry-run` posts nothing. Like `issue take`, it doesn't comment again when the newest comment already is a claim.
327
+
328
+ ## `bdt issue take`
329
+
330
+ ```bash
331
+ bdt issue take [NUMBER] [--target-branch dev]
332
+ ```
333
+
334
+ Claims an issue (GitHub) or work item (Azure DevOps): comments `Taken by <you>` on it, followed by the
335
+ running coding agent's session (the same `<Agent> Session: <id>` note every bdt comment gets: a
336
+ claude.ai link under a bridged Claude session, otherwise the bare session id; nothing outside an
337
+ agent), so others can see who is on it. `<you>` is the GitHub login
338
+ `gh` is authenticated as, else the git `user.name`. Without `NUMBER` it takes the issue the current
339
+ branch's PR closes (`Fixes #N`, or an issue/work-item URL in the PR body); it refuses to guess if
340
+ that is none or several. If the issue's newest comment already is a "Taken by ..." claim (by anyone) it
341
+ says so and posts nothing, so it's safe to run repeatedly -- including before `bdt issue do`.
342
+
301
343
  ## `bdt worktree`
302
344
 
303
345
  ```bash
@@ -1,7 +1,7 @@
1
1
  # bmsdna-devtools
2
2
 
3
- Shared developer tooling for BMS projects: PR build/check status, PR
4
- creation, issue/work item creation and comments, git worktrees (creation and
3
+ Shared developer tooling for BMS projects: PR build/check status and a
4
+ one-shot PR summary (`bdt pr info`), PR creation, claiming an issue (`bdt issue take`), issue/work item creation and comments, git worktrees (creation and
5
5
  merged-worktree/orphaned-test-DB cleanup), a commit-and-push helper with
6
6
  pre-flight checks, Azure log queries, and static checks (`bdt lint`) for
7
7
  postgres/psycopg SQL rules, pydantic-model placement, hand-wired HTTP access in
@@ -35,9 +35,12 @@ Find the PR opened from the current branch and report build/check status
35
35
  GitHub — whichever `origin` points at.
36
36
 
37
37
  ```bash
38
- bdt pr status [--target-branch main] [--wait]
38
+ bdt pr status [--target-branch dev] [--wait]
39
39
  ```
40
40
 
41
+ Every `--target` / `--target-branch` option in bdt (`pr create/status/info/retry/publish/update/comment/watch-deploy`,
42
+ `issue take`, `commit`) defaults to `dev`; pass `--target main` (or whatever your PRs go into) to override.
43
+
41
44
  If the PR can't be merged, that's reported immediately instead of polling
42
45
  for builds/checks that will never run — e.g. on Azure DevOps:
43
46
  `PR #42 ('feat: widgets') has merge conflicts with the target branch (mergeStatus=conflicts)`;
@@ -72,6 +75,25 @@ locally at all. `bdt pr retry`, `pr publish`, `pr update`, and `pr comment`
72
75
  all accept the same option, for the same reason. (`pr watch-deploy` doesn't:
73
76
  it watches a branch-triggered build/workflow run, not any particular PR.)
74
77
 
78
+ ## `bdt pr info`
79
+
80
+ ```bash
81
+ bdt pr info [--json] [--pr-id N] [--target-branch dev]
82
+ ```
83
+
84
+ One-shot summary of the current branch's PR: link, state (open/merged/closed, draft), aggregate
85
+ build state (`passing`, `failing`, `pending`, `waiting` for a manual approval, `none`) and the
86
+ issue(s)/work item(s) it closes, with links. Exits 1 when the branch has no PR. `--json` is what
87
+ a status integration reads (e.g. the Claude Code mod planned in bmsuisse/skills#61). Build state is judged the way `bdt pr status` does: on GitHub from
88
+ the check rollup (only a failed check fails it; skipped/cancelled ones don't count), on Azure DevOps
89
+ from the latest build of each pipeline (any failed result fails it; unfinished builds are pending, or
90
+ `waiting` when a stage needs approval). `unknown` if Azure DevOps can't be asked.
91
+
92
+ Azure DevOps limits: the PR is found by the current branch into `--target-branch` (default `dev`),
93
+ a PR with merge conflicts is reported as an error (exit 1) like `pr status` does, and the closed
94
+ work items are only those whose URL is in the PR description -- not ones linked via
95
+ `bdt pr create --issue`.
96
+
75
97
  ## `bdt pr retry`
76
98
 
77
99
  Retry only the **failed** job(s)/stage(s) of the most recent build/run for the
@@ -79,7 +101,7 @@ PR opened from the current branch — not a whole new build/run. Whenever `bdt
79
101
  pr status` reports a failure, it prints a hint to run this.
80
102
 
81
103
  ```bash
82
- bdt pr retry [--target-branch main]
104
+ bdt pr retry [--target-branch dev]
83
105
  ```
84
106
 
85
107
  No build/run ID needed — like `bdt pr status`, it resolves the PR (and its
@@ -112,7 +134,7 @@ workflow run triggered *directly* on `--target-branch` and reports its
112
134
  status the same way `pr status` does (failed steps print their logs inline).
113
135
 
114
136
  ```bash
115
- bdt pr watch-deploy [--target-branch main] [--wait]
137
+ bdt pr watch-deploy [--target-branch dev] [--wait]
116
138
  ```
117
139
 
118
140
  After `bdt pr status` reports the PR's build/checks succeeded, if a build/
@@ -133,7 +155,7 @@ branch. Failed steps are printed via `gh run view <id> --log-failed`.
133
155
  ## `bdt pr create`
134
156
 
135
157
  ```bash
136
- bdt pr create --target main # or --target test
158
+ bdt pr create --target dev # or --target main / test
137
159
  ```
138
160
 
139
161
  Creates a PR from the current branch into `--target`. On Azure DevOps,
@@ -141,7 +163,7 @@ a thin wrapper around `az repos pr create` (org/project/repo inferred by
141
163
  `az` itself from the git remote). On GitHub, `gh pr create --fill` (autofills
142
164
  title/body from commit info so it never blocks on an interactive prompt).
143
165
  Extra arguments pass through either way, e.g.
144
- `bdt pr create --target main -- --title "..."`.
166
+ `bdt pr create --target dev -- --title "..."`.
145
167
 
146
168
  PRs are created as **drafts by default**; a successful create always prints
147
169
  the PR's link plus `bdt pr publish` (which abstracts over the host) to mark
@@ -285,6 +307,26 @@ description and runs `claude -p "<prompt>" --name "60: <title>"`, so the session
285
307
  with `claude --resume`. `--agent` picks another executable (it then gets just the prompt as its
286
308
  last argument); extra args are passed through; `--dry-run` prints the command.
287
309
 
310
+ It takes the issue first (like `bdt issue take`): before the agent starts it comments `Taken by <you>`
311
+ on the issue, naming the session it is about to start -- for `claude` a `--session-id` is generated and
312
+ passed, so the comment says `claude --resume <id>`; the prompt tells the agent not to take it again. For
313
+ another `--agent` the comment just says `(via <agent>)`. `--dry-run` posts nothing. Like `issue take`, it doesn't comment again when the newest comment already is a claim.
314
+
315
+ ## `bdt issue take`
316
+
317
+ ```bash
318
+ bdt issue take [NUMBER] [--target-branch dev]
319
+ ```
320
+
321
+ Claims an issue (GitHub) or work item (Azure DevOps): comments `Taken by <you>` on it, followed by the
322
+ running coding agent's session (the same `<Agent> Session: <id>` note every bdt comment gets: a
323
+ claude.ai link under a bridged Claude session, otherwise the bare session id; nothing outside an
324
+ agent), so others can see who is on it. `<you>` is the GitHub login
325
+ `gh` is authenticated as, else the git `user.name`. Without `NUMBER` it takes the issue the current
326
+ branch's PR closes (`Fixes #N`, or an issue/work-item URL in the PR body); it refuses to guess if
327
+ that is none or several. If the issue's newest comment already is a "Taken by ..." claim (by anyone) it
328
+ says so and posts nothing, so it's safe to run repeatedly -- including before `bdt issue do`.
329
+
288
330
  ## `bdt worktree`
289
331
 
290
332
  ```bash
@@ -314,6 +314,17 @@ def add_comment(session: requests.Session, remote: AdoRemote, work_item_id: int,
314
314
  return r.json()
315
315
 
316
316
 
317
+ def last_comment(session: requests.Session, remote: AdoRemote, work_item_id: int) -> str | None:
318
+ """Text (HTML, as stored) of the work item's newest comment, or None if it has none."""
319
+ r = session.get(
320
+ f"{_base_url(remote)}/_apis/wit/workItems/{work_item_id}/comments",
321
+ params={"api-version": COMMENTS_API_VERSION, "$top": 1, "order": "desc"},
322
+ )
323
+ r.raise_for_status()
324
+ comments = r.json().get("comments") or []
325
+ return comments[0].get("text") if comments else None
326
+
327
+
317
328
  def update_comment(session: requests.Session, remote: AdoRemote, work_item_id: int, comment_id: int, text: str) -> dict:
318
329
  r = session.patch(
319
330
  f"{_base_url(remote)}/_apis/wit/workItems/{work_item_id}/comments/{comment_id}",
@@ -379,14 +390,20 @@ def comment_with_screenshots(
379
390
  message: str | None,
380
391
  screenshot_paths: list[str],
381
392
  file_paths: list[str] | None = None,
393
+ agent_note: bool = True,
382
394
  ) -> dict:
383
- """Post a comment, with a message and/or screenshots/files, on the work item."""
395
+ """Post a comment, with a message and/or screenshots/files, on the work item.
396
+
397
+ `agent_note=False` leaves out the detected agent-session note (see `gh_issue.comment`).
398
+ """
384
399
  file_paths = file_paths or []
385
400
  images = _upload_attachments(session, remote, screenshot_paths) if screenshot_paths else []
386
401
  files = _upload_attachments(session, remote, file_paths) if file_paths else []
387
402
  if images or files:
388
403
  link_attachments(session, remote, work_item_id, images + files)
389
- content = ensure_agent_session_note(build_comment_content_html(message, images, files)) or ""
404
+ content = build_comment_content_html(message, images, files) or ""
405
+ if agent_note:
406
+ content = ensure_agent_session_note(content) or ""
390
407
  comment = add_comment(session, remote, work_item_id, content)
391
408
  print(
392
409
  f"Added comment #{comment['id']} ({len(screenshot_paths)} screenshot(s), {len(file_paths)} file(s)) "
@@ -1,8 +1,10 @@
1
1
  from __future__ import annotations
2
2
 
3
3
  import json
4
+ import re
4
5
  import subprocess
5
6
  import sys
7
+ import uuid
6
8
  from collections.abc import Callable
7
9
  from datetime import datetime, timedelta, timezone
8
10
  from importlib.metadata import version as _pkg_version
@@ -20,9 +22,9 @@ from . import gh_issue, gh_pr
20
22
  from . import find_injection as find_injection_mod
21
23
  from . import lint as lint_mod
22
24
  from . import logs as logs_mod
23
- from . import pr_build, pr_issue_link, pr_labels, pull as pull_mod, worktree as worktree_mod
25
+ from . import pr_build, pr_info as pr_info_mod, pr_issue_link, pr_labels, pull as pull_mod, worktree as worktree_mod
24
26
  from .ado_auth import auth_header
25
- from .cli_tools import detect_agent_session, require_az, require_gh
27
+ from .cli_tools import CLI_TIMEOUT_SECS, detect_agent_session, require_az, require_gh
26
28
  from .gitrepo import AdoRemote, GitHubRemote, UnknownRemoteError, current_branch, current_remote, head_commit_subject
27
29
 
28
30
  # Non-ASCII output (checkmarks, en-dashes in ADO project names, etc.) needs a
@@ -34,6 +36,9 @@ if sys.platform == "win32":
34
36
 
35
37
  __version__ = _pkg_version("bmsdna-devtools")
36
38
 
39
+ # What `--target` / `--target-branch` mean when not given: the branch PRs normally go into.
40
+ DEFAULT_TARGET_BRANCH = "dev"
41
+
37
42
  app = typer.Typer(
38
43
  name="bdt",
39
44
  help=f"Shared BMS developer tooling: PRs/builds (Azure DevOps or GitHub), worktrees, commits, logs (v{__version__})",
@@ -115,6 +120,14 @@ def _resolve_ado_pr(pat: str | None, remote: AdoRemote, target: str, pr_id: int
115
120
  return session, pr
116
121
 
117
122
 
123
+ def _current_pr_info(pr_id: int | None, target_branch: str, pat: str | None) -> pr_info_mod.PrInfo:
124
+ remote = current_remote()
125
+ if isinstance(remote, GitHubRemote):
126
+ return pr_info_mod.github_info(gh_pr.get_pr(require_gh(), pr_id, fields=gh_pr.PR_INFO_FIELDS), remote)
127
+ session, pr = _resolve_ado_pr(pat, remote, target_branch, pr_id=pr_id)
128
+ return pr_info_mod.ado_info(session, pr, remote)
129
+
130
+
118
131
  def _gh_branch_for(gh: str, pr_id: int | None, *, needed: bool) -> str:
119
132
  """Branch to namespace uploaded screenshots/files under (see `gh_pr.push_assets`) --
120
133
  the checked-out branch normally, or (when acting on a PR by `--pr-id`, which may not
@@ -219,7 +232,7 @@ def _link_and_label_ado(session: requests.Session, remote: AdoRemote, pr_id: int
219
232
 
220
233
  @pr_app.command("create")
221
234
  def pr_create(
222
- target: str = typer.Option("main", "--target", help="Target branch (e.g. main, test)"),
235
+ target: str = typer.Option(DEFAULT_TARGET_BRANCH, "--target", help="Target branch (e.g. dev, main, test)"),
223
236
  draft: bool = typer.Option(
224
237
  True,
225
238
  "--draft/--no-draft",
@@ -371,7 +384,7 @@ def pr_create(
371
384
 
372
385
  @pr_app.command("publish")
373
386
  def pr_publish(
374
- target: str = typer.Option("main", "--target", help="Target branch of the PR (Azure DevOps only)"),
387
+ target: str = typer.Option(DEFAULT_TARGET_BRANCH, "--target", help="Target branch of the PR (Azure DevOps only)"),
375
388
  pr_id: int | None = typer.Option(None, "--pr-id", help=_PR_ID_HELP),
376
389
  pat: str | None = typer.Option(
377
390
  None,
@@ -393,7 +406,7 @@ def pr_publish(
393
406
 
394
407
  @pr_app.command("status")
395
408
  def pr_status(
396
- target_branch: str = typer.Option("main", "--target-branch", help="Target branch of the PR (Azure DevOps only — gh has no equivalent filter, it always resolves the PR for the current branch)"),
409
+ target_branch: str = typer.Option(DEFAULT_TARGET_BRANCH, "--target-branch", help="Target branch of the PR (Azure DevOps only — gh has no equivalent filter, it always resolves the PR for the current branch)"),
397
410
  wait: bool = typer.Option(False, "--wait", help="Poll until all pipelines/checks are completed; stops early and reports status if one needs manual approval"),
398
411
  pr_id: int | None = typer.Option(None, "--pr-id", help=_PR_ID_HELP),
399
412
  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)"),
@@ -408,9 +421,30 @@ def pr_status(
408
421
  pr_build.run(remote, pat, target_branch, wait, pr_id=pr_id)
409
422
 
410
423
 
424
+ @pr_app.command("info")
425
+ def pr_info(
426
+ as_json: bool = typer.Option(False, "--json", help="Print machine-readable JSON (for status integrations that poll it)"),
427
+ target_branch: str = typer.Option(DEFAULT_TARGET_BRANCH, "--target-branch", help="Target branch of the PR (Azure DevOps only)"),
428
+ pr_id: int | None = typer.Option(None, "--pr-id", help=_PR_ID_HELP),
429
+ 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)"),
430
+ ) -> None:
431
+ """One-shot summary of the PR opened from the current branch, or --pr-id directly: link, state,
432
+ aggregate build state, and the issue(s)/work item(s) it closes. Exits 1 if there is no such PR.
433
+ Build state is judged the way `pr status` does (latest build per pipeline on Azure DevOps).
434
+ """
435
+ info = _current_pr_info(pr_id, target_branch, pat)
436
+ if as_json:
437
+ typer.echo(json.dumps(info.to_json_dict()))
438
+ return
439
+ flags = ", ".join([info.state, *(["draft"] if info.draft else []), f"build: {info.build}"])
440
+ typer.echo(f"PR #{info.number}: {info.title} ({flags})\n{info.url}")
441
+ for issue in info.issues:
442
+ typer.echo(f"Issue #{issue.number}: {issue.url}")
443
+
444
+
411
445
  @pr_app.command("retry")
412
446
  def pr_retry(
413
- target_branch: str = typer.Option("main", "--target-branch", help="Target branch of the PR (Azure DevOps only)"),
447
+ target_branch: str = typer.Option(DEFAULT_TARGET_BRANCH, "--target-branch", help="Target branch of the PR (Azure DevOps only)"),
414
448
  pr_id: int | None = typer.Option(None, "--pr-id", help=_PR_ID_HELP),
415
449
  pat: str | None = typer.Option(
416
450
  None,
@@ -432,7 +466,7 @@ def pr_retry(
432
466
 
433
467
  @pr_app.command("watch-deploy")
434
468
  def pr_watch_deploy(
435
- target_branch: str = typer.Option("main", "--target-branch", help="Branch to watch for a directly-triggered build/workflow run (e.g. a post-merge deployment pipeline)"),
469
+ target_branch: str = typer.Option(DEFAULT_TARGET_BRANCH, "--target-branch", help="Branch to watch for a directly-triggered build/workflow run (e.g. a post-merge deployment pipeline)"),
436
470
  wait: bool = typer.Option(False, "--wait", help="Poll until the build/workflow run(s) are completed; stops early and reports status if one needs manual approval"),
437
471
  pat: str | None = typer.Option(
438
472
  None,
@@ -467,7 +501,7 @@ def pr_update(
467
501
  file: list[str] = typer.Option(
468
502
  [], "--file", help="Path to an arbitrary file to append to the PR description as a linked attachment (repeatable)"
469
503
  ),
470
- target: str = typer.Option("main", "--target", help="Target branch of the PR (Azure DevOps only)"),
504
+ target: str = typer.Option(DEFAULT_TARGET_BRANCH, "--target", help="Target branch of the PR (Azure DevOps only)"),
471
505
  pr_id: int | None = typer.Option(None, "--pr-id", help=_PR_ID_HELP),
472
506
  pat: str | None = typer.Option(
473
507
  None,
@@ -510,7 +544,7 @@ def pr_comment(
510
544
  file: list[str] = typer.Option(
511
545
  [], "--file", help="Path to an arbitrary file to link in the comment as an attachment (repeatable)"
512
546
  ),
513
- target: str = typer.Option("main", "--target", help="Target branch of the PR (Azure DevOps only)"),
547
+ target: str = typer.Option(DEFAULT_TARGET_BRANCH, "--target", help="Target branch of the PR (Azure DevOps only)"),
514
548
  pr_id: int | None = typer.Option(None, "--pr-id", help=_PR_ID_HELP),
515
549
  pat: str | None = typer.Option(
516
550
  None,
@@ -717,7 +751,86 @@ def issue_do(
717
751
  session = requests.Session()
718
752
  session.headers.update(auth_header(pat))
719
753
  title, body = issue_do_mod.fetch_ado(session, remote, number)
720
- issue_do_mod.run(number, title, body, agent=agent, extra=list(ctx.args), dry_run=dry_run)
754
+ extra = list(ctx.args)
755
+ # claude can be given the session id up front, so the "Taken by" comment can link the session that will exist
756
+ session_id = str(uuid.uuid4()) if agent == "claude" and "--session-id" not in extra else None
757
+ if dry_run:
758
+ typer.echo(f"(dry run: would comment 'Taken by ...' on #{number} first)", err=True)
759
+ else:
760
+ _take(remote, number, issue_do_mod.take_message(_current_user(remote), agent, session_id), pat, agent_note=False)
761
+ issue_do_mod.run(number, title, body, agent=agent, extra=extra, dry_run=dry_run, session_id=session_id)
762
+
763
+
764
+ @issue_app.command("take")
765
+ def issue_take(
766
+ number: int | None = typer.Argument(None, help="Issue number (GitHub) or work item ID (Azure DevOps). Default: the issue the current branch's PR closes"),
767
+ target_branch: str = typer.Option(DEFAULT_TARGET_BRANCH, "--target-branch", help="Target branch of the PR (Azure DevOps only; only used to find the issue when no number is given)"),
768
+ 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)"),
769
+ ) -> None:
770
+ """Claim an issue / work item: comment "Taken by <you>" on it, with this coding agent's
771
+ session link (detected automatically) so others can see who is working on it and find the session.
772
+ """
773
+ remote = current_remote()
774
+ if number is None:
775
+ issues = _current_pr_info(None, target_branch, pat).issues
776
+ if len(issues) != 1:
777
+ raise typer.BadParameter(
778
+ f"The current branch's PR closes {len(issues)} issues, so can't tell which to take -- pass the number"
779
+ if issues
780
+ else "No issue number given, and the current branch's PR doesn't reference one (e.g. 'Fixes #N')"
781
+ )
782
+ number = issues[0].number
783
+ _take(remote, number, f"{issue_do_mod.TAKEN_PREFIX} {_current_user(remote)}", pat)
784
+
785
+
786
+ def _take(remote: AdoRemote | GitHubRemote, number: int, message: str, pat: str | None, *, agent_note: bool = True) -> None:
787
+ """Comment `message` ("Taken by ...") on the issue -- unless its newest comment already is such a
788
+ claim, so running this twice (or `issue do` after `issue take`) doesn't post it again."""
789
+ kind = "issue" if isinstance(remote, GitHubRemote) else "work item"
790
+ if issue_do_mod.is_take_comment(_last_comment(remote, number, pat)):
791
+ typer.echo(f"{kind.capitalize()} #{number} is already taken (its last comment says so) -- not commenting again")
792
+ return
793
+ typer.echo(f"Taking {kind} #{number}")
794
+ _comment_on_issue(remote, number, message, pat, agent_note=agent_note)
795
+
796
+
797
+ def _last_comment(remote: AdoRemote | GitHubRemote, number: int, pat: str | None) -> str | None:
798
+ if isinstance(remote, GitHubRemote):
799
+ return gh_issue.last_comment(require_gh(), remote.owner, remote.repo, number)
800
+ session = requests.Session()
801
+ session.headers.update(auth_header(pat))
802
+ return ado_issue.last_comment(session, remote, number)
803
+
804
+
805
+ def _comment_on_issue(remote: AdoRemote | GitHubRemote, number: int, message: str, pat: str | None, *, agent_note: bool = True) -> None:
806
+ if isinstance(remote, GitHubRemote):
807
+ gh_issue.comment(require_gh(), remote.owner, remote.repo, number, message, [], agent_note=agent_note)
808
+ else:
809
+ session = requests.Session()
810
+ session.headers.update(auth_header(pat))
811
+ ado_issue.comment_with_screenshots(session, remote, number, message, [], [], agent_note=agent_note)
812
+
813
+
814
+ def _current_user(remote: AdoRemote | GitHubRemote) -> str:
815
+ """Who to say took an issue: the GitHub login `gh` is authenticated as, else the git `user.name`."""
816
+ if isinstance(remote, GitHubRemote):
817
+ r = _capture([require_gh(), "api", "user", "--jq", ".login"])
818
+ if r and r.returncode == 0 and r.stdout.strip():
819
+ return r.stdout.strip()
820
+ r = _capture(["git", "config", "user.name"])
821
+ # `git config user.name` comes from the (possibly cloned-from-elsewhere) repo's own config and ends up in a
822
+ # markdown/HTML comment, so keep it to plain name characters: no links, tags or @mentions
823
+ name = re.sub(r"[^\w .'-]", "", r.stdout if r else "").strip()[:64]
824
+ return name or "unknown"
825
+
826
+
827
+ def _capture(cmd: list[str]) -> subprocess.CompletedProcess | None:
828
+ """Run `cmd` for its UTF-8 output, bounded so a stalled `gh` (expired login prompting) can't hang the
829
+ caller; None on timeout."""
830
+ try:
831
+ return subprocess.run(cmd, capture_output=True, encoding="utf-8", timeout=CLI_TIMEOUT_SECS, check=False)
832
+ except subprocess.TimeoutExpired:
833
+ return None
721
834
 
722
835
 
723
836
  @issue_app.command("update")
@@ -1116,7 +1229,7 @@ def commit(
1116
1229
  subrepo: list[str] = typer.Option([], "--subrepo", help="Submodule directory name to split matching files into (repeatable)"),
1117
1230
  skip_message_check: bool = typer.Option(False, "--skip-message-check", help="Don't require a conventional-commit-style message"),
1118
1231
  allow_main: bool = typer.Option(False, "--allow-main", help="Allow committing directly on main/master"),
1119
- target: str = typer.Option("main", "--target", help="Target branch of the PR to draft on a 'feat' commit (Azure DevOps only)"),
1232
+ target: str = typer.Option(DEFAULT_TARGET_BRANCH, "--target", help="Target branch of the PR to draft on a 'feat' commit (Azure DevOps only)"),
1120
1233
  pat: str | None = typer.Option(
1121
1234
  None,
1122
1235
  "--pat",
@@ -366,12 +366,19 @@ def comment(
366
366
  message: str | None,
367
367
  screenshot_paths: list[str],
368
368
  file_paths: list[str] | None = None,
369
+ agent_note: bool = True,
369
370
  ) -> None:
370
- """Post a comment, with a message and/or screenshots/files, on a GitHub issue."""
371
+ """Post a comment, with a message and/or screenshots/files, on a GitHub issue.
372
+
373
+ `agent_note=False` leaves out the detected agent-session note, for a caller (`bdt issue do`) that
374
+ is not itself the agent and writes the right session into `message` instead.
375
+ """
371
376
  file_paths = file_paths or []
372
377
  images = _screenshot_images(owner, repo, f"issue-{number}", screenshot_paths) if screenshot_paths else []
373
378
  files = _file_links(owner, repo, f"issue-{number}", file_paths) if file_paths else []
374
- content = ensure_agent_session_note(build_comment_content(message, images, files)) or ""
379
+ content = build_comment_content(message, images, files) or ""
380
+ if agent_note:
381
+ content = ensure_agent_session_note(content) or ""
375
382
  url = _run_gh(gh, ["issue", "comment", str(number), "--body", content])
376
383
  comment_id = parse_comment_id(url)
377
384
  suffix = f" (comment #{comment_id})" if comment_id else ""
@@ -379,6 +386,19 @@ def comment(
379
386
  print(url)
380
387
 
381
388
 
389
+ _LAST_COMMENT_QUERY = (
390
+ "query($owner: String!, $repo: String!, $number: Int!) { repository(owner: $owner, name: $repo) "
391
+ "{ issue(number: $number) { comments(last: 1) { nodes { body } } } } }"
392
+ )
393
+
394
+
395
+ def last_comment(gh: str, owner: str, repo: str, number: int) -> str | None:
396
+ """Text of the issue's newest comment, or None if it has none."""
397
+ out = _run_gh(gh, ["api", "graphql", "-f", f"query={_LAST_COMMENT_QUERY}", "-f", f"owner={owner}", "-f", f"repo={repo}", "-F", f"number={number}"])
398
+ nodes = json.loads(out)["data"]["repository"]["issue"]["comments"]["nodes"]
399
+ return nodes[-1]["body"] if nodes else None
400
+
401
+
382
402
  def update_comment(gh: str, owner: str, repo: str, comment_id: str, text: str) -> None:
383
403
  _run_gh(gh, ["api", "--method", "PATCH", f"repos/{owner}/{repo}/issues/comments/{comment_id}", "-f", f"body={text}"])
384
404
  print(f"Updated comment #{comment_id}")
@@ -23,6 +23,8 @@ from .cli_tools import CLI_TIMEOUT_SECS, EXIT_NEEDS_APPROVAL, PollHeartbeat, det
23
23
  from .pr_markdown import build_attachments_section, build_comment_content, build_screenshots_section
24
24
 
25
25
  PR_VIEW_FIELDS = "number,title,baseRefName,headRefName,mergeable,statusCheckRollup,isDraft"
26
+ # What `bdt pr info` needs on top of the check rollup: where to link to, and the issues GitHub says it closes.
27
+ PR_INFO_FIELDS = "number,title,url,state,isDraft,closingIssuesReferences,statusCheckRollup"
26
28
 
27
29
  # GitHub has no API for uploading images to a PR description (only the web
28
30
  # UI's drag-and-drop, which needs a browser session). The standard
@@ -99,14 +101,16 @@ def _pr_id_args(pr_id: int | None) -> list[str]:
99
101
  return [str(pr_id)] if pr_id is not None else []
100
102
 
101
103
 
102
- def get_pr(gh: str, pr_id: int | None = None) -> dict:
104
+ def get_pr(gh: str, pr_id: int | None = None, fields: str = PR_VIEW_FIELDS) -> dict:
103
105
  """The PR for the current branch, however `gh` resolves it — there's no
104
106
  target-branch filter on `gh pr view` the way ADO's search API has one.
105
107
 
106
108
  If `pr_id` is given, resolves that PR directly instead -- lets a caller act on a
107
109
  specific PR without needing its branch checked out locally at all.
110
+
111
+ `fields` is the `--json` field list; defaults to what `bdt pr status` needs.
108
112
  """
109
- args = ["pr", "view", *_pr_id_args(pr_id), "--json", PR_VIEW_FIELDS]
113
+ args = ["pr", "view", *_pr_id_args(pr_id), "--json", fields]
110
114
  return _run_gh_json(gh, args)
111
115
 
112
116
 
@@ -19,6 +19,7 @@ from .ado_issue import _base_url
19
19
  from .gitrepo import AdoRemote
20
20
 
21
21
  _HTML_TAG_RE = re.compile(r"<[^>]+>")
22
+ TAKEN_PREFIX = "Taken by"
22
23
 
23
24
 
24
25
  def fetch_github(gh: str, number: int) -> tuple[str, str]:
@@ -49,20 +50,37 @@ def build_prompt(number: int, title: str, body: str) -> str:
49
50
  return (
50
51
  f"Work on issue {number}: {title}\n\n{body.strip() or '(no description)'}\n\n"
51
52
  "Follow the dev-workflow skill: worktree, draft PR early, implement, test, publish the PR "
52
- f"and reference issue {number} in it. Do not merge."
53
+ f"and reference issue {number} in it. Do not merge. `bdt issue do` has already marked "
54
+ f"issue {number} as taken (with this session), so don't run `bdt issue take`."
53
55
  )
54
56
 
55
57
 
56
- def build_command(agent: str, number: int, title: str, body: str, extra: list[str]) -> list[str]:
58
+ def is_take_comment(text: str | None) -> bool:
59
+ """Is `text` (a comment, markdown or ADO HTML) a "Taken by ..." claim -- what `take_message` and
60
+ `bdt issue take` post?"""
61
+ return _HTML_TAG_RE.sub("", text or "").strip().startswith(TAKEN_PREFIX)
62
+
63
+
64
+ def take_message(user: str, agent: str, session_id: str | None) -> str:
65
+ """The "Taken by" comment `bdt issue do` posts before the agent starts. It names the session itself,
66
+ because the agent's own session doesn't exist yet and `bdt`'s auto-detected one (if `bdt issue do` is
67
+ run from inside an agent) would be the wrong one."""
68
+ if session_id is None:
69
+ return f"{TAKEN_PREFIX} {user} (via {agent})"
70
+ return f"{TAKEN_PREFIX} {user}\n\nClaude Session: {session_id} (resume with `claude --resume {session_id}`)"
71
+
72
+
73
+ def build_command(agent: str, number: int, title: str, body: str, extra: list[str], session_id: str | None = None) -> list[str]:
57
74
  prompt = build_prompt(number, title, body)
58
75
  if agent == "claude":
59
- return [agent, "-p", prompt, "--name", session_name(number, title), *extra]
76
+ session = ["--session-id", session_id] if session_id else []
77
+ return [agent, "-p", prompt, "--name", session_name(number, title), *session, *extra]
60
78
  # Other agents: no known naming flag, so just hand over the prompt.
61
79
  return [agent, *extra, prompt]
62
80
 
63
81
 
64
- def run(number: int, title: str, body: str, *, agent: str, extra: list[str], dry_run: bool) -> None:
65
- cmd = build_command(agent, number, title, body, extra)
82
+ def run(number: int, title: str, body: str, *, agent: str, extra: list[str], dry_run: bool, session_id: str | None = None) -> None:
83
+ cmd = build_command(agent, number, title, body, extra, session_id)
66
84
  if dry_run:
67
85
  print(" ".join(json.dumps(c) if " " in c or "\n" in c else c for c in cmd))
68
86
  return