claude-dev-env 2.4.0 → 2.7.0

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 (309) hide show
  1. package/CLAUDE.md +26 -59
  2. package/_shared/pr-loop/scripts/_claude_permissions_common.py +84 -0
  3. package/_shared/pr-loop/scripts/code_rules_gate.py +6 -3
  4. package/_shared/pr-loop/scripts/code_rules_gate_parts/CLAUDE.md +12 -2
  5. package/_shared/pr-loop/scripts/code_rules_gate_parts/baseline_import_isolation.py +309 -0
  6. package/_shared/pr-loop/scripts/code_rules_gate_parts/staged_test_regression.py +540 -0
  7. package/_shared/pr-loop/scripts/code_rules_gate_parts/staged_test_running.py +206 -70
  8. package/_shared/pr-loop/scripts/code_rules_gate_parts/tests/__init__.py +1 -0
  9. package/_shared/pr-loop/scripts/code_rules_gate_parts/tests/_repo_test_helpers.py +76 -0
  10. package/_shared/pr-loop/scripts/code_rules_gate_parts/tests/test_baseline_import_isolation.py +248 -0
  11. package/_shared/pr-loop/scripts/code_rules_gate_parts/tests/test_staged_test_regression.py +309 -0
  12. package/_shared/pr-loop/scripts/code_rules_gate_parts/tests/test_staged_test_running.py +91 -58
  13. package/_shared/pr-loop/scripts/grant_project_claude_permissions.py +306 -306
  14. package/_shared/pr-loop/scripts/pr_loop_shared_constants/claude_permissions_constants.py +44 -0
  15. package/_shared/pr-loop/scripts/pr_loop_shared_constants/code_rules_gate_constants.py +202 -0
  16. package/_shared/pr-loop/scripts/pr_loop_shared_constants/copilot_quota_constants.py +24 -24
  17. package/_shared/pr-loop/scripts/pr_loop_shared_constants/stale_worktree_rule_sweep_constants.py +107 -107
  18. package/_shared/pr-loop/scripts/revoke_project_claude_permissions.py +290 -48
  19. package/_shared/pr-loop/scripts/tests/test_claude_permissions_common.py +42 -2
  20. package/_shared/pr-loop/scripts/tests/test_claude_permissions_constants.py +36 -0
  21. package/_shared/pr-loop/scripts/tests/test_code_rules_gate.py +100 -1
  22. package/_shared/pr-loop/scripts/tests/test_fix_hookspath.py +497 -497
  23. package/_shared/pr-loop/scripts/tests/test_revoke_project_claude_permissions.py +311 -2
  24. package/_shared/pr-loop/scripts/tests/test_stale_worktree_rule_sweep.py +301 -301
  25. package/_shared/pr-loop/scripts/tests/test_stale_worktree_rule_sweep_constants.py +85 -85
  26. package/_shared/pr-loop/worker-spawn.md +1 -1
  27. package/agents/CLAUDE.md +3 -2
  28. package/agents/caveman.md +0 -1
  29. package/agents/clasp-deployment-orchestrator.md +0 -1
  30. package/agents/clean-coder.md +0 -1
  31. package/agents/code-advisor.md +0 -1
  32. package/agents/code-quality-agent.md +1 -2
  33. package/agents/code-verifier.md +36 -8
  34. package/agents/deep-research.md +0 -1
  35. package/agents/docs-agent.md +0 -1
  36. package/agents/git-commit-crafter.md +0 -1
  37. package/agents/issue-tracker.md +42 -0
  38. package/agents/plan-packet-validator.md +0 -1
  39. package/agents/pr-description-writer.md +0 -1
  40. package/agents/test_agent_frontmatter.py +67 -18
  41. package/audit-rubrics/category_rubrics/category-o-docstring-vs-impl-drift.md +143 -141
  42. package/bin/CLAUDE.md +68 -5
  43. package/bin/codex-compat.mjs +104 -0
  44. package/bin/codex-compat.test.mjs +51 -0
  45. package/bin/ever-shipped-skills.mjs +1 -0
  46. package/bin/install-constants.mjs +88 -0
  47. package/bin/install.mjs +1138 -114
  48. package/bin/install.prune.test.mjs +869 -19
  49. package/bin/install.test.mjs +906 -2
  50. package/codex-capability-map.json +13 -0
  51. package/commands/implement.md +1 -1
  52. package/commands/right-size.md +1 -1
  53. package/docs/CLAUDE.md +1 -0
  54. package/docs/CODE_RULES.md +2 -0
  55. package/docs/codex-compatibility.md +25 -0
  56. package/docs/host-pool-health-monitor.md +102 -0
  57. package/docs/nas-ssh-invocation.md +96 -12
  58. package/docs/references/CLAUDE.md +4 -2
  59. package/docs/references/advisor-tool.md +13 -0
  60. package/docs/references/code-review-enforcement.md +35 -0
  61. package/docs/references/team-advisor-skill.md +14 -0
  62. package/hooks/blocking/CLAUDE.md +4 -0
  63. package/hooks/blocking/code_review_pr_create_gate.py +7 -3
  64. package/hooks/blocking/code_review_push_gate.py +9 -4
  65. package/hooks/blocking/code_review_stamp_directory_write_blocker.py +8 -0
  66. package/hooks/blocking/config/__init__.py +5 -5
  67. package/hooks/blocking/config/code_review_enforcement_constants.py +40 -7
  68. package/hooks/blocking/config/test_code_review_enforcement_constants.py +58 -0
  69. package/hooks/blocking/config/verified_commit_constants.py +160 -159
  70. package/hooks/blocking/eli11_reply_enforcer.py +479 -0
  71. package/hooks/blocking/gh_body_arg_blocker.py +1 -1
  72. package/hooks/blocking/nas_ssh_binary_enforcer.py +8 -46
  73. package/hooks/blocking/orchestrator_refresh_reschedule_gate.py +256 -0
  74. package/hooks/blocking/pre_tool_use_dispatcher.py +24 -24
  75. package/hooks/blocking/shell_substitution_blocker.py +129 -0
  76. package/hooks/blocking/state_description_blocker.py +1 -1
  77. package/hooks/blocking/stop_dispatcher.py +1 -1
  78. package/hooks/blocking/test_bash_pre_tool_use_dispatcher.py +2 -3
  79. package/hooks/blocking/test_code_review_pr_create_gate.py +14 -0
  80. package/hooks/blocking/test_code_review_push_gate.py +16 -0
  81. package/hooks/blocking/test_code_review_stamp_directory_write_blocker.py +19 -0
  82. package/hooks/blocking/test_eli11_reply_enforcer.py +457 -0
  83. package/hooks/blocking/test_orchestrator_refresh_reschedule_gate.py +231 -0
  84. package/hooks/blocking/test_pre_tool_use_dispatcher.py +10 -1
  85. package/hooks/blocking/test_shell_substitution_blocker.py +124 -0
  86. package/hooks/blocking/test_stop_dispatcher.py +23 -0
  87. package/hooks/blocking/test_unscoped_search_blocker.py +102 -0
  88. package/hooks/blocking/test_verdict_directory_write_blocker.py +804 -808
  89. package/hooks/blocking/test_verification_verdict_store.py +54 -0
  90. package/hooks/blocking/test_verified_commit_gate.py +581 -581
  91. package/hooks/blocking/test_verified_commit_message_accuracy_blocker.py +131 -131
  92. package/hooks/blocking/unscoped_search_blocker.py +391 -0
  93. package/hooks/blocking/verdict_directory_write_blocker.py +687 -687
  94. package/hooks/blocking/verification_verdict_store.py +1039 -1036
  95. package/hooks/blocking/verified_commit_message_accuracy_blocker.py +167 -167
  96. package/hooks/blocking/verifier_verdict_minter.py +280 -280
  97. package/hooks/git-hooks/CLAUDE.md +3 -0
  98. package/hooks/git-hooks/conftest.py +30 -0
  99. package/hooks/git-hooks/gate_utils.py +2 -2
  100. package/hooks/git-hooks/git_hooks_constants/__init__.py +41 -2
  101. package/hooks/git-hooks/pre_push.py +75 -4
  102. package/hooks/git-hooks/pre_push_base_reference.py +166 -0
  103. package/hooks/git-hooks/test_config.py +0 -15
  104. package/hooks/git-hooks/test_gate_utils.py +3 -15
  105. package/hooks/git-hooks/test_pre_commit.py +1 -15
  106. package/hooks/git-hooks/test_pre_push.py +257 -23
  107. package/hooks/git-hooks/test_pre_push_base_reference.py +339 -0
  108. package/hooks/hooks.json +10 -12
  109. package/hooks/hooks_constants/CLAUDE.md +7 -2
  110. package/hooks/hooks_constants/bash_pre_tool_use_dispatcher_constants.py +4 -4
  111. package/hooks/hooks_constants/eli11_reply_enforcer_constants.py +101 -0
  112. package/hooks/hooks_constants/enter_worktree_prefetch_constants.py +18 -18
  113. package/hooks/hooks_constants/nas_ssh_binary_enforcer_constants.py +2 -8
  114. package/hooks/hooks_constants/orchestrator_refresh_reschedule_gate_constants.py +48 -0
  115. package/hooks/hooks_constants/ruff_integration_constants.py +16 -0
  116. package/hooks/hooks_constants/shell_command_segments.py +82 -0
  117. package/hooks/hooks_constants/shell_substitution_blocker_constants.py +67 -0
  118. package/hooks/hooks_constants/stop_dispatcher_constants.py +1 -0
  119. package/hooks/hooks_constants/test_bash_pre_tool_use_dispatcher_constants.py +5 -6
  120. package/hooks/hooks_constants/test_stop_dispatcher_constants.py +1 -0
  121. package/hooks/hooks_constants/unscoped_search_blocker_constants.py +153 -0
  122. package/hooks/lifecycle/enter_worktree_origin_prefetch.py +163 -146
  123. package/hooks/lifecycle/test_enter_worktree_origin_prefetch.py +185 -178
  124. package/hooks/pyproject.toml +1 -0
  125. package/hooks/validators/CLAUDE.md +1 -0
  126. package/hooks/validators/config/__init__.py +0 -0
  127. package/hooks/validators/config/directory_exemption_constants.py +183 -0
  128. package/hooks/validators/config/test_directory_exemption_constants.py +21 -0
  129. package/hooks/validators/conftest.py +4 -0
  130. package/hooks/validators/ruff_integration.py +49 -5
  131. package/hooks/validators/run_all_validators.py +206 -9
  132. package/hooks/validators/test_directory_exemption_constants.py +185 -0
  133. package/hooks/validators/test_python_antipattern_checks.py +110 -5
  134. package/hooks/validators/test_ruff_integration.py +92 -1
  135. package/hooks/validators/test_run_all_validators.py +115 -68
  136. package/hooks/validators/test_run_all_validators_pretooluse.py +159 -1
  137. package/package.json +13 -3
  138. package/rules/CLAUDE.md +17 -22
  139. package/rules/agent-spawn-protocol.md +6 -6
  140. package/rules/anti-corollary-tests.md +1 -1
  141. package/rules/bdd.md +1 -1
  142. package/rules/cleanup-temp-files.md +10 -4
  143. package/rules/code-standards.md +7 -0
  144. package/rules/conservative-action.md +1 -5
  145. package/rules/context7.md +0 -4
  146. package/rules/destructive-commands.md +47 -0
  147. package/rules/doc-inventory-integrity.md +48 -0
  148. package/rules/doc-prose-cuts.md +58 -0
  149. package/rules/docstring-prose-matches-implementation.md +53 -44
  150. package/rules/durable-post-artifacts.md +0 -4
  151. package/rules/eli11-replies.md +31 -0
  152. package/rules/explore-thoroughly.md +4 -4
  153. package/rules/falsify-before-green.md +68 -0
  154. package/rules/file-global-constants.md +1 -1
  155. package/rules/filesystem-search.md +51 -0
  156. package/rules/gh-cli-conventions.md +27 -0
  157. package/rules/git-workflow.md +26 -0
  158. package/rules/hedging-claims.md +9 -0
  159. package/rules/long-horizon-autonomy.md +0 -4
  160. package/rules/measurement-denominators.md +48 -0
  161. package/rules/nas-ssh-invocation.md +23 -5
  162. package/rules/parallel-tools.md +2 -2
  163. package/rules/plain-illustrative-docstrings.md +3 -7
  164. package/rules/plain-language.md +2 -0
  165. package/rules/proof-of-work-pr-comments.md +0 -4
  166. package/rules/re-stage-before-commit.md +2 -0
  167. package/rules/research-mode.md +10 -0
  168. package/rules/shell-invocation.md +21 -0
  169. package/rules/testing.md +4 -0
  170. package/rules/verified-commit-gate-skip.md +3 -27
  171. package/rules/verify-before-asking.md +5 -0
  172. package/rules/windows-filesystem-safe.md +1 -1
  173. package/rules/workers-done-before-complete.md +4 -0
  174. package/scripts/CLAUDE.md +1 -0
  175. package/scripts/Capture-PoolHealth.ps1 +410 -0
  176. package/scripts/Migrate-ShellPolicy.ps1 +1 -1
  177. package/scripts/_code_review_test_support.py +404 -0
  178. package/scripts/claude_chain_runner.py +141 -1
  179. package/scripts/codex_capability_bridge.py +171 -0
  180. package/scripts/codex_compat_materializer.py +1087 -0
  181. package/scripts/codex_compat_watcher.py +502 -0
  182. package/scripts/conftest.py +16 -1
  183. package/scripts/dev_env_scripts_constants/CLAUDE.md +1 -1
  184. package/scripts/dev_env_scripts_constants/claude_chain_constants.py +9 -0
  185. package/scripts/dev_env_scripts_constants/code_review_constants.py +37 -0
  186. package/scripts/invoke_code_review.py +11 -4
  187. package/scripts/resolve_worker_spawn.py +626 -626
  188. package/scripts/spawn_grok_batch.py +672 -672
  189. package/scripts/sync_to_cursor/rules.py +0 -10
  190. package/scripts/test_claude_chain_runner.py +131 -0
  191. package/scripts/test_invoke_code_review.py +85 -908
  192. package/scripts/test_invoke_code_review_chain.py +70 -0
  193. package/scripts/test_invoke_code_review_cli.py +192 -0
  194. package/scripts/test_invoke_code_review_contract.py +256 -0
  195. package/scripts/test_invoke_code_review_git.py +123 -0
  196. package/scripts/test_invoke_code_review_mode.py +99 -0
  197. package/scripts/test_resolve_worker_spawn.py +1014 -1014
  198. package/scripts/tests/test_code_review_constants.py +80 -0
  199. package/scripts/tests/test_codex_capability_bridge.py +91 -0
  200. package/scripts/tests/test_codex_compat_materializer.py +632 -0
  201. package/scripts/tests/test_codex_compat_watcher.py +599 -0
  202. package/scripts/tests/test_sync_to_cursor.py +0 -1
  203. package/skills/CLAUDE.md +2 -0
  204. package/skills/auditing-claude-config/SKILL.md +114 -114
  205. package/skills/autoconverge/SKILL.md +427 -427
  206. package/skills/autoconverge/reference/convergence.md +24 -3
  207. package/skills/autoconverge/workflow/CLAUDE.md +1 -0
  208. package/skills/autoconverge/workflow/converge.clean-audit.test.mjs +3 -3
  209. package/skills/autoconverge/workflow/converge.contract.test.mjs +1263 -1263
  210. package/skills/autoconverge/workflow/converge.mjs +168 -1
  211. package/skills/autoconverge/workflow/converge.p2-advance.test.mjs +202 -0
  212. package/skills/autoconverge/workflow/fixtures/wf_run/subagents/workflows/wf_881252e6-700/agent-a11d903476b803493.jsonl +2 -2
  213. package/skills/autoconverge/workflow/fixtures/wf_run/subagents/workflows/wf_881252e6-700/agent-a26213978adeef6fb.jsonl +2 -2
  214. package/skills/autoconverge/workflow/fixtures/wf_run/subagents/workflows/wf_881252e6-700/agent-a3def0d15ed9d9110.jsonl +2 -2
  215. package/skills/autoconverge/workflow/fixtures/wf_run/subagents/workflows/wf_881252e6-700/agent-a41f41b1b708ee3b7.jsonl +2 -2
  216. package/skills/autoconverge/workflow/fixtures/wf_run/subagents/workflows/wf_881252e6-700/agent-a758b880abecc3ff7.jsonl +2 -2
  217. package/skills/autoconverge/workflow/fixtures/wf_run/subagents/workflows/wf_881252e6-700/agent-a8897b89656b1bd16.jsonl +2 -2
  218. package/skills/autoconverge/workflow/fixtures/wf_run/subagents/workflows/wf_881252e6-700/agent-abd463d744a1437bc.jsonl +2 -2
  219. package/skills/autoconverge/workflow/fixtures/wf_run/subagents/workflows/wf_881252e6-700/agent-ad19d027ae8ee1816.jsonl +2 -2
  220. package/skills/autoconverge/workflow/fixtures/wf_run/workflows/wf_881252e6-700.json +265 -265
  221. package/skills/bugteam/reference/copilot-gap-analysis.md +1 -1
  222. package/skills/closeout/SKILL.md +33 -50
  223. package/skills/codex-review/scripts/codex_review_scripts_constants/run_constants.py +8 -0
  224. package/skills/codex-review/scripts/run_codex_review.py +233 -1
  225. package/skills/codex-review/scripts/test_run_codex_review.py +189 -0
  226. package/skills/condensing-instructions/SKILL.md +72 -0
  227. package/skills/copilot-review/SKILL.md +119 -119
  228. package/skills/e-code-review/SKILL.md +52 -0
  229. package/skills/e-code-review/reference/fix.md +54 -0
  230. package/skills/e-code-review/reference/loop.md +43 -0
  231. package/skills/e-code-review/reference/low.md +57 -0
  232. package/skills/e-code-review/reference/medium.md +153 -0
  233. package/skills/e-code-review/reference/xhigh.md +182 -0
  234. package/skills/e-simplify/SKILL.md +97 -0
  235. package/skills/fresh-branch/CLAUDE.md +1 -1
  236. package/skills/fresh-branch/SKILL.md +5 -6
  237. package/skills/fresh-branch/scripts/create_fresh_branch.py +42 -24
  238. package/skills/fresh-branch/scripts/fresh_branch_scripts_constants/fresh_branch_cli_constants.py +1 -3
  239. package/skills/fresh-branch/scripts/test_create_fresh_branch.py +30 -126
  240. package/skills/issue-tracker/SKILL.md +92 -0
  241. package/skills/issue-tracker/reference/epic-and-sub-issue-model.md +55 -0
  242. package/skills/issue-tracker/reference/handoff-schema.md +64 -0
  243. package/skills/issue-tracker/reference/operation-matrix.md +41 -0
  244. package/skills/orchestrator/SKILL.md +177 -22
  245. package/skills/orchestrator/scripts/status_gate.py +625 -0
  246. package/skills/orchestrator/scripts/status_gate_constants/__init__.py +1 -0
  247. package/skills/orchestrator/scripts/status_gate_constants/config/__init__.py +1 -0
  248. package/skills/orchestrator/scripts/status_gate_constants/config/constants.py +47 -0
  249. package/skills/orchestrator/scripts/test_status_gate.py +439 -0
  250. package/skills/orchestrator-refresh/SKILL.md +129 -35
  251. package/skills/plan-to-pr/SKILL.md +155 -0
  252. package/skills/plan-to-pr/reference/final-validation-tasks.md +15 -0
  253. package/skills/plan-to-pr/reference/model-routing.md +36 -0
  254. package/skills/plan-to-pr/reference/packet-contract.md +43 -0
  255. package/skills/plan-to-pr/reference/packet-schema.json +57 -0
  256. package/skills/plan-to-pr/reference/process-inventory.md +22 -0
  257. package/skills/plan-to-pr/reference/review-loop.md +33 -0
  258. package/skills/plan-to-pr/reference/run-record.schema.json +27 -0
  259. package/skills/plan-to-pr/reference/self-audit-tasks.md +15 -0
  260. package/skills/plan-to-pr/reference/task-seeds.md +14 -0
  261. package/skills/plan-to-pr/reference/task-ticket.md +38 -0
  262. package/skills/plan-to-pr/scripts/config/__init__.py +1 -0
  263. package/skills/plan-to-pr/scripts/config/constants.py +193 -0
  264. package/skills/plan-to-pr/scripts/create_packet.py +173 -0
  265. package/skills/plan-to-pr/scripts/test_create_packet.py +102 -0
  266. package/skills/plan-to-pr/scripts/test_validate_packet.py +256 -0
  267. package/skills/plan-to-pr/scripts/test_validate_protocol.py +135 -0
  268. package/skills/plan-to-pr/scripts/test_validate_run.py +158 -0
  269. package/skills/plan-to-pr/scripts/validate_packet.py +655 -0
  270. package/skills/plan-to-pr/scripts/validate_protocol.py +622 -0
  271. package/skills/plan-to-pr/scripts/validate_run.py +173 -0
  272. package/skills/plan-to-pr/test_skill_contract.py +207 -0
  273. package/skills/plan-to-pr/test_task_ticket_contract.py +151 -0
  274. package/skills/pr-converge/SKILL.md +472 -469
  275. package/skills/pr-converge/reference/examples.md +3 -3
  276. package/skills/pr-converge/reference/fix-protocol.md +1 -1
  277. package/skills/pr-converge/reference/ground-rules.md +7 -4
  278. package/skills/pr-converge/reference/multi-pr-orchestration.md +4 -1
  279. package/skills/pr-converge/reference/per-tick.md +5 -5
  280. package/skills/pr-converge/reference/progress-checklist.md +1 -1
  281. package/skills/pr-converge/scripts/check_convergence_gates.py +279 -279
  282. package/skills/pr-converge/scripts/test_check_convergence_codex.py +507 -507
  283. package/skills/pr-converge/scripts/test_check_convergence_gates.py +84 -84
  284. package/skills/pr-converge/test_step5_host_branch.py +1 -1
  285. package/skills/pr-fix-protocol/SKILL.md +1 -1
  286. package/skills/privacy-hygiene/SKILL.md +68 -68
  287. package/skills/privacy-hygiene/reference/sweep-procedure.md +1 -1
  288. package/skills/prototype/workflows/promotion.md +1 -1
  289. package/skills/release-notes-html/SKILL.md +164 -0
  290. package/skills/session-log/SKILL.md +1 -1
  291. package/skills/task-build/CLAUDE.md +8 -7
  292. package/skills/task-build/SKILL.md +16 -8
  293. package/skills/task-build/reference/tool-routing.md +19 -0
  294. package/rules/claude-md-orphan-file.md +0 -28
  295. package/rules/cleanup-command-forms.md +0 -23
  296. package/rules/code-reviews.md +0 -11
  297. package/rules/env-var-table-code-drift.md +0 -10
  298. package/rules/gh-body-file.md +0 -5
  299. package/rules/gh-paginate.md +0 -3
  300. package/rules/hook-prose-matches-detector.md +0 -15
  301. package/rules/no-historical-clutter.md +0 -26
  302. package/rules/no-inline-destructive-literals.md +0 -9
  303. package/rules/no-justification-noise.md +0 -61
  304. package/rules/package-inventory-stale-entry.md +0 -25
  305. package/rules/right-sized-engineering.md +0 -28
  306. package/rules/self-contained-docs.md +0 -17
  307. package/rules/shell-invocation-policy.md +0 -5
  308. package/rules/tdd.md +0 -7
  309. package/skills/closeout/reference/issue-body-templates.md +0 -108
