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
@@ -0,0 +1,655 @@
1
+ """Validate a completed Plan-to-PR packet using only the Python standard library."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import argparse
6
+ import json
7
+ import re
8
+ import shlex
9
+ import sys
10
+ from pathlib import Path
11
+
12
+ if str(Path(__file__).parent) not in sys.path:
13
+ sys.path.insert(0, str(Path(__file__).parent))
14
+
15
+ from config.constants import (
16
+ COMMAND_SEPARATOR,
17
+ HANDOFF_ACCEPTANCE_PREFIX,
18
+ HANDOFF_ALLOWED_FILES_PREFIX,
19
+ HANDOFF_APPROVAL_LINE,
20
+ HANDOFF_PACKET_PREFIX,
21
+ HANDOFF_TASK_ORDER_PREFIX,
22
+ HANDOFF_TASK_LINE_PATTERN,
23
+ HANDOFF_TEST_PREFIX,
24
+ HANDOFF_VERIFICATION_PREFIX,
25
+ MARKDOWN_FIELD_SEPARATOR,
26
+ PACKET_VALIDATION_ERROR_EXIT_CODE,
27
+ PATH_TRAVERSAL_TOKEN,
28
+ REPRODUCIBILITY_BLOCK_PATTERN,
29
+ ALL_REQUIRED_PACKET_FILES,
30
+ SLUG_PATTERN,
31
+ ALL_SOURCE_FIELDS,
32
+ SOURCE_LOCATOR_PATTERN,
33
+ ALL_TASK_COMMAND_FIELDS,
34
+ ALL_TASK_FIELDS,
35
+ TASK_LINE_PREFIX,
36
+ UNRESOLVED_TEXT_PATTERN,
37
+ DOCS_DIRECTORY_NAME,
38
+ MARKDOWN_HEADING_TEMPLATE,
39
+ NEWLINE_SEPARATOR,
40
+ PACKET_DIRECTORY_MINIMUM_PARTS,
41
+ PACKET_PARENT_PARTS_SLICE_START,
42
+ PACKET_SLUG_PARTS_SLICE_END,
43
+ ALL_PACKET_FIELDS,
44
+ ALL_PACKET_REQUIRED_FIELDS,
45
+ ALL_TASK_CONTRACT_FIELDS,
46
+ ALL_VALIDATION_FIELDS,
47
+ )
48
+
49
+
50
+ def validate_packet(packet_directory: Path) -> list[str]:
51
+ """Return field-level diagnostics for a packet directory.
52
+
53
+ Args:
54
+ packet_directory: Packet directory to validate.
55
+ Returns:
56
+ Diagnostics, with an empty list for a valid packet.
57
+ """
58
+ errors: list[str] = []
59
+ errors.extend(_validate_packet_boundary(packet_directory))
60
+ errors.extend(_validate_required_files(packet_directory))
61
+ packet_payload, packet_errors = _read_packet_payload(packet_directory)
62
+ errors.extend(packet_errors)
63
+ if packet_payload is None:
64
+ return errors
65
+ errors.extend(_validate_packet_fields(packet_payload, packet_directory))
66
+ errors.extend(_validate_sources(packet_payload, packet_directory))
67
+ errors.extend(_validate_tasks(packet_payload, packet_directory))
68
+ errors.extend(_validate_markdown(packet_payload, packet_directory))
69
+ return errors
70
+
71
+
72
+ def _validate_packet_boundary(packet_directory: Path) -> list[str]:
73
+ """Ensure the argument names a repository packet under docs/plans."""
74
+ resolved_path = packet_directory.resolve()
75
+ parts = resolved_path.parts
76
+ if len(parts) < PACKET_DIRECTORY_MINIMUM_PARTS or parts[
77
+ PACKET_PARENT_PARTS_SLICE_START:PACKET_SLUG_PARTS_SLICE_END
78
+ ] != (
79
+ DOCS_DIRECTORY_NAME,
80
+ "plans",
81
+ ):
82
+ return ["packet path must be within docs/plans/<slug>"]
83
+ if not SLUG_PATTERN.fullmatch(parts[-1]):
84
+ return ["packet path slug must contain lowercase letters, numbers, and hyphens"]
85
+ return []
86
+
87
+
88
+ def _validate_required_files(packet_directory: Path) -> list[str]:
89
+ """Check the packet files required by the contract."""
90
+ return [
91
+ f"missing required file: {relative_path}"
92
+ for relative_path in ALL_REQUIRED_PACKET_FILES
93
+ if not (packet_directory / relative_path).is_file()
94
+ ]
95
+
96
+
97
+ def _read_packet_payload(
98
+ packet_directory: Path,
99
+ ) -> tuple[dict[str, object] | None, list[str]]:
100
+ """Read packet.json and report parse or top-level shape errors."""
101
+ packet_file = packet_directory / "packet.json"
102
+ if not packet_file.is_file():
103
+ return None, []
104
+ try:
105
+ parsed_packet: object = json.loads(packet_file.read_text(encoding="utf-8"))
106
+ except (OSError, json.JSONDecodeError) as packet_error:
107
+ return None, [f"packet.json: invalid JSON: {packet_error}"]
108
+ if not isinstance(parsed_packet, dict):
109
+ return None, ["packet.json: expected an object"]
110
+ return parsed_packet, []
111
+
112
+
113
+ def _validate_packet_fields(
114
+ all_packet: dict[str, object], packet_directory: Path
115
+ ) -> list[str]:
116
+ """Validate packet metadata and its repository-relative boundaries."""
117
+ errors = _validate_packet_field_names(all_packet)
118
+ errors.extend(_validate_packet_metadata(all_packet, packet_directory))
119
+ errors.extend(_validate_packet_lists(all_packet))
120
+ return errors
121
+
122
+
123
+ def _validate_packet_field_names(all_packet: dict[str, object]) -> list[str]:
124
+ """Report unknown and missing packet fields."""
125
+ errors = [
126
+ f"packet.json: unknown field: {each_field}"
127
+ for each_field in sorted(set(all_packet) - set(ALL_PACKET_FIELDS))
128
+ ]
129
+ errors.extend(
130
+ f"packet.json.{each_field}: required"
131
+ for each_field in ALL_PACKET_REQUIRED_FIELDS
132
+ if each_field not in all_packet
133
+ )
134
+ return errors
135
+
136
+
137
+ def _validate_packet_metadata(
138
+ all_packet: dict[str, object], packet_directory: Path
139
+ ) -> list[str]:
140
+ """Validate packet version, identity, status, and request."""
141
+ errors: list[str] = []
142
+ if all_packet.get("schema_version") != 1:
143
+ errors.append("packet.json.schema_version: must be 1")
144
+ if all_packet.get("slug") != packet_directory.name:
145
+ errors.append("packet.json.slug: must match the packet directory")
146
+ if all_packet.get("status") != "approved":
147
+ errors.append("packet.json.status: must be approved")
148
+ request = all_packet.get("request")
149
+ if not isinstance(request, str) or not request.strip():
150
+ errors.append("packet.json.request: must be a non-empty string")
151
+ errors.extend(
152
+ _validate_path_list(
153
+ all_packet.get("allowed_files"), "packet.json.allowed_files"
154
+ )
155
+ )
156
+ errors.extend(_validate_validation_record(all_packet.get("validation")))
157
+ return errors
158
+
159
+
160
+ def _validate_packet_lists(all_packet: dict[str, object]) -> list[str]:
161
+ """Validate packet decisions and resolved questions."""
162
+ errors: list[str] = []
163
+ decisions = all_packet.get("decisions")
164
+ if (
165
+ not isinstance(decisions, list)
166
+ or not decisions
167
+ or any(
168
+ not isinstance(each_decision, str) or not each_decision.strip()
169
+ for each_decision in decisions
170
+ )
171
+ ):
172
+ errors.append("packet.json.decisions: must be a non-empty string list")
173
+ open_questions = all_packet.get("open_questions")
174
+ if not isinstance(open_questions, list) or any(
175
+ not isinstance(each_question, str) or not each_question.strip()
176
+ for each_question in open_questions
177
+ ):
178
+ errors.append("packet.json.open_questions: must be a string list")
179
+ if all_packet.get("open_questions") != []:
180
+ errors.append("packet.json.open_questions: must be empty")
181
+ return errors
182
+
183
+
184
+ def _validate_path_list(all_paths: object, field_name: str) -> list[str]:
185
+ """Validate a repository-relative path list."""
186
+ if not isinstance(all_paths, list) or not all_paths:
187
+ return [f"{field_name}: must be a non-empty list"]
188
+ errors: list[str] = []
189
+ for each_index, each_path in enumerate(all_paths):
190
+ if (
191
+ not isinstance(each_path, str)
192
+ or not each_path.strip()
193
+ or Path(each_path).is_absolute()
194
+ or "\\" in each_path
195
+ or PATH_TRAVERSAL_TOKEN in each_path
196
+ ):
197
+ errors.append(
198
+ f"{field_name}[{each_index}]: must be a repository-relative path"
199
+ )
200
+ path_names = [each_path for each_path in all_paths if isinstance(each_path, str)]
201
+ if len(set(path_names)) != len(path_names):
202
+ errors.append(f"{field_name}: entries must be unique")
203
+ return errors
204
+
205
+
206
+ def _validate_validation_record(validation_record: object) -> list[str]:
207
+ """Validate the packet validation evidence fields."""
208
+ if not isinstance(validation_record, dict):
209
+ return ["packet.json.validation: must be an object"]
210
+ allowed_fields = set(ALL_VALIDATION_FIELDS)
211
+ errors = [
212
+ f"packet.json.validation: unknown field: {each_field}"
213
+ for each_field in sorted(set(validation_record) - allowed_fields)
214
+ ]
215
+ for each_field in ALL_VALIDATION_FIELDS[:-1]:
216
+ if validation_record.get(each_field) is not True:
217
+ errors.append(f"packet.json.validation.{each_field}: must be true")
218
+ if validation_record.get("validated_by") != "native-plan-to-pr":
219
+ errors.append("packet.json.validation.validated_by: must be native-plan-to-pr")
220
+ return errors
221
+
222
+
223
+ def _validate_sources(
224
+ all_packet: dict[str, object], packet_directory: Path
225
+ ) -> list[str]:
226
+ """Ensure every source reference points to a non-empty repository file."""
227
+ sources = all_packet.get("sources")
228
+ if not isinstance(sources, list) or not sources:
229
+ return ["packet.json.sources: must be a list"]
230
+ repository_root = packet_directory.parents[2]
231
+ errors: list[str] = []
232
+ for each_index, each_source in enumerate(sources):
233
+ if not isinstance(each_source, dict):
234
+ errors.append(f"packet.json.sources[{each_index}]: must be an object")
235
+ continue
236
+ allowed_fields = set(ALL_SOURCE_FIELDS)
237
+ errors.extend(
238
+ f"packet.json.sources[{each_index}]: unknown field: {each_field}"
239
+ for each_field in sorted(set(each_source) - allowed_fields)
240
+ )
241
+ for each_field in ALL_SOURCE_FIELDS:
242
+ if (
243
+ not isinstance(each_source.get(each_field), str)
244
+ or not each_source[each_field].strip()
245
+ ):
246
+ errors.append(
247
+ f"packet.json.sources[{each_index}].{each_field}: required non-empty string"
248
+ )
249
+ source_path = each_source.get("path")
250
+ if not isinstance(source_path, str):
251
+ continue
252
+ locator = each_source.get("locator")
253
+ if isinstance(locator, str) and not SOURCE_LOCATOR_PATTERN.fullmatch(
254
+ locator.strip()
255
+ ):
256
+ errors.append(
257
+ f"packet.json.sources[{each_index}].locator: must be line, lines, or section locator"
258
+ )
259
+ if "\\" in source_path or PATH_TRAVERSAL_TOKEN in source_path:
260
+ errors.append(
261
+ f"packet.json.sources[{each_index}].path: must be a repository-relative path"
262
+ )
263
+ continue
264
+ source_file = (repository_root / source_path).resolve()
265
+ if not _is_within(source_file, repository_root) or not source_file.is_file():
266
+ errors.append(
267
+ f"packet.json.sources[{each_index}].path: file does not exist"
268
+ )
269
+ elif not source_file.read_text(encoding="utf-8").strip():
270
+ errors.append(
271
+ f"packet.json.sources[{each_index}].path: source file is empty"
272
+ )
273
+ return errors
274
+
275
+
276
+ def _validate_tasks(all_packet: dict[str, object], packet_directory: Path) -> list[str]:
277
+ """Validate task identity, scope, dependencies, and commands."""
278
+ tasks = all_packet.get("tasks")
279
+ if not isinstance(tasks, list) or not tasks:
280
+ return ["packet.json.tasks: must be a non-empty list"]
281
+ allowed_files = all_packet.get("allowed_files", [])
282
+ all_task_ids: set[str] = set()
283
+ dependencies_by_task: dict[str, list[str]] = {}
284
+ errors: list[str] = []
285
+ for each_index, each_task in enumerate(tasks):
286
+ if not isinstance(each_task, dict):
287
+ errors.append(f"packet.json.tasks[{each_index}]: must be an object")
288
+ continue
289
+ allowed_fields = set(ALL_TASK_CONTRACT_FIELDS)
290
+ errors.extend(
291
+ f"packet.json.tasks[{each_index}]: unknown field: {each_field}"
292
+ for each_field in sorted(set(each_task) - allowed_fields)
293
+ )
294
+ task_id = each_task.get("id")
295
+ if not isinstance(task_id, str) or not re.fullmatch(r"task-[0-9]+", task_id):
296
+ errors.append(f"packet.json.tasks[{each_index}].id: must match task-N")
297
+ continue
298
+ if task_id in all_task_ids:
299
+ errors.append(f"packet.json.tasks[{each_index}].id: duplicate {task_id}")
300
+ all_task_ids.add(task_id)
301
+ for each_field in ALL_TASK_FIELDS:
302
+ if (
303
+ not isinstance(each_task.get(each_field), str)
304
+ or not each_task[each_field].strip()
305
+ ):
306
+ errors.append(
307
+ f"packet.json.tasks[{each_index}].{each_field}: required non-empty string"
308
+ )
309
+ task_files = each_task.get("allowed_files")
310
+ errors.extend(
311
+ _validate_path_list(
312
+ task_files, f"packet.json.tasks[{each_index}].allowed_files"
313
+ )
314
+ )
315
+ if isinstance(task_files, list) and isinstance(allowed_files, list):
316
+ errors.extend(
317
+ f"packet.json.tasks[{each_index}].allowed_files[{each_file_index}]: outside packet scope"
318
+ for each_file_index, each_path in enumerate(task_files)
319
+ if each_path not in allowed_files
320
+ )
321
+ if each_task.get("commit") != 1:
322
+ errors.append(f"packet.json.tasks[{each_index}].commit: must be 1")
323
+ for each_command_field in ALL_TASK_COMMAND_FIELDS:
324
+ command = each_task.get(each_command_field)
325
+ if isinstance(command, str) and not _is_reproducible_command(command):
326
+ errors.append(
327
+ f"packet.json.tasks[{each_index}].{each_command_field}: must be a concrete reproducible command"
328
+ )
329
+ dependencies_by_task[task_id] = _validate_dependencies(
330
+ each_task, task_id, all_task_ids, errors
331
+ )
332
+ errors.extend(_validate_dependency_cycles(dependencies_by_task))
333
+ return errors
334
+
335
+
336
+ def _validate_dependencies(
337
+ all_task: dict[str, object],
338
+ task_id: str,
339
+ all_task_ids: set[str],
340
+ all_errors: list[str],
341
+ ) -> list[str]:
342
+ """Validate one task's optional dependency list."""
343
+ dependencies = all_task.get("dependencies", [])
344
+ if not isinstance(dependencies, list):
345
+ all_errors.append(f"packet.json.tasks[{task_id}].dependencies: must be a list")
346
+ return []
347
+ dependency_names = [
348
+ each_dependency
349
+ for each_dependency in dependencies
350
+ if isinstance(each_dependency, str)
351
+ ]
352
+ if len(set(dependency_names)) != len(dependency_names):
353
+ all_errors.append(
354
+ f"packet.json.tasks[{task_id}].dependencies: entries must be unique"
355
+ )
356
+ valid_dependencies: list[str] = []
357
+ for each_dependency in dependencies:
358
+ if not isinstance(each_dependency, str) or not re.fullmatch(
359
+ r"task-[0-9]+", each_dependency
360
+ ):
361
+ all_errors.append(
362
+ f"packet.json.tasks[{task_id}].dependencies: invalid task ID"
363
+ )
364
+ elif each_dependency == task_id:
365
+ all_errors.append(
366
+ f"packet.json.tasks[{task_id}].dependencies: cannot depend on itself"
367
+ )
368
+ else:
369
+ valid_dependencies.append(each_dependency)
370
+ return valid_dependencies
371
+
372
+
373
+ def _validate_dependency_cycles(
374
+ dependencies_by_task: dict[str, list[str]],
375
+ ) -> list[str]:
376
+ """Return diagnostics for missing dependencies and directed cycles."""
377
+ errors = [
378
+ f"dependency references unknown task: {each_dependency}"
379
+ for all_dependencies in dependencies_by_task.values()
380
+ for each_dependency in all_dependencies
381
+ if each_dependency not in dependencies_by_task
382
+ ]
383
+ visiting: set[str] = set()
384
+ visited: set[str] = set()
385
+
386
+ def visit(task_id: str) -> None:
387
+ """Visit one dependency node.
388
+
389
+ Args:
390
+ task_id: Dependency node to inspect.
391
+ """
392
+ if task_id in visiting:
393
+ errors.append(f"dependency cycle detected at {task_id}")
394
+ return
395
+ if task_id in visited:
396
+ return
397
+ visiting.add(task_id)
398
+ for each_dependency in dependencies_by_task.get(task_id, []):
399
+ if each_dependency in dependencies_by_task:
400
+ visit(each_dependency)
401
+ visiting.remove(task_id)
402
+ visited.add(task_id)
403
+
404
+ for each_task_id in dependencies_by_task:
405
+ visit(each_task_id)
406
+ return errors
407
+
408
+
409
+ def _validate_markdown(
410
+ all_packet: dict[str, object], packet_directory: Path
411
+ ) -> list[str]:
412
+ """Validate required markdown sections and packet-wide unresolved text."""
413
+ errors = _validate_markdown_sections(packet_directory)
414
+ task_text = _read_markdown_file(packet_directory, "tasks.md")
415
+ all_tasks = all_packet.get("tasks", [])
416
+ if not isinstance(all_tasks, list):
417
+ return errors
418
+ errors.extend(_validate_task_markdown(all_tasks, task_text))
419
+ handoff_text = _read_markdown_file(packet_directory, "handoff.md")
420
+ errors.extend(_validate_handoff(handoff_text, all_packet, packet_directory.name))
421
+ return errors
422
+
423
+
424
+ def _validate_markdown_sections(packet_directory: Path) -> list[str]:
425
+ """Validate required markdown headings and unresolved text."""
426
+ errors: list[str] = []
427
+ sections_by_file = {
428
+ "context.md": (
429
+ "Request",
430
+ "Repository Facts",
431
+ "Constraints",
432
+ "Source References",
433
+ ),
434
+ "plan.md": ("Implementation", "Decisions", "Dependencies", "Risks"),
435
+ "tasks.md": ("Tasks",),
436
+ "handoff.md": ("Approval", "Task Order"),
437
+ }
438
+ for each_filename, each_headings in sections_by_file.items():
439
+ each_text = _read_markdown_file(packet_directory, each_filename)
440
+ for each_heading in each_headings:
441
+ if not re.search(
442
+ MARKDOWN_HEADING_TEMPLATE.format(re.escape(each_heading)),
443
+ each_text,
444
+ re.MULTILINE | re.IGNORECASE,
445
+ ):
446
+ errors.append(f"{each_filename}: missing section {each_heading}")
447
+ if _has_unresolved_text(each_text):
448
+ errors.append(
449
+ f"{each_filename}: contains unresolved questions or placeholders"
450
+ )
451
+ return errors
452
+
453
+
454
+ def _read_markdown_file(packet_directory: Path, filename: str) -> str:
455
+ """Read one packet markdown file or return empty text when absent."""
456
+ packet_file = packet_directory / filename
457
+ if not packet_file.is_file():
458
+ return ""
459
+ return packet_file.read_text(encoding="utf-8")
460
+
461
+
462
+ def _validate_task_markdown(all_tasks: list[object], task_text: str) -> list[str]:
463
+ """Ensure tasks.md repeats every task field and file."""
464
+ errors: list[str] = []
465
+ for each_task in all_tasks:
466
+ if (
467
+ isinstance(each_task, dict)
468
+ and isinstance(each_task.get("id"), str)
469
+ and each_task["id"] not in task_text
470
+ ):
471
+ errors.append(f"tasks.md: missing task {each_task['id']}")
472
+ if isinstance(each_task, dict):
473
+ task_id = each_task.get("id")
474
+ if not isinstance(task_id, str):
475
+ continue
476
+ for each_field_name in ALL_TASK_FIELDS:
477
+ field_text = each_task.get(each_field_name)
478
+ if isinstance(field_text, str):
479
+ is_field_present = (
480
+ _contains_exact_command(task_text, field_text)
481
+ if each_field_name in ALL_TASK_COMMAND_FIELDS
482
+ else field_text in task_text
483
+ )
484
+ else:
485
+ is_field_present = False
486
+ if not is_field_present:
487
+ errors.append(f"tasks.md: task {task_id} missing {each_field_name}")
488
+ all_task_files = each_task.get("allowed_files")
489
+ if isinstance(all_task_files, list):
490
+ for each_file_path in all_task_files:
491
+ if (
492
+ isinstance(each_file_path, str)
493
+ and each_file_path not in task_text
494
+ ):
495
+ errors.append(
496
+ f"tasks.md: task {task_id} missing file {each_file_path}"
497
+ )
498
+ return errors
499
+
500
+
501
+ def _contains_exact_command(markdown_text: str, command_text: str) -> bool:
502
+ """Return whether a task command appears as one complete Markdown field."""
503
+ return any(
504
+ each_segment.strip() == command_text.strip()
505
+ for each_line in markdown_text.splitlines()
506
+ for each_segment in each_line.split(MARKDOWN_FIELD_SEPARATOR)
507
+ )
508
+
509
+
510
+ def _validate_handoff(
511
+ handoff_text: str, all_packet: dict[str, object], packet_slug: str
512
+ ) -> list[str]:
513
+ """Ensure the host task seed restates the approved packet tasks exactly."""
514
+ all_handoff_lines = handoff_text.splitlines()
515
+ handoff_lines = set(all_handoff_lines)
516
+ errors = _validate_handoff_headers(handoff_lines, packet_slug)
517
+ all_tasks = all_packet.get("tasks", [])
518
+ if not isinstance(all_tasks, list):
519
+ return errors
520
+ all_task_ids = [
521
+ each_task["id"]
522
+ for each_task in all_tasks
523
+ if isinstance(each_task, dict) and isinstance(each_task.get("id"), str)
524
+ ]
525
+ if len(all_task_ids) != len(all_tasks):
526
+ return errors
527
+ declared_task_ids = _declared_handoff_task_ids(all_handoff_lines)
528
+ if declared_task_ids != all_task_ids:
529
+ errors.append("handoff.md: declared tasks do not match packet.json")
530
+ task_order_line = (
531
+ f"{HANDOFF_TASK_ORDER_PREFIX}{COMMAND_SEPARATOR.join(all_task_ids)}"
532
+ )
533
+ if task_order_line not in handoff_lines:
534
+ errors.append("handoff.md: task order does not match packet.json")
535
+ errors.extend(_validate_handoff_task_fields(handoff_lines, all_tasks))
536
+ return errors
537
+
538
+
539
+ def _validate_handoff_headers(
540
+ all_handoff_lines: set[str], packet_slug: str
541
+ ) -> list[str]:
542
+ """Return missing packet identity and approval diagnostics."""
543
+ required_lines = {
544
+ "packet slug": f"{HANDOFF_PACKET_PREFIX}{packet_slug}",
545
+ "approval state": HANDOFF_APPROVAL_LINE,
546
+ }
547
+ return [
548
+ f"handoff.md: missing {each_field_name}"
549
+ for each_field_name, each_required_line in required_lines.items()
550
+ if each_required_line not in all_handoff_lines
551
+ ]
552
+
553
+
554
+ def _declared_handoff_task_ids(all_handoff_lines: list[str]) -> list[str]:
555
+ """Return task identifiers declared by handoff task lines."""
556
+ declared_task_ids: list[str] = []
557
+ for each_line in all_handoff_lines:
558
+ task_match = HANDOFF_TASK_LINE_PATTERN.match(each_line)
559
+ if task_match is not None:
560
+ declared_task_ids.append(task_match.group(1))
561
+ return declared_task_ids
562
+
563
+
564
+ def _validate_handoff_task_fields(
565
+ all_handoff_lines: set[str], all_tasks: list[object]
566
+ ) -> list[str]:
567
+ """Return diagnostics for every packet task field in the handoff."""
568
+ errors: list[str] = []
569
+ for each_task in all_tasks:
570
+ if not isinstance(each_task, dict) or not isinstance(each_task.get("id"), str):
571
+ continue
572
+ task_id = each_task["id"]
573
+ all_task_files = each_task.get("allowed_files", [])
574
+ task_file_names = (
575
+ [
576
+ each_file_path
577
+ for each_file_path in all_task_files
578
+ if isinstance(each_file_path, str)
579
+ ]
580
+ if isinstance(all_task_files, list)
581
+ else []
582
+ )
583
+ task_lines = _handoff_task_lines(task_id, each_task, task_file_names)
584
+ errors.extend(
585
+ f"handoff.md: task {task_id} missing {each_field_name}"
586
+ for each_field_name, each_required_line in task_lines.items()
587
+ if each_required_line not in all_handoff_lines
588
+ )
589
+ return errors
590
+
591
+
592
+ def _handoff_task_lines(
593
+ task_id: str, all_task: dict[str, object], all_task_files: list[str]
594
+ ) -> dict[str, str]:
595
+ """Build exact handoff lines for one packet task."""
596
+ return {
597
+ "deliverable": f"{TASK_LINE_PREFIX}{task_id}: {all_task.get('deliverable', '')}",
598
+ "allowed files": f"{HANDOFF_ALLOWED_FILES_PREFIX}{COMMAND_SEPARATOR.join(all_task_files)}",
599
+ "acceptance command": f"{HANDOFF_ACCEPTANCE_PREFIX}{all_task.get('acceptance_command', '')}",
600
+ "test command": f"{HANDOFF_TEST_PREFIX}{all_task.get('test_command', '')}",
601
+ "verification command": f"{HANDOFF_VERIFICATION_PREFIX}{all_task.get('verification_command', '')}",
602
+ }
603
+
604
+
605
+ def _has_unresolved_text(text: str) -> bool:
606
+ """Detect unresolved questions and common template markers outside code."""
607
+ prose = re.sub(r"```[\s\S]*?```|`[^`]+`", "", text)
608
+ return bool(UNRESOLVED_TEXT_PATTERN.search(prose))
609
+
610
+
611
+ def _is_reproducible_command(command: str) -> bool:
612
+ """Accept a concrete shell command with no placeholders or randomness."""
613
+ try:
614
+ tokens = shlex.split(command)
615
+ except ValueError:
616
+ return False
617
+ return (
618
+ bool(tokens)
619
+ and not _has_unresolved_text(command)
620
+ and not REPRODUCIBILITY_BLOCK_PATTERN.search(command)
621
+ )
622
+
623
+
624
+ def _is_within(candidate_path: Path, parent_path: Path) -> bool:
625
+ """Return whether a resolved path is within a resolved directory."""
626
+ try:
627
+ candidate_path.relative_to(parent_path)
628
+ except ValueError:
629
+ return False
630
+ return True
631
+
632
+
633
+ def _parse_arguments() -> argparse.Namespace:
634
+ """Parse the packet directory argument."""
635
+ parser = argparse.ArgumentParser(description="Validate a Plan-to-PR packet.")
636
+ parser.add_argument("packet_directory", type=Path)
637
+ return parser.parse_args()
638
+
639
+
640
+ def main() -> int:
641
+ """Run validation and return the CLI status code.
642
+
643
+ Returns:
644
+ The process status code.
645
+ """
646
+ errors = validate_packet(_parse_arguments().packet_directory)
647
+ if errors:
648
+ print(NEWLINE_SEPARATOR.join(errors), file=sys.stderr)
649
+ return PACKET_VALIDATION_ERROR_EXIT_CODE
650
+ print("packet validation passed")
651
+ return 0
652
+
653
+
654
+ if __name__ == "__main__":
655
+ raise SystemExit(main())