claude-dev-env 2.3.0 → 2.5.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.
- package/CLAUDE.md +53 -48
- package/_shared/pr-loop/scripts/_claude_permissions_common.py +84 -0
- package/_shared/pr-loop/scripts/code_rules_gate.py +4 -2
- package/_shared/pr-loop/scripts/grant_project_claude_permissions.py +306 -306
- package/_shared/pr-loop/scripts/pr_loop_shared_constants/claude_permissions_constants.py +44 -0
- package/_shared/pr-loop/scripts/pr_loop_shared_constants/copilot_quota_constants.py +24 -24
- package/_shared/pr-loop/scripts/pr_loop_shared_constants/stale_worktree_rule_sweep_constants.py +107 -107
- package/_shared/pr-loop/scripts/revoke_project_claude_permissions.py +290 -48
- package/_shared/pr-loop/scripts/tests/test_claude_permissions_common.py +42 -2
- package/_shared/pr-loop/scripts/tests/test_claude_permissions_constants.py +36 -0
- package/_shared/pr-loop/scripts/tests/test_code_rules_gate.py +100 -1
- package/_shared/pr-loop/scripts/tests/test_fix_hookspath.py +497 -497
- package/_shared/pr-loop/scripts/tests/test_revoke_project_claude_permissions.py +311 -2
- package/_shared/pr-loop/scripts/tests/test_stale_worktree_rule_sweep.py +301 -301
- package/_shared/pr-loop/scripts/tests/test_stale_worktree_rule_sweep_constants.py +85 -85
- package/_shared/pr-loop/worker-spawn.md +1 -1
- package/agents/CLAUDE.md +3 -1
- package/agents/caveman.md +0 -1
- package/agents/clasp-deployment-orchestrator.md +0 -1
- package/agents/clean-coder.md +0 -1
- package/agents/code-advisor.md +0 -1
- package/agents/code-quality-agent.md +1 -2
- package/agents/code-verifier.md +3 -4
- package/agents/deep-research.md +0 -1
- package/agents/docs-agent.md +0 -1
- package/agents/git-commit-crafter.md +0 -1
- package/agents/issue-tracker.md +42 -0
- package/agents/plan-packet-validator.md +0 -1
- package/agents/pr-description-writer.md +0 -1
- package/agents/skill-writer-agent.md +84 -0
- package/agents/test_agent_frontmatter.py +67 -18
- package/audit-rubrics/category_rubrics/category-o-docstring-vs-impl-drift.md +105 -3
- package/audit-rubrics/prompts/category-o-docstring-vs-impl-drift.md +29 -13
- package/bin/CLAUDE.md +68 -5
- package/bin/ever-shipped-skills.mjs +1 -0
- package/bin/install-constants.mjs +88 -0
- package/bin/install.mjs +1138 -114
- package/bin/install.prune.test.mjs +869 -19
- package/bin/install.test.mjs +906 -2
- package/commands/implement.md +1 -1
- package/commands/right-size.md +1 -1
- package/docs/CLAUDE.md +2 -0
- package/docs/host-pool-health-monitor.md +102 -0
- package/docs/references/CLAUDE.md +5 -2
- package/docs/references/advisor-tool.md +13 -0
- package/docs/references/code-review-enforcement.md +107 -0
- package/docs/references/team-advisor-skill.md +14 -0
- package/docs/wsl-docker-cowork-starter-matrix.md +89 -0
- package/hooks/blocking/CLAUDE.md +9 -1
- package/hooks/blocking/code_review_enforcement_config_bootstrap.py +53 -0
- package/hooks/blocking/code_review_gate_deny.py +74 -0
- package/hooks/blocking/code_review_pr_create_gate.py +198 -0
- package/hooks/blocking/code_review_push_gate.py +145 -0
- package/hooks/blocking/code_review_stamp_directory_write_blocker.py +348 -0
- package/hooks/blocking/code_review_stamp_store.py +233 -0
- package/hooks/blocking/code_review_stamp_write_blocker_parts/__init__.py +7 -0
- package/hooks/blocking/code_review_stamp_write_blocker_parts/conftest.py +15 -0
- package/hooks/blocking/code_review_stamp_write_blocker_parts/obfuscated_stamp_path_reference.py +212 -0
- package/hooks/blocking/code_review_stamp_write_blocker_parts/split_directory_change_into_stamp.py +138 -0
- package/hooks/blocking/code_review_stamp_write_blocker_parts/test_obfuscated_stamp_path_reference.py +49 -0
- package/hooks/blocking/code_review_stamp_write_blocker_parts/test_split_directory_change_into_stamp.py +38 -0
- package/hooks/blocking/code_verifier_spawn_preflight_gate.py +39 -27
- package/hooks/blocking/config/__init__.py +5 -5
- package/hooks/blocking/config/code_review_enforcement_constants.py +113 -0
- package/hooks/blocking/config/test_code_review_enforcement_constants.py +113 -0
- package/hooks/blocking/config/verified_commit_constants.py +160 -155
- package/hooks/blocking/conftest.py +2 -0
- package/hooks/blocking/convergence_gate_blocker.py +112 -23
- package/hooks/blocking/destructive_command_blocker.py +19 -6
- package/hooks/blocking/orchestrator_refresh_reschedule_gate.py +256 -0
- package/hooks/blocking/pr_description_proof_of_work.py +52 -34
- package/hooks/blocking/pre_tool_use_dispatcher.py +24 -24
- package/hooks/blocking/test_bash_pre_tool_use_dispatcher.py +4 -1
- package/hooks/blocking/test_code_review_enforcement_config_bootstrap.py +62 -0
- package/hooks/blocking/test_code_review_gate_deny.py +54 -0
- package/hooks/blocking/test_code_review_pr_create_gate.py +199 -0
- package/hooks/blocking/test_code_review_push_gate.py +205 -0
- package/hooks/blocking/test_code_review_stamp_directory_write_blocker.py +199 -0
- package/hooks/blocking/test_code_review_stamp_store.py +205 -0
- package/hooks/blocking/test_code_verifier_spawn_preflight_gate.py +124 -2
- package/hooks/blocking/test_convergence_gate_blocker.py +153 -5
- package/hooks/blocking/test_destructive_command_blocker.py +1 -1
- package/hooks/blocking/test_destructive_command_blocker_deny_mode.py +45 -0
- package/hooks/blocking/test_orchestrator_refresh_reschedule_gate.py +231 -0
- package/hooks/blocking/test_pr_description_proof_of_work.py +151 -0
- package/hooks/blocking/test_pre_tool_use_dispatcher.py +17 -8
- package/hooks/blocking/test_verdict_directory_write_blocker.py +808 -808
- package/hooks/blocking/test_verification_verdict_store.py +974 -903
- package/hooks/blocking/test_verified_commit_gate.py +581 -581
- package/hooks/blocking/test_verified_commit_message_accuracy_blocker.py +131 -131
- package/hooks/blocking/test_volatile_path_in_post_blocker.py +114 -2
- package/hooks/blocking/verdict_directory_write_blocker.py +687 -687
- package/hooks/blocking/verification_verdict_store.py +1039 -1014
- package/hooks/blocking/verified_commit_gate_parts/gated_invocations.py +29 -17
- package/hooks/blocking/verified_commit_gate_parts/tests/test_gated_invocations.py +35 -0
- package/hooks/blocking/verified_commit_message_accuracy_blocker.py +167 -167
- package/hooks/blocking/verifier_verdict_minter.py +280 -280
- package/hooks/blocking/volatile_path_in_post_blocker.py +69 -8
- package/hooks/git-hooks/git_hooks_constants/__init__.py +6 -0
- package/hooks/git-hooks/pre_push.py +89 -2
- package/hooks/git-hooks/test_pre_push.py +128 -0
- package/hooks/hooks.json +26 -1
- package/hooks/hooks_constants/CLAUDE.md +3 -1
- package/hooks/hooks_constants/bash_pre_tool_use_dispatcher_constants.py +8 -0
- package/hooks/hooks_constants/code_rules_path_utils_constants.py +1 -0
- package/hooks/hooks_constants/code_verifier_spawn_preflight_gate_constants.py +26 -11
- package/hooks/hooks_constants/convergence_gate_blocker_constants.py +20 -3
- package/hooks/hooks_constants/destructive_command_segment_constants.py +3 -1
- package/hooks/hooks_constants/enter_worktree_prefetch_constants.py +18 -18
- package/hooks/hooks_constants/orchestrator_refresh_reschedule_gate_constants.py +48 -0
- package/hooks/hooks_constants/pr_description_proof_of_work_constants.py +0 -4
- package/hooks/hooks_constants/pre_tool_use_dispatcher_constants.py +4 -0
- package/hooks/hooks_constants/pyproject_config_discovery_constants.py +16 -0
- package/hooks/hooks_constants/ruff_integration_constants.py +16 -0
- package/hooks/hooks_constants/test_bash_pre_tool_use_dispatcher_constants.py +24 -0
- package/hooks/hooks_constants/test_pre_tool_use_dispatcher_constants.py +6 -0
- package/hooks/hooks_constants/volatile_path_in_post_blocker_constants.py +8 -1
- package/hooks/lifecycle/enter_worktree_origin_prefetch.py +163 -146
- package/hooks/lifecycle/test_enter_worktree_origin_prefetch.py +185 -178
- package/hooks/pyproject.toml +1 -0
- package/hooks/validators/CLAUDE.md +2 -0
- package/hooks/validators/config/__init__.py +0 -0
- package/hooks/validators/config/directory_exemption_constants.py +183 -0
- package/hooks/validators/config/test_directory_exemption_constants.py +21 -0
- package/hooks/validators/conftest.py +4 -0
- package/hooks/validators/mypy_integration.py +63 -50
- package/hooks/validators/pyproject_config_discovery.py +101 -0
- package/hooks/validators/ruff_integration.py +257 -25
- package/hooks/validators/run_all_validators.py +223 -19
- package/hooks/validators/test_directory_exemption_constants.py +185 -0
- package/hooks/validators/test_mypy_integration.py +32 -0
- package/hooks/validators/test_pyproject_config_discovery.py +94 -0
- package/hooks/validators/test_python_antipattern_checks.py +110 -5
- package/hooks/validators/test_ruff_integration.py +160 -2
- package/hooks/validators/test_run_all_validators.py +140 -68
- package/hooks/validators/test_run_all_validators_config_discovery.py +123 -0
- package/hooks/validators/test_run_all_validators_pretooluse.py +159 -1
- package/package.json +10 -2
- package/rules/CLAUDE.md +1 -0
- package/rules/docstring-prose-matches-implementation.md +45 -67
- package/rules/durable-post-artifacts.md +7 -0
- package/rules/state-what-is.md +25 -0
- package/rules/verified-commit-gate-skip.md +1 -1
- package/scripts/CLAUDE.md +1 -0
- package/scripts/Capture-PoolHealth.ps1 +410 -0
- package/scripts/_code_review_test_support.py +404 -0
- package/scripts/claude_chain_runner.py +141 -1
- package/scripts/codec_forwarding_test_support.py +83 -0
- package/scripts/conftest.py +23 -0
- package/scripts/dev_env_scripts_constants/CLAUDE.md +2 -2
- package/scripts/dev_env_scripts_constants/claude_chain_constants.py +53 -1
- package/scripts/dev_env_scripts_constants/code_review_constants.py +129 -12
- package/scripts/dev_env_scripts_constants/test_code_review_constants.py +55 -0
- package/scripts/invoke_code_review.py +550 -38
- package/scripts/resolve_worker_spawn.py +626 -619
- package/scripts/spawn_grok_batch.py +672 -672
- package/scripts/test_claude_chain_runner.py +131 -0
- package/scripts/test_invoke_code_review_chain.py +70 -0
- package/scripts/test_invoke_code_review_cli.py +192 -0
- package/scripts/test_invoke_code_review_codec.py +77 -0
- package/scripts/test_invoke_code_review_contract.py +256 -0
- package/scripts/test_invoke_code_review_git.py +123 -0
- package/scripts/test_invoke_code_review_mode.py +99 -0
- package/scripts/test_resolve_worker_spawn.py +1014 -1014
- package/scripts/test_resolve_worker_spawn_codec.py +101 -0
- package/skills/CLAUDE.md +2 -0
- package/skills/auditing-claude-config/SKILL.md +114 -114
- package/skills/autoconverge/SKILL.md +427 -421
- package/skills/autoconverge/reference/convergence.md +26 -4
- package/skills/autoconverge/reference/multi-pr.md +6 -1
- package/skills/autoconverge/reference/stop-conditions.md +16 -10
- package/skills/autoconverge/workflow/CLAUDE.md +1 -0
- package/skills/autoconverge/workflow/converge.clean-audit.test.mjs +4 -4
- package/skills/autoconverge/workflow/converge.codex-gate.test.mjs +175 -3
- package/skills/autoconverge/workflow/converge.contract.test.mjs +1263 -1244
- package/skills/autoconverge/workflow/converge.mjs +191 -8
- package/skills/autoconverge/workflow/converge.p2-advance.test.mjs +202 -0
- package/skills/autoconverge/workflow/converge_multi.mjs +7 -3
- package/skills/autoconverge/workflow/converge_multi.run-input.test.mjs +5 -0
- package/skills/autoconverge/workflow/fixtures/wf_run/subagents/workflows/wf_881252e6-700/agent-a11d903476b803493.jsonl +2 -2
- package/skills/autoconverge/workflow/fixtures/wf_run/subagents/workflows/wf_881252e6-700/agent-a26213978adeef6fb.jsonl +2 -2
- package/skills/autoconverge/workflow/fixtures/wf_run/subagents/workflows/wf_881252e6-700/agent-a3def0d15ed9d9110.jsonl +2 -2
- package/skills/autoconverge/workflow/fixtures/wf_run/subagents/workflows/wf_881252e6-700/agent-a41f41b1b708ee3b7.jsonl +2 -2
- package/skills/autoconverge/workflow/fixtures/wf_run/subagents/workflows/wf_881252e6-700/agent-a758b880abecc3ff7.jsonl +2 -2
- package/skills/autoconverge/workflow/fixtures/wf_run/subagents/workflows/wf_881252e6-700/agent-a8897b89656b1bd16.jsonl +2 -2
- package/skills/autoconverge/workflow/fixtures/wf_run/subagents/workflows/wf_881252e6-700/agent-abd463d744a1437bc.jsonl +2 -2
- package/skills/autoconverge/workflow/fixtures/wf_run/subagents/workflows/wf_881252e6-700/agent-ad19d027ae8ee1816.jsonl +2 -2
- package/skills/autoconverge/workflow/fixtures/wf_run/workflows/wf_881252e6-700.json +265 -265
- package/skills/closeout/SKILL.md +33 -50
- package/skills/codex-review/scripts/codex_review_scripts_constants/run_constants.py +8 -0
- package/skills/codex-review/scripts/run_codex_review.py +233 -1
- package/skills/codex-review/scripts/test_run_codex_review.py +189 -0
- package/skills/condensing-instructions/SKILL.md +81 -0
- package/skills/copilot-review/SKILL.md +119 -119
- package/skills/e-code-review/SKILL.md +52 -0
- package/skills/e-code-review/reference/fix.md +54 -0
- package/skills/e-code-review/reference/loop.md +43 -0
- package/skills/e-code-review/reference/low.md +57 -0
- package/skills/e-code-review/reference/medium.md +153 -0
- package/skills/e-code-review/reference/xhigh.md +182 -0
- package/skills/e-simplify/SKILL.md +97 -0
- package/skills/fresh-branch/CLAUDE.md +2 -0
- package/skills/fresh-branch/SKILL.md +2 -0
- package/skills/fresh-branch/scripts/create_fresh_branch.py +78 -180
- package/skills/fresh-branch/scripts/fresh_branch_git_commands.py +285 -0
- package/skills/fresh-branch/scripts/fresh_branch_scripts_constants/fresh_branch_cli_constants.py +1 -0
- package/skills/fresh-branch/scripts/pytest.ini +4 -0
- package/skills/fresh-branch/scripts/test_create_fresh_branch.py +98 -0
- package/skills/fresh-branch/scripts/test_fresh_branch_git_commands.py +310 -0
- package/skills/issue-tracker/SKILL.md +92 -0
- package/skills/issue-tracker/reference/epic-and-sub-issue-model.md +55 -0
- package/skills/issue-tracker/reference/handoff-schema.md +64 -0
- package/skills/issue-tracker/reference/operation-matrix.md +41 -0
- package/skills/orchestrator/SKILL.md +162 -21
- package/skills/orchestrator/scripts/status_gate.py +625 -0
- package/skills/orchestrator/scripts/status_gate_constants/__init__.py +1 -0
- package/skills/orchestrator/scripts/status_gate_constants/config/__init__.py +1 -0
- package/skills/orchestrator/scripts/status_gate_constants/config/constants.py +47 -0
- package/skills/orchestrator/scripts/test_status_gate.py +439 -0
- package/skills/orchestrator-refresh/SKILL.md +110 -35
- package/skills/plan-to-pr/SKILL.md +155 -0
- package/skills/plan-to-pr/reference/final-validation-tasks.md +15 -0
- package/skills/plan-to-pr/reference/model-routing.md +36 -0
- package/skills/plan-to-pr/reference/packet-contract.md +43 -0
- package/skills/plan-to-pr/reference/packet-schema.json +57 -0
- package/skills/plan-to-pr/reference/process-inventory.md +22 -0
- package/skills/plan-to-pr/reference/review-loop.md +33 -0
- package/skills/plan-to-pr/reference/run-record.schema.json +27 -0
- package/skills/plan-to-pr/reference/self-audit-tasks.md +15 -0
- package/skills/plan-to-pr/reference/task-seeds.md +14 -0
- package/skills/plan-to-pr/reference/task-ticket.md +38 -0
- package/skills/plan-to-pr/scripts/config/__init__.py +1 -0
- package/skills/plan-to-pr/scripts/config/constants.py +193 -0
- package/skills/plan-to-pr/scripts/create_packet.py +173 -0
- package/skills/plan-to-pr/scripts/test_create_packet.py +102 -0
- package/skills/plan-to-pr/scripts/test_validate_packet.py +256 -0
- package/skills/plan-to-pr/scripts/test_validate_protocol.py +135 -0
- package/skills/plan-to-pr/scripts/test_validate_run.py +158 -0
- package/skills/plan-to-pr/scripts/validate_packet.py +655 -0
- package/skills/plan-to-pr/scripts/validate_protocol.py +622 -0
- package/skills/plan-to-pr/scripts/validate_run.py +173 -0
- package/skills/plan-to-pr/test_skill_contract.py +207 -0
- package/skills/plan-to-pr/test_task_ticket_contract.py +151 -0
- package/skills/pr-converge/SKILL.md +472 -469
- package/skills/pr-converge/reference/examples.md +3 -3
- package/skills/pr-converge/reference/fix-protocol.md +1 -1
- package/skills/pr-converge/reference/ground-rules.md +7 -4
- package/skills/pr-converge/reference/multi-pr-orchestration.md +4 -1
- package/skills/pr-converge/reference/per-tick.md +5 -5
- package/skills/pr-converge/reference/progress-checklist.md +1 -1
- package/skills/pr-converge/scripts/check_convergence_gates.py +279 -279
- package/skills/pr-converge/scripts/test_check_convergence_codex.py +507 -507
- package/skills/pr-converge/scripts/test_check_convergence_gates.py +84 -84
- package/skills/pr-converge/test_step5_host_branch.py +1 -1
- package/skills/pr-fix-protocol/SKILL.md +1 -1
- package/skills/privacy-hygiene/SKILL.md +68 -68
- package/skills/prototype/SKILL.md +86 -0
- package/skills/prototype/reference/honest-limitations.md +23 -0
- package/skills/prototype/reference/promotion-tasks.md +23 -0
- package/skills/prototype/scripts/build_sandbox_settings.py +249 -0
- package/skills/prototype/scripts/conftest.py +15 -0
- package/skills/prototype/scripts/launch_sandbox.py +205 -0
- package/skills/prototype/scripts/probe_sandbox_safety.py +311 -0
- package/skills/prototype/scripts/prototype_scripts_constants/__init__.py +1 -0
- package/skills/prototype/scripts/prototype_scripts_constants/config/__init__.py +0 -0
- package/skills/prototype/scripts/prototype_scripts_constants/config/build_sandbox_settings_constants.py +41 -0
- package/skills/prototype/scripts/prototype_scripts_constants/config/launch_sandbox_constants.py +23 -0
- package/skills/prototype/scripts/prototype_scripts_constants/config/probe_sandbox_safety_constants.py +45 -0
- package/skills/prototype/scripts/prototype_scripts_constants/config/prototype_common_constants.py +10 -0
- package/skills/prototype/scripts/test_build_sandbox_settings.py +275 -0
- package/skills/prototype/scripts/test_launch_sandbox.py +303 -0
- package/skills/prototype/scripts/test_probe_sandbox_safety.py +284 -0
- package/skills/prototype/workflows/promotion.md +27 -0
- package/skills/prototype/workflows/sandbox.md +35 -0
- package/skills/release-notes-html/SKILL.md +164 -0
- package/skills/skill-builder/CLAUDE.md +3 -3
- package/skills/skill-builder/SKILL.md +5 -5
- package/skills/skill-builder/references/CLAUDE.md +1 -1
- package/skills/skill-builder/references/delegation-map.md +3 -3
- package/skills/skill-builder/references/description-field.md +1 -1
- package/skills/skill-builder/references/skill-modularity.md +2 -3
- package/skills/skill-builder/workflows/CLAUDE.md +1 -1
- package/skills/skill-builder/workflows/improve-skill.md +1 -1
- package/skills/skill-builder/workflows/new-skill.md +2 -2
- package/skills/task-build/CLAUDE.md +8 -7
- package/skills/task-build/SKILL.md +16 -8
- package/skills/task-build/reference/tool-routing.md +19 -0
- package/skills/team-advisor/SKILL.md +2 -2
- package/scripts/test_invoke_code_review.py +0 -672
- package/skills/closeout/reference/issue-body-templates.md +0 -108
package/skills/closeout/SKILL.md
CHANGED
|
@@ -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,
|
|
6
|
-
|
|
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 —
|
|
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
|
|
23
|
-
- A body that leans on chat context reads as a puzzle to anyone who opens the issue cold. Write each
|
|
24
|
-
- A
|
|
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
|
|
45
|
-
- [ ] Phase
|
|
46
|
-
- [ ] Phase
|
|
47
|
-
- [ ] Phase
|
|
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 2 — Draft one issue-candidate record per obstacle; confirmation gate via AskUserQuestion
|
|
48
|
+
- [ ] Phase 3 — Route each candidate to its repo
|
|
49
|
+
- [ ] Phase 4 — Hand each approved record to the issue-tracker; collect numbers and URLs
|
|
50
|
+
- [ ] Phase 5 — Print 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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
|
84
|
+
### Phase 3 — Repo routing
|
|
99
85
|
|
|
100
|
-
Route each
|
|
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
|
|
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
|
|
97
|
+
### Phase 4 — Hand off to the issue-tracker
|
|
112
98
|
|
|
113
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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,
|
|
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,
|
|
151
|
-
- `reference/` —
|
|
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
|
-
|
|
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
|