@@ -2,8 +2,9 @@
2
2
  name: closeout
3
3
  description: >-
4
4
  Harvests session obstacles into GitHub issues backed by quoted evidence:
5
- validates each draft with the user, dedupes against open and closed issues,
6
- files children under a parent checklist, and prints a cloud handoff prompt.
5
+ validates each draft with the user, then delegates filing to the issue-tracker
6
+ agent (skill fallback), which dedupes against open and closed issues and files
7
+ each under an epic as a native sub-issue. Prints a cloud handoff prompt.
7
8
  Triggers: /closeout, close out this session, close out the session, file the
8
9
  session obstacles, session closeout, harvest session obstacles, end-of-session
9
10
  issue filing. Not session-log (vault journal) — closeout files issues and
@@ -12,18 +13,20 @@ description: >-
12
13
 
13
14
  # Closeout
14
15
 
15
- **Core principle:** At the end of a working session, turn the obstacles the session hit into user-approved GitHub issues — each backed by a quoted line — plus a computed cloud handoff prompt, never guessing, never filing without approval, never touching the host repo's live pipeline.
16
+ **Core principle:** At the end of a working session, turn the obstacles the session hit into user-approved GitHub issues — each backed by a quoted line — then hand each approved draft to the issue-tracker for filing, and print a computed cloud handoff prompt. Closeout never guesses, never files without approval, and never touches the host repo's live pipeline.
17
+
18
+ ## The delegation boundary
19
+
20
+ Closeout is the session-end entry point. It harvests obstacles, validates them with the user, and drafts one issue-candidate record per obstacle. The **issue-tracker** owns filing: dedup against open and closed issues, one epic per work-stream, native sub-issues, labels, and marker-delimited body sections. Closeout hands each approved record to the `issue-tracker` **agent** (primary), falling back to the `issue-tracker` **skill** when the agent is unavailable. Closeout-filed issues run the full tracker path — the same as any other tracked issue. Closeout builds no separate filing layer.
16
21
 
17
22
  ## Gotchas
18
23
 
19
24
  Highest-signal content. Append a bullet each time a run fails in a new way.
20
25
 
21
26
  - An obstacle stated from memory drifts. File only what the session can quote word for word — the actual error text, the exact command, the exact log line. A candidate that cannot be quoted goes under "Unverified candidates" for the user to judge, never into a filed issue as fact.
22
- - Skipping the confirmation gate files noise to a shared server that other people read. Every parent and child draft passes the AskUserQuestion gate before any write.
23
- - A body that leans on chat context reads as a puzzle to anyone who opens the issue cold. Write each body so a reader with zero session context acts on it: name the failure, the count, and the quoted line.
24
- - A `--body` string mangles backticks on GitHub they land as literal `\``. Every `gh` create and comment uses `--body-file <path>`.
25
- - A dedupe search that skips closed issues re-files a twin the team already resolved. The search covers `--state all`.
26
- - A volatile path in an issue body breaks the moment the job scratch is cleaned. Keep temp dirs, worktrees, and `$CLAUDE_JOB_DIR` out of every body.
27
+ - Skipping the confirmation gate files noise to a shared server that other people read. Every draft passes the AskUserQuestion gate before closeout hands it to the tracker.
28
+ - A body that leans on chat context reads as a puzzle to anyone who opens the issue cold. Write each record so a reader with zero session context acts on it: name the failure, the count, and the quoted line.
29
+ - A volatile path in an issue body breaks the moment the job scratch is cleaned. Keep temp dirs, worktrees, and `$CLAUDE_JOB_DIR` out of every record.
27
30
 
28
31
  ## When this skill applies
29
32
 
@@ -40,21 +43,18 @@ Triggers: `/closeout`, "close out this session", "file the session obstacles", "
40
43
  ## The process
41
44
 
42
45
  ```
43
- - [ ] Phase 1 — Harvest obstacles from the three sources; quote verbatim evidence
44
- - [ ] Phase 1Run the PII pass over every candidate
45
- - [ ] Phase 2Draft the parent + children set
46
- - [ ] Phase 2Confirmation gate: AskUserQuestion, file only on approval
47
- - [ ] Phase 3Dedupe each candidate against open and closed issues
48
- - [ ] Phase 4 — Route each issue to its repo
49
- - [ ] Phase 5 — File children, then the parent checklist
50
- - [ ] Phase 6 — Print the computed cloud handoff prompt in chat
46
+ - [ ] Phase 1 — Harvest obstacles from the three sources; quote verbatim evidence; run the PII pass
47
+ - [ ] Phase 2Draft one issue-candidate record per obstacle; confirmation gate via AskUserQuestion
48
+ - [ ] Phase 3Route each candidate to its repo
49
+ - [ ] Phase 4Hand each approved record to the issue-tracker; collect numbers and URLs
50
+ - [ ] Phase 5Print the computed cloud handoff prompt in chat
51
51
  ```
52
52
 
53
53
  ### Track the phases on the task list
54
54
 
55
- At invocation, copy the six phases onto the session task list — one task each via TaskCreate: harvest, draft + user validation, dedupe, repo routing, filing, handoff prompt. Mark a task `in_progress` with TaskUpdate when its phase starts and `completed` when the phase finishes.
55
+ At invocation, copy the five phases onto the session task list — one task each via TaskCreate: harvest, draft plus user validation, repo routing, tracker hand-off, handoff prompt. Mark a task `in_progress` with TaskUpdate when its phase starts and `completed` when the phase finishes.
56
56
 
57
- Hold one line on the filing task: never mark it `completed` while any planned issue is still uncreated. A filing phase that lands fewer issues than the approved set keeps the filing task open, with the missing issues named on it.
57
+ Hold one line on the hand-off task: never mark it `completed` while any approved record has no issue number back from the tracker. A hand-off that lands fewer issues than the approved set keeps the task open, with the missing records named on it.
58
58
 
59
59
  ### Phase 1 — Harvest obstacles
60
60
 
@@ -68,36 +68,22 @@ An obstacle is a hook block, a gate that fired wrongly, a tool that failed, a fo
68
68
 
69
69
  **Non-negotiable evidence rule:** every filed issue quotes verbatim evidence captured this session — the actual error text, the exact command, the exact log line. An obstacle you cannot quote is dropped, or listed under a "Unverified candidates" section of the drafts for the user to decide. It is never filed as fact.
70
70
 
71
- **PII pass (runs on every run):** strip personal data from every issue body and from the handoff prompt — emails, real names, home paths, private hosts and IPs, account ids, tokens. The pass runs whether the target repo is public or private; repository visibility changes only how aggressive the redaction is (public repos get the strictest pass), never whether the pass runs. Checklist and swaps: [reference/pii-redaction-checklist.md](reference/pii-redaction-checklist.md).
71
+ **PII pass (runs on every run):** strip personal data from every record and from the handoff prompt — emails, real names, home paths, private hosts and IPs, account ids, tokens. The pass runs whether the target repo is public or private; repository visibility changes only how aggressive the redaction is (public repos get the strictest pass), never whether the pass runs. Checklist and swaps: [reference/pii-redaction-checklist.md](reference/pii-redaction-checklist.md).
72
72
 
73
73
  ### Phase 2 — Draft and validate with the user
74
74
 
75
- Build the parent children issue set as drafts. Body shapes and worked examples: [reference/issue-body-templates.md](reference/issue-body-templates.md).
75
+ Build one **issue-candidate record** per obstacle, in the tracker's handoff shape: `kind`, `title`, `epic`, `summary`, `evidence`, `where`, `impact`, `proposed_fix`, `blocking`. Field meanings and a filled example: the issue-tracker skill's handoff schema (`skills/issue-tracker/reference/handoff-schema.md`). Group related obstacles under one `epic` label so the tracker files them as sub-issues of a shared epic.
76
76
 
77
77
  Then the **mandatory confirmation gate**. Present through AskUserQuestion:
78
78
 
79
- - Each drafted parent and child — title, target repo, one line of scope each.
79
+ - Each drafted record — title, target repo, `epic`, one line of scope.
80
80
  - Any PII concern the pass found.
81
- - Any closed twin a dedupe search surfaced (see Phase 3), as a reopen/comment/file-new choice.
82
-
83
- Filing to GitHub is an irreversible write to a shared server that other people read. File only on explicit user approval. The user validates every finding before anything is posted.
84
-
85
- ### Phase 3 — Dedupe
86
-
87
- Before filing each candidate, search open and closed issues on the target repo:
88
-
89
- ```
90
- gh issue list --search "<terms>" --state all
91
- ```
92
-
93
- - **Open twin exists** → comment on it, rather than filing a new issue.
94
- - **Closed twin exists** → do not silently file or comment. Surface it in the Phase 2 gate as "previously closed twin — reopen, comment, or file new" for the user to decide.
95
81
 
96
- `gh issue list` needs no pagination flags. If you show a `gh api` read of a paginated list endpoint anywhere, show `--paginate --slurp` piped to external `jq` `gh`'s built-in `--jq` runs per page and gives wrong cross-page results.
82
+ Filing to GitHub is an irreversible write to a shared server that other people read. Hand a record to the tracker only on explicit user approval. The user validates every finding before anything is posted.
97
83
 
98
- ### Phase 4 — Repo routing
84
+ ### Phase 3 — Repo routing
99
85
 
100
- Route each issue by a deterministic rule:
86
+ Route each candidate by a deterministic rule, and pass the resolved repo to the tracker:
101
87
 
102
88
  - The evidence names a file under the dev-env tree — `packages/claude-dev-env/hooks/`, `rules/`, `skills/`, `commands/`, `agents/`, `bin/`, or `docs/` — or an installed copy of those under `~/.claude/` (hooks, rules, skills, commands, agents) → file against **claude-dev-env**.
103
89
  - Otherwise → the **working repo**, read live:
@@ -106,17 +92,15 @@ Route each issue by a deterministic rule:
106
92
  gh repo view --json nameWithOwner
107
93
  ```
108
94
 
109
- - **Cross-repo case** — a hook shipped by repo B blocked work in repo A → file against **B** and reference A in the body.
95
+ - **Cross-repo case** — a hook shipped by repo B blocked work in repo A → file against **B** and name A in the record.
110
96
 
111
- ### Phase 5File
97
+ ### Phase 4Hand off to the issue-tracker
112
98
 
113
- File **children first**, then the parent. The parent body is a checklist of `- [ ] owner/repo#N` lines, one per child created.
99
+ For each approved record, hand it to the `issue-tracker` **agent** (primary) with the resolved repo, one record per agent call. When the agent is unavailable, load the `issue-tracker` **skill** and run the same op inline. The tracker runs the full path for each record: dedup open and closed issues, find or create the epic, create the sub-issue, apply labels, attach the native sub-issue, and refresh the epic checklist. It returns the issue number and URL, which closeout collects for the handoff prompt.
114
100
 
115
- - Every `gh issue create` and `gh issue comment` uses `--body-file <path>`, never `--body`.
116
- - No volatile paths in any body: no temp dirs, no worktrees, no `$CLAUDE_JOB_DIR`, no `.claude-editor/jobs` or `.claude/worktrees` paths.
117
- - Bodies are self-contained and specific: the failure mode, the count, and the quoted line — not "improve error handling".
101
+ A closed twin the tracker's dedup surfaces comes back for a reopen-or-file-new decision. Route that decision through the same AskUserQuestion gate before the tracker proceeds.
118
102
 
119
- ### Phase 6 — Computed handoff prompt
103
+ ### Phase 5 — Computed handoff prompt
120
104
 
121
105
  Print **in chat** (not a file) a prompt the user pastes into a cloud session that opens PRs for the filed issues and drives them to convergence. Template and worked example: [reference/handoff-prompt-template.md](reference/handoff-prompt-template.md).
122
106
 
@@ -134,18 +118,17 @@ This skill runs inside a repo whose pipeline is live in production. Hold these l
134
118
 
135
119
  - It never runs that pipeline, never merges, never deploys, never syncs.
136
120
  - It never runs the host repo's automations.
137
- - It creates issues and comments and prints text. Nothing else.
121
+ - It harvests obstacles, validates them, delegates filing to the tracker, and prints text. Nothing else.
138
122
 
139
123
  ## File index
140
124
 
141
125
  | File | Purpose |
142
126
  |------|---------|
143
- | `SKILL.md` | This hub — core principle, gotchas, refusal cases, six-phase process, boundaries |
144
- | `reference/issue-body-templates.md` | Parent and child issue body shapes with worked examples |
127
+ | `SKILL.md` | This hub — core principle, delegation boundary, gotchas, refusal cases, five-phase process, boundaries |
145
128
  | `reference/pii-redaction-checklist.md` | The PII pass: categories, swaps, public-versus-private aggression |
146
129
  | `reference/handoff-prompt-template.md` | The computed cloud handoff prompt shape with a worked example |
147
130
 
148
131
  ## Folder map
149
132
 
150
- - `SKILL.md` — hub: principle, gotchas, refusal, six-phase process, boundaries.
151
- - `reference/` — issue body templates, PII redaction checklist, handoff prompt template.
133
+ - `SKILL.md` — hub: principle, delegation boundary, gotchas, refusal, five-phase process, boundaries.
134
+ - `reference/` — PII redaction checklist, handoff prompt template.
@@ -24,6 +24,14 @@ OUTCOME_CLASS_COMPLETED = "completed"
24
24
  MISSING_BINARY_EXIT_CODE = 127
25
25
  TIMEOUT_EXIT_CODE = 124
26
26
  SUBPROCESS_DECODE_EXIT_CODE = 70
27
+ CWD_KEYWORD = "cwd"
28
+ CAPTURE_STREAMS_KEYWORD = "capture_output"
29
+ TEXT_MODE_KEYWORD = "text"
30
+ ENCODING_KEYWORD = "encoding"
31
+ CHECK_KEYWORD = "check"
32
+ TIMEOUT_KEYWORD = "timeout"
33
+ ENVIRONMENT_KEYWORD = "env"
34
+ PROCESS_TREE_KILL_TIMEOUT_SECONDS = 10
27
35
  SHAPE_FLAG_TOKEN_TAIL_PATTERN = r"(?![\w-])"
28
36
  JSONL_EVENT_TYPE_KEY = "type"
29
37
  JSONL_ENTRY_KEY = "item"
@@ -18,6 +18,7 @@ import json
18
18
  import os
19
19
  import re
20
20
  import shutil
21
+ import signal
21
22
  import subprocess
22
23
  from dataclasses import dataclass
23
24
  from pathlib import Path
@@ -27,15 +28,24 @@ from codex_review_scripts_constants.codex_usage_probe_constants import (
27
28
  WINDOWS_COMMAND_SHELL,
28
29
  WINDOWS_COMMAND_SHELL_RUN_FLAG,
29
30
  WINDOWS_OS_NAME,
31
+ WINDOWS_TASKKILL_COMMAND,
32
+ WINDOWS_TASKKILL_FORCE_FLAG,
33
+ WINDOWS_TASKKILL_PID_FLAG,
34
+ WINDOWS_TASKKILL_TREE_FLAG,
30
35
  )
31
36
  from codex_review_scripts_constants.run_constants import (
32
37
  ALL_SHAPE_PROBE_REQUIRED_FLAGS,
33
38
  BASE_TARGET_FLAG,
39
+ CAPTURE_STREAMS_KEYWORD,
40
+ CHECK_KEYWORD,
34
41
  CODEX_BINARY_NAME,
35
42
  CODEX_MODEL_PIN,
36
43
  COMMIT_TARGET_FLAG,
37
44
  CUSTOM_INSTRUCTIONS_PROMPT,
45
+ CWD_KEYWORD,
38
46
  DEFAULT_TIMEOUT_SECONDS,
47
+ ENCODING_KEYWORD,
48
+ ENVIRONMENT_KEYWORD,
39
49
  EXEC_SUBCOMMAND,
40
50
  HELP_FLAG,
41
51
  JSON_FLAG,
@@ -50,17 +60,239 @@ from codex_review_scripts_constants.run_constants import (
50
60
  MODEL_FLAG,
51
61
  OUTCOME_CLASS_CODEX_DOWN,
52
62
  OUTCOME_CLASS_COMPLETED,
63
+ PROCESS_TREE_KILL_TIMEOUT_SECONDS,
53
64
  REVIEW_SUBCOMMAND,
54
65
  SHAPE_FLAG_TOKEN_TAIL_PATTERN,
55
66
  SUBPROCESS_DECODE_EXIT_CODE,
67
+ TEXT_MODE_KEYWORD,
56
68
  TIMEOUT_EXIT_CODE,
69
+ TIMEOUT_KEYWORD,
57
70
  UNCOMMITTED_TARGET_FLAG,
58
71
  UTF8_ENCODING,
59
72
  VERSION_FLAG,
60
73
  VERSION_PROBE_PATTERN,
61
74
  )
62
75
 
63
- codex_subprocess_runner = subprocess.run
76
+
77
+ def _kill_windows_process_tree(process_identifier: int) -> None:
78
+ """Kill a Windows process and every descendant it started, by PID.
79
+
80
+ Swallows taskkill failures so the caller can fall back to ``Popen.kill()``
81
+ and a timed drain. A raised ``TimeoutExpired`` here would replace the
82
+ original review-timeout exception and skip that drain path.
83
+ """
84
+ try:
85
+ subprocess.run(
86
+ [
87
+ WINDOWS_TASKKILL_COMMAND,
88
+ WINDOWS_TASKKILL_TREE_FLAG,
89
+ WINDOWS_TASKKILL_FORCE_FLAG,
90
+ WINDOWS_TASKKILL_PID_FLAG,
91
+ str(process_identifier),
92
+ ],
93
+ stdout=subprocess.DEVNULL,
94
+ stderr=subprocess.DEVNULL,
95
+ check=False,
96
+ timeout=PROCESS_TREE_KILL_TIMEOUT_SECONDS,
97
+ )
98
+ except (subprocess.TimeoutExpired, OSError):
99
+ return
100
+
101
+
102
+ def _kill_posix_process_group(process_identifier: int) -> None:
103
+ """Kill a POSIX process group so no grandchild keeps the capture pipe open."""
104
+ try:
105
+ process_group_identifier = os.getpgid(process_identifier)
106
+ os.killpg(process_group_identifier, signal.SIGKILL)
107
+ except (ProcessLookupError, PermissionError):
108
+ return
109
+
110
+
111
+ def _terminate_process_tree(review_process: subprocess.Popen[str]) -> None:
112
+ """Kill the review process and every descendant it spawned.
113
+
114
+ Tree kill first (taskkill /T or killpg). When the direct child is still
115
+ alive after that, fall back to ``Popen.kill()`` so ``Popen.__exit__`` never
116
+ hits an unbounded wait on a surviving process.
117
+ """
118
+ if review_process.poll() is not None:
119
+ return
120
+ if os.name == WINDOWS_OS_NAME:
121
+ _kill_windows_process_tree(review_process.pid)
122
+ else:
123
+ _kill_posix_process_group(review_process.pid)
124
+ if review_process.poll() is not None:
125
+ return
126
+ try:
127
+ review_process.kill()
128
+ except ProcessLookupError:
129
+ return
130
+
131
+
132
+ def _open_codex_popen(
133
+ all_arguments: list[str],
134
+ all_keyword_arguments: dict[str, object],
135
+ ) -> subprocess.Popen[str]:
136
+ """Start the codex process in its own session so its tree can be killed."""
137
+ should_capture = bool(all_keyword_arguments.get(CAPTURE_STREAMS_KEYWORD, False))
138
+ stream_target = subprocess.PIPE if should_capture else None
139
+ working_directory = all_keyword_arguments.get(CWD_KEYWORD)
140
+ stream_encoding = all_keyword_arguments.get(ENCODING_KEYWORD)
141
+ process_environment = all_keyword_arguments.get(ENVIRONMENT_KEYWORD)
142
+ return subprocess.Popen(
143
+ all_arguments,
144
+ cwd=working_directory if isinstance(working_directory, str) else None,
145
+ stdout=stream_target,
146
+ stderr=stream_target,
147
+ text=bool(all_keyword_arguments.get(TEXT_MODE_KEYWORD, False)),
148
+ encoding=stream_encoding if isinstance(stream_encoding, str) else None,
149
+ env=process_environment if isinstance(process_environment, dict) else None,
150
+ start_new_session=os.name != WINDOWS_OS_NAME,
151
+ )
152
+
153
+
154
+ def _drain_process_after_tree_kill(
155
+ review_process: subprocess.Popen[str],
156
+ ) -> None:
157
+ """Join pipe reader threads after a tree kill; never block unbounded.
158
+
159
+ ::
160
+
161
+ kill tree -> communicate(grace) ok: pipe EOF, threads join
162
+ kill incomplete, no drain flag: Popen.__exit__ wait() hangs
163
+
164
+ Windows needs a post-kill ``communicate()`` so timed-out reader threads
165
+ join. Every platform needs a timed join so an incomplete tree kill cannot
166
+ leave ``Popen.__exit__`` waiting forever.
167
+ """
168
+ try:
169
+ review_process.communicate(timeout=PROCESS_TREE_KILL_TIMEOUT_SECONDS)
170
+ except subprocess.TimeoutExpired:
171
+ try:
172
+ review_process.kill()
173
+ except ProcessLookupError:
174
+ pass
175
+ try:
176
+ review_process.communicate(timeout=PROCESS_TREE_KILL_TIMEOUT_SECONDS)
177
+ except subprocess.TimeoutExpired:
178
+ return
179
+
180
+
181
+ def _communicate_with_tree_kill_on_timeout(
182
+ review_process: subprocess.Popen[str],
183
+ timeout_seconds: float | None,
184
+ ) -> tuple[str, str]:
185
+ """Drain the process; on timeout kill its whole tree, drain again, re-raise.
186
+
187
+ ::
188
+
189
+ codex -> codex.exe -> code-mode-host grandchildren hold the stdout pipe
190
+ kill only the parent on timeout drain waits for EOF, hangs
191
+ ok: kill the whole tree, then drain pipe reaches EOF, timeout raised
192
+
193
+ Killing only the direct child on a timeout leaves grandchildren holding the
194
+ capture pipe open, so the drain read never reaches end-of-file and blocks
195
+ forever. Killing the entire tree and then draining with a grace timeout lets
196
+ the pipe reach end-of-file without an unbounded wait.
197
+ """
198
+ try:
199
+ return review_process.communicate(timeout=timeout_seconds)
200
+ except subprocess.TimeoutExpired:
201
+ _terminate_process_tree(review_process)
202
+ _drain_process_after_tree_kill(review_process)
203
+ raise
204
+
205
+
206
+ def _completed_from_streams(
207
+ all_arguments: list[str],
208
+ review_return_code: int,
209
+ captured_stdout: str,
210
+ captured_stderr: str,
211
+ should_check: bool,
212
+ ) -> subprocess.CompletedProcess[str]:
213
+ """Wrap captured streams in a CompletedProcess, honoring the check flag."""
214
+ completed_process = subprocess.CompletedProcess(
215
+ all_arguments, review_return_code, captured_stdout, captured_stderr
216
+ )
217
+ if should_check:
218
+ completed_process.check_returncode()
219
+ return completed_process
220
+
221
+
222
+ def _optional_timeout_seconds(requested_timeout: object) -> float | None:
223
+ """Return a numeric timeout as float, or None when it is not a number."""
224
+ if isinstance(requested_timeout, (int, float)):
225
+ return float(requested_timeout)
226
+ return None
227
+
228
+
229
+ def _close_popen_streams(review_process: subprocess.Popen[str]) -> None:
230
+ """Close the child's stdio pipes without waiting for process exit."""
231
+ for each_stream in (review_process.stdout, review_process.stderr, review_process.stdin):
232
+ if each_stream is None:
233
+ continue
234
+ try:
235
+ each_stream.close()
236
+ except OSError:
237
+ pass
238
+
239
+
240
+ def _reap_process_with_grace(review_process: subprocess.Popen[str]) -> None:
241
+ """Kill and wait with a grace timeout so cleanup never blocks unbounded.
242
+
243
+ ::
244
+
245
+ poll None after incomplete drain ok: kill + wait(grace), then return
246
+ Popen.__exit__ wait() no timeout flag: hang forever on surviving child
247
+
248
+ ``Popen`` as a context manager always ends in ``wait()`` with no timeout.
249
+ After a timed-out drain that still leaves the child alive, that wait would
250
+ hang. This path kills once more and bounds the reap.
251
+ """
252
+ if review_process.poll() is not None:
253
+ return
254
+ try:
255
+ review_process.kill()
256
+ except ProcessLookupError:
257
+ return
258
+ try:
259
+ review_process.wait(timeout=PROCESS_TREE_KILL_TIMEOUT_SECONDS)
260
+ except subprocess.TimeoutExpired:
261
+ return
262
+
263
+
264
+ def _run_codex_process(
265
+ all_arguments: list[str],
266
+ **all_keyword_arguments: object,
267
+ ) -> subprocess.CompletedProcess[str]:
268
+ """Run a codex process, killing its whole child tree on a timeout.
269
+
270
+ Mirrors the ``subprocess.run`` keyword arguments the review wrapper passes:
271
+ ``cwd``, ``capture_output``, ``text``, ``encoding``, ``check``, ``timeout``,
272
+ and ``env``. Avoids ``Popen`` as a context manager so a surviving child after
273
+ a timed-out drain cannot hang on an unbounded ``wait()``.
274
+ """
275
+ timeout_seconds = _optional_timeout_seconds(all_keyword_arguments.get(TIMEOUT_KEYWORD))
276
+ should_check = bool(all_keyword_arguments.get(CHECK_KEYWORD, False))
277
+ review_process = _open_codex_popen(all_arguments, all_keyword_arguments)
278
+ try:
279
+ captured_stdout, captured_stderr = _communicate_with_tree_kill_on_timeout(
280
+ review_process, timeout_seconds
281
+ )
282
+ review_return_code = review_process.returncode
283
+ finally:
284
+ _close_popen_streams(review_process)
285
+ _reap_process_with_grace(review_process)
286
+ return _completed_from_streams(
287
+ all_arguments,
288
+ review_return_code,
289
+ captured_stdout,
290
+ captured_stderr,
291
+ should_check,
292
+ )
293
+
294
+
295
+ codex_subprocess_runner = _run_codex_process
64
296
 
65
297
 
66
298
  @dataclass(frozen=True)
@@ -5,6 +5,7 @@ from __future__ import annotations
5
5
  import json
6
6
  import subprocess
7
7
  import sys
8
+ import time
8
9
  from pathlib import Path
9
10
  from typing import Callable
10
11
 
@@ -810,3 +811,191 @@ def test_resolve_prefix_falls_back_to_bare_name_when_absent(
810
811
  monkeypatch.setattr(wrapper.shutil, "which", lambda _name: None)
811
812
 
812
813
  assert wrapper._resolve_codex_command_prefix() == [CODEX_BINARY_NAME]
814
+
815
+
816
+ def _is_process_running(process_identifier: int) -> bool:
817
+ if sys.platform == "win32":
818
+ process_listing = subprocess.run(
819
+ ["tasklist", "/FI", f"PID eq {process_identifier}"],
820
+ capture_output=True,
821
+ text=True,
822
+ check=False,
823
+ )
824
+ return str(process_identifier) in process_listing.stdout
825
+ liveness_probe = subprocess.run(
826
+ ["kill", "-0", str(process_identifier)],
827
+ capture_output=True,
828
+ check=False,
829
+ )
830
+ return liveness_probe.returncode == 0
831
+
832
+
833
+ def _wait_until_process_stops(
834
+ process_identifier: int, deadline_seconds: float
835
+ ) -> bool:
836
+ poll_interval_seconds = 0.5
837
+ deadline = time.monotonic() + deadline_seconds
838
+ while time.monotonic() < deadline:
839
+ if not _is_process_running(process_identifier):
840
+ return True
841
+ time.sleep(poll_interval_seconds)
842
+ return not _is_process_running(process_identifier)
843
+
844
+
845
+ def _grandchild_spawn_source(grandchild_pid_path: Path, lifetime_seconds: int) -> str:
846
+ """Python source that spawns a grandchild and records its PID before sleeping.
847
+
848
+ ::
849
+
850
+ Popen(grandchild) -> write grandchild.pid -> sleep grandchild holds pipe
851
+ """
852
+ return (
853
+ "import subprocess, sys, time, pathlib; "
854
+ "grandchild = subprocess.Popen([sys.executable, '-c', "
855
+ f"'import time; time.sleep({lifetime_seconds})']); "
856
+ f"pathlib.Path(r'{grandchild_pid_path}').write_text(str(grandchild.pid)); "
857
+ f"time.sleep({lifetime_seconds})"
858
+ )
859
+
860
+
861
+ def test_run_command_kills_grandchild_tree_on_timeout_without_hanging(
862
+ tmp_path: Path,
863
+ ) -> None:
864
+ """The timeout kills the grandchild that inherited the capture pipe.
865
+
866
+ ::
867
+
868
+ middle -> grandchild, timeout at 2s ok: grandchild PID stops running
869
+ kill only the middle child flag: grandchild lives on 30s
870
+
871
+ A direct-child-only kill leaves the grandchild alive on POSIX, so asserting
872
+ the recorded grandchild PID stops running guards the regression in CI.
873
+ """
874
+ grandchild_lifetime_seconds = 30
875
+ review_timeout_seconds = 2
876
+ wall_clock_ceiling_seconds = 20
877
+ grandchild_pid_path = tmp_path / "grandchild.pid"
878
+ grandchild_source = _grandchild_spawn_source(
879
+ grandchild_pid_path, grandchild_lifetime_seconds
880
+ )
881
+ start_time = time.monotonic()
882
+ with pytest.raises(subprocess.TimeoutExpired):
883
+ wrapper._run_command(
884
+ [sys.executable, "-c", grandchild_source],
885
+ working_directory=tmp_path,
886
+ timeout_seconds=review_timeout_seconds,
887
+ )
888
+ assert time.monotonic() - start_time < wall_clock_ceiling_seconds
889
+ grandchild_identifier = int(grandchild_pid_path.read_text())
890
+ assert _wait_until_process_stops(grandchild_identifier, wall_clock_ceiling_seconds)
891
+
892
+
893
+ def test_windows_process_tree_kill_builds_taskkill_argv(
894
+ monkeypatch: pytest.MonkeyPatch,
895
+ ) -> None:
896
+ """The Windows kill path issues taskkill /T /F /PID for the given process id."""
897
+ target_process_identifier = 4242
898
+ recorded_argv: list[list[str]] = []
899
+
900
+ def record_argv(all_arguments: list[str], **_keywords: object) -> None:
901
+ recorded_argv.append(all_arguments)
902
+
903
+ monkeypatch.setattr(wrapper.subprocess, "run", record_argv)
904
+ wrapper._kill_windows_process_tree(target_process_identifier)
905
+
906
+ assert recorded_argv == [
907
+ ["taskkill", "/T", "/F", "/PID", str(target_process_identifier)]
908
+ ]
909
+
910
+
911
+ def test_windows_process_tree_kill_swallows_taskkill_timeout(
912
+ monkeypatch: pytest.MonkeyPatch,
913
+ ) -> None:
914
+ """A hung taskkill must not raise — caller falls back to Popen.kill + drain."""
915
+
916
+ def raise_taskkill_timeout(
917
+ _all_arguments: list[str], **_keywords: object
918
+ ) -> None:
919
+ raise subprocess.TimeoutExpired(cmd="taskkill", timeout=1)
920
+
921
+ monkeypatch.setattr(wrapper.subprocess, "run", raise_taskkill_timeout)
922
+ wrapper._kill_windows_process_tree(4242)
923
+
924
+
925
+ def test_drain_joins_pipes_when_direct_kill_raises_process_lookup_error() -> None:
926
+ """Even when the process is already gone, drain still joins pipe readers."""
927
+ all_communicate_timeouts: list[float | None] = []
928
+
929
+ class _AlreadyDeadProcess:
930
+ def communicate(
931
+ self, timeout: float | None = None
932
+ ) -> tuple[str, str]:
933
+ all_communicate_timeouts.append(timeout)
934
+ if len(all_communicate_timeouts) == 1:
935
+ raise subprocess.TimeoutExpired(cmd="codex", timeout=timeout or 0)
936
+ return "", ""
937
+
938
+ def kill(self) -> None:
939
+ raise ProcessLookupError()
940
+
941
+ already_dead_process = _AlreadyDeadProcess()
942
+ wrapper._drain_process_after_tree_kill(
943
+ already_dead_process # type: ignore[arg-type] # duck-typed Popen stand-in
944
+ )
945
+ assert len(all_communicate_timeouts) == 2
946
+
947
+
948
+ def test_reap_process_with_grace_bounds_wait_when_child_survives() -> None:
949
+ """Cleanup kills a surviving child and waits with a grace timeout, not forever."""
950
+ all_wait_timeouts: list[float | None] = []
951
+ all_kill_calls: list[bool] = []
952
+
953
+ class _SurvivingProcess:
954
+ def poll(self) -> int | None:
955
+ return None
956
+
957
+ def kill(self) -> None:
958
+ all_kill_calls.append(True)
959
+
960
+ def wait(self, timeout: float | None = None) -> int:
961
+ all_wait_timeouts.append(timeout)
962
+ raise subprocess.TimeoutExpired(cmd="codex", timeout=timeout or 0)
963
+
964
+ surviving_process = _SurvivingProcess()
965
+ wrapper._reap_process_with_grace(
966
+ surviving_process # type: ignore[arg-type] # duck-typed Popen stand-in
967
+ )
968
+ assert all_kill_calls == [True]
969
+ assert all_wait_timeouts == [wrapper.PROCESS_TREE_KILL_TIMEOUT_SECONDS]
970
+
971
+
972
+ def test_run_command_surfaces_timeout_when_tree_kill_is_noop(
973
+ monkeypatch: pytest.MonkeyPatch,
974
+ tmp_path: Path,
975
+ ) -> None:
976
+ """An incomplete tree kill still raises TimeoutExpired inside a wall ceiling.
977
+
978
+ ::
979
+
980
+ terminate is a no-op, child sleeps 60s ok: TimeoutExpired < 25s wall
981
+ no post-kill drain / fallback kill flag: hang past ceiling on wait
982
+
983
+ The post-kill grace drain and direct-child kill fallback keep the caller
984
+ from blocking forever when taskkill/killpg leave the direct child alive.
985
+ """
986
+ review_timeout_seconds = 1
987
+ wall_clock_ceiling_seconds = 25
988
+ sleep_source = "import time; time.sleep(60)"
989
+
990
+ def leave_process_alive(_review_process: object) -> None:
991
+ return None
992
+
993
+ monkeypatch.setattr(wrapper, "_terminate_process_tree", leave_process_alive)
994
+ start_time = time.monotonic()
995
+ with pytest.raises(subprocess.TimeoutExpired):
996
+ wrapper._run_command(
997
+ [sys.executable, "-c", sleep_source],
998
+ working_directory=tmp_path,
999
+ timeout_seconds=review_timeout_seconds,
1000
+ )
1001
+ assert time.monotonic() - start_time < wall_clock_ceiling_seconds