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.
Files changed (290) hide show
  1. package/CLAUDE.md +53 -48
  2. package/_shared/pr-loop/scripts/_claude_permissions_common.py +84 -0
  3. package/_shared/pr-loop/scripts/code_rules_gate.py +4 -2
  4. package/_shared/pr-loop/scripts/grant_project_claude_permissions.py +306 -306
  5. package/_shared/pr-loop/scripts/pr_loop_shared_constants/claude_permissions_constants.py +44 -0
  6. package/_shared/pr-loop/scripts/pr_loop_shared_constants/copilot_quota_constants.py +24 -24
  7. package/_shared/pr-loop/scripts/pr_loop_shared_constants/stale_worktree_rule_sweep_constants.py +107 -107
  8. package/_shared/pr-loop/scripts/revoke_project_claude_permissions.py +290 -48
  9. package/_shared/pr-loop/scripts/tests/test_claude_permissions_common.py +42 -2
  10. package/_shared/pr-loop/scripts/tests/test_claude_permissions_constants.py +36 -0
  11. package/_shared/pr-loop/scripts/tests/test_code_rules_gate.py +100 -1
  12. package/_shared/pr-loop/scripts/tests/test_fix_hookspath.py +497 -497
  13. package/_shared/pr-loop/scripts/tests/test_revoke_project_claude_permissions.py +311 -2
  14. package/_shared/pr-loop/scripts/tests/test_stale_worktree_rule_sweep.py +301 -301
  15. package/_shared/pr-loop/scripts/tests/test_stale_worktree_rule_sweep_constants.py +85 -85
  16. package/_shared/pr-loop/worker-spawn.md +1 -1
  17. package/agents/CLAUDE.md +3 -1
  18. package/agents/caveman.md +0 -1
  19. package/agents/clasp-deployment-orchestrator.md +0 -1
  20. package/agents/clean-coder.md +0 -1
  21. package/agents/code-advisor.md +0 -1
  22. package/agents/code-quality-agent.md +1 -2
  23. package/agents/code-verifier.md +3 -4
  24. package/agents/deep-research.md +0 -1
  25. package/agents/docs-agent.md +0 -1
  26. package/agents/git-commit-crafter.md +0 -1
  27. package/agents/issue-tracker.md +42 -0
  28. package/agents/plan-packet-validator.md +0 -1
  29. package/agents/pr-description-writer.md +0 -1
  30. package/agents/skill-writer-agent.md +84 -0
  31. package/agents/test_agent_frontmatter.py +67 -18
  32. package/audit-rubrics/category_rubrics/category-o-docstring-vs-impl-drift.md +105 -3
  33. package/audit-rubrics/prompts/category-o-docstring-vs-impl-drift.md +29 -13
  34. package/bin/CLAUDE.md +68 -5
  35. package/bin/ever-shipped-skills.mjs +1 -0
  36. package/bin/install-constants.mjs +88 -0
  37. package/bin/install.mjs +1138 -114
  38. package/bin/install.prune.test.mjs +869 -19
  39. package/bin/install.test.mjs +906 -2
  40. package/commands/implement.md +1 -1
  41. package/commands/right-size.md +1 -1
  42. package/docs/CLAUDE.md +2 -0
  43. package/docs/host-pool-health-monitor.md +102 -0
  44. package/docs/references/CLAUDE.md +5 -2
  45. package/docs/references/advisor-tool.md +13 -0
  46. package/docs/references/code-review-enforcement.md +107 -0
  47. package/docs/references/team-advisor-skill.md +14 -0
  48. package/docs/wsl-docker-cowork-starter-matrix.md +89 -0
  49. package/hooks/blocking/CLAUDE.md +9 -1
  50. package/hooks/blocking/code_review_enforcement_config_bootstrap.py +53 -0
  51. package/hooks/blocking/code_review_gate_deny.py +74 -0
  52. package/hooks/blocking/code_review_pr_create_gate.py +198 -0
  53. package/hooks/blocking/code_review_push_gate.py +145 -0
  54. package/hooks/blocking/code_review_stamp_directory_write_blocker.py +348 -0
  55. package/hooks/blocking/code_review_stamp_store.py +233 -0
  56. package/hooks/blocking/code_review_stamp_write_blocker_parts/__init__.py +7 -0
  57. package/hooks/blocking/code_review_stamp_write_blocker_parts/conftest.py +15 -0
  58. package/hooks/blocking/code_review_stamp_write_blocker_parts/obfuscated_stamp_path_reference.py +212 -0
  59. package/hooks/blocking/code_review_stamp_write_blocker_parts/split_directory_change_into_stamp.py +138 -0
  60. package/hooks/blocking/code_review_stamp_write_blocker_parts/test_obfuscated_stamp_path_reference.py +49 -0
  61. package/hooks/blocking/code_review_stamp_write_blocker_parts/test_split_directory_change_into_stamp.py +38 -0
  62. package/hooks/blocking/code_verifier_spawn_preflight_gate.py +39 -27
  63. package/hooks/blocking/config/__init__.py +5 -5
  64. package/hooks/blocking/config/code_review_enforcement_constants.py +113 -0
  65. package/hooks/blocking/config/test_code_review_enforcement_constants.py +113 -0
  66. package/hooks/blocking/config/verified_commit_constants.py +160 -155
  67. package/hooks/blocking/conftest.py +2 -0
  68. package/hooks/blocking/convergence_gate_blocker.py +112 -23
  69. package/hooks/blocking/destructive_command_blocker.py +19 -6
  70. package/hooks/blocking/orchestrator_refresh_reschedule_gate.py +256 -0
  71. package/hooks/blocking/pr_description_proof_of_work.py +52 -34
  72. package/hooks/blocking/pre_tool_use_dispatcher.py +24 -24
  73. package/hooks/blocking/test_bash_pre_tool_use_dispatcher.py +4 -1
  74. package/hooks/blocking/test_code_review_enforcement_config_bootstrap.py +62 -0
  75. package/hooks/blocking/test_code_review_gate_deny.py +54 -0
  76. package/hooks/blocking/test_code_review_pr_create_gate.py +199 -0
  77. package/hooks/blocking/test_code_review_push_gate.py +205 -0
  78. package/hooks/blocking/test_code_review_stamp_directory_write_blocker.py +199 -0
  79. package/hooks/blocking/test_code_review_stamp_store.py +205 -0
  80. package/hooks/blocking/test_code_verifier_spawn_preflight_gate.py +124 -2
  81. package/hooks/blocking/test_convergence_gate_blocker.py +153 -5
  82. package/hooks/blocking/test_destructive_command_blocker.py +1 -1
  83. package/hooks/blocking/test_destructive_command_blocker_deny_mode.py +45 -0
  84. package/hooks/blocking/test_orchestrator_refresh_reschedule_gate.py +231 -0
  85. package/hooks/blocking/test_pr_description_proof_of_work.py +151 -0
  86. package/hooks/blocking/test_pre_tool_use_dispatcher.py +17 -8
  87. package/hooks/blocking/test_verdict_directory_write_blocker.py +808 -808
  88. package/hooks/blocking/test_verification_verdict_store.py +974 -903
  89. package/hooks/blocking/test_verified_commit_gate.py +581 -581
  90. package/hooks/blocking/test_verified_commit_message_accuracy_blocker.py +131 -131
  91. package/hooks/blocking/test_volatile_path_in_post_blocker.py +114 -2
  92. package/hooks/blocking/verdict_directory_write_blocker.py +687 -687
  93. package/hooks/blocking/verification_verdict_store.py +1039 -1014
  94. package/hooks/blocking/verified_commit_gate_parts/gated_invocations.py +29 -17
  95. package/hooks/blocking/verified_commit_gate_parts/tests/test_gated_invocations.py +35 -0
  96. package/hooks/blocking/verified_commit_message_accuracy_blocker.py +167 -167
  97. package/hooks/blocking/verifier_verdict_minter.py +280 -280
  98. package/hooks/blocking/volatile_path_in_post_blocker.py +69 -8
  99. package/hooks/git-hooks/git_hooks_constants/__init__.py +6 -0
  100. package/hooks/git-hooks/pre_push.py +89 -2
  101. package/hooks/git-hooks/test_pre_push.py +128 -0
  102. package/hooks/hooks.json +26 -1
  103. package/hooks/hooks_constants/CLAUDE.md +3 -1
  104. package/hooks/hooks_constants/bash_pre_tool_use_dispatcher_constants.py +8 -0
  105. package/hooks/hooks_constants/code_rules_path_utils_constants.py +1 -0
  106. package/hooks/hooks_constants/code_verifier_spawn_preflight_gate_constants.py +26 -11
  107. package/hooks/hooks_constants/convergence_gate_blocker_constants.py +20 -3
  108. package/hooks/hooks_constants/destructive_command_segment_constants.py +3 -1
  109. package/hooks/hooks_constants/enter_worktree_prefetch_constants.py +18 -18
  110. package/hooks/hooks_constants/orchestrator_refresh_reschedule_gate_constants.py +48 -0
  111. package/hooks/hooks_constants/pr_description_proof_of_work_constants.py +0 -4
  112. package/hooks/hooks_constants/pre_tool_use_dispatcher_constants.py +4 -0
  113. package/hooks/hooks_constants/pyproject_config_discovery_constants.py +16 -0
  114. package/hooks/hooks_constants/ruff_integration_constants.py +16 -0
  115. package/hooks/hooks_constants/test_bash_pre_tool_use_dispatcher_constants.py +24 -0
  116. package/hooks/hooks_constants/test_pre_tool_use_dispatcher_constants.py +6 -0
  117. package/hooks/hooks_constants/volatile_path_in_post_blocker_constants.py +8 -1
  118. package/hooks/lifecycle/enter_worktree_origin_prefetch.py +163 -146
  119. package/hooks/lifecycle/test_enter_worktree_origin_prefetch.py +185 -178
  120. package/hooks/pyproject.toml +1 -0
  121. package/hooks/validators/CLAUDE.md +2 -0
  122. package/hooks/validators/config/__init__.py +0 -0
  123. package/hooks/validators/config/directory_exemption_constants.py +183 -0
  124. package/hooks/validators/config/test_directory_exemption_constants.py +21 -0
  125. package/hooks/validators/conftest.py +4 -0
  126. package/hooks/validators/mypy_integration.py +63 -50
  127. package/hooks/validators/pyproject_config_discovery.py +101 -0
  128. package/hooks/validators/ruff_integration.py +257 -25
  129. package/hooks/validators/run_all_validators.py +223 -19
  130. package/hooks/validators/test_directory_exemption_constants.py +185 -0
  131. package/hooks/validators/test_mypy_integration.py +32 -0
  132. package/hooks/validators/test_pyproject_config_discovery.py +94 -0
  133. package/hooks/validators/test_python_antipattern_checks.py +110 -5
  134. package/hooks/validators/test_ruff_integration.py +160 -2
  135. package/hooks/validators/test_run_all_validators.py +140 -68
  136. package/hooks/validators/test_run_all_validators_config_discovery.py +123 -0
  137. package/hooks/validators/test_run_all_validators_pretooluse.py +159 -1
  138. package/package.json +10 -2
  139. package/rules/CLAUDE.md +1 -0
  140. package/rules/docstring-prose-matches-implementation.md +45 -67
  141. package/rules/durable-post-artifacts.md +7 -0
  142. package/rules/state-what-is.md +25 -0
  143. package/rules/verified-commit-gate-skip.md +1 -1
  144. package/scripts/CLAUDE.md +1 -0
  145. package/scripts/Capture-PoolHealth.ps1 +410 -0
  146. package/scripts/_code_review_test_support.py +404 -0
  147. package/scripts/claude_chain_runner.py +141 -1
  148. package/scripts/codec_forwarding_test_support.py +83 -0
  149. package/scripts/conftest.py +23 -0
  150. package/scripts/dev_env_scripts_constants/CLAUDE.md +2 -2
  151. package/scripts/dev_env_scripts_constants/claude_chain_constants.py +53 -1
  152. package/scripts/dev_env_scripts_constants/code_review_constants.py +129 -12
  153. package/scripts/dev_env_scripts_constants/test_code_review_constants.py +55 -0
  154. package/scripts/invoke_code_review.py +550 -38
  155. package/scripts/resolve_worker_spawn.py +626 -619
  156. package/scripts/spawn_grok_batch.py +672 -672
  157. package/scripts/test_claude_chain_runner.py +131 -0
  158. package/scripts/test_invoke_code_review_chain.py +70 -0
  159. package/scripts/test_invoke_code_review_cli.py +192 -0
  160. package/scripts/test_invoke_code_review_codec.py +77 -0
  161. package/scripts/test_invoke_code_review_contract.py +256 -0
  162. package/scripts/test_invoke_code_review_git.py +123 -0
  163. package/scripts/test_invoke_code_review_mode.py +99 -0
  164. package/scripts/test_resolve_worker_spawn.py +1014 -1014
  165. package/scripts/test_resolve_worker_spawn_codec.py +101 -0
  166. package/skills/CLAUDE.md +2 -0
  167. package/skills/auditing-claude-config/SKILL.md +114 -114
  168. package/skills/autoconverge/SKILL.md +427 -421
  169. package/skills/autoconverge/reference/convergence.md +26 -4
  170. package/skills/autoconverge/reference/multi-pr.md +6 -1
  171. package/skills/autoconverge/reference/stop-conditions.md +16 -10
  172. package/skills/autoconverge/workflow/CLAUDE.md +1 -0
  173. package/skills/autoconverge/workflow/converge.clean-audit.test.mjs +4 -4
  174. package/skills/autoconverge/workflow/converge.codex-gate.test.mjs +175 -3
  175. package/skills/autoconverge/workflow/converge.contract.test.mjs +1263 -1244
  176. package/skills/autoconverge/workflow/converge.mjs +191 -8
  177. package/skills/autoconverge/workflow/converge.p2-advance.test.mjs +202 -0
  178. package/skills/autoconverge/workflow/converge_multi.mjs +7 -3
  179. package/skills/autoconverge/workflow/converge_multi.run-input.test.mjs +5 -0
  180. package/skills/autoconverge/workflow/fixtures/wf_run/subagents/workflows/wf_881252e6-700/agent-a11d903476b803493.jsonl +2 -2
  181. package/skills/autoconverge/workflow/fixtures/wf_run/subagents/workflows/wf_881252e6-700/agent-a26213978adeef6fb.jsonl +2 -2
  182. package/skills/autoconverge/workflow/fixtures/wf_run/subagents/workflows/wf_881252e6-700/agent-a3def0d15ed9d9110.jsonl +2 -2
  183. package/skills/autoconverge/workflow/fixtures/wf_run/subagents/workflows/wf_881252e6-700/agent-a41f41b1b708ee3b7.jsonl +2 -2
  184. package/skills/autoconverge/workflow/fixtures/wf_run/subagents/workflows/wf_881252e6-700/agent-a758b880abecc3ff7.jsonl +2 -2
  185. package/skills/autoconverge/workflow/fixtures/wf_run/subagents/workflows/wf_881252e6-700/agent-a8897b89656b1bd16.jsonl +2 -2
  186. package/skills/autoconverge/workflow/fixtures/wf_run/subagents/workflows/wf_881252e6-700/agent-abd463d744a1437bc.jsonl +2 -2
  187. package/skills/autoconverge/workflow/fixtures/wf_run/subagents/workflows/wf_881252e6-700/agent-ad19d027ae8ee1816.jsonl +2 -2
  188. package/skills/autoconverge/workflow/fixtures/wf_run/workflows/wf_881252e6-700.json +265 -265
  189. package/skills/closeout/SKILL.md +33 -50
  190. package/skills/codex-review/scripts/codex_review_scripts_constants/run_constants.py +8 -0
  191. package/skills/codex-review/scripts/run_codex_review.py +233 -1
  192. package/skills/codex-review/scripts/test_run_codex_review.py +189 -0
  193. package/skills/condensing-instructions/SKILL.md +81 -0
  194. package/skills/copilot-review/SKILL.md +119 -119
  195. package/skills/e-code-review/SKILL.md +52 -0
  196. package/skills/e-code-review/reference/fix.md +54 -0
  197. package/skills/e-code-review/reference/loop.md +43 -0
  198. package/skills/e-code-review/reference/low.md +57 -0
  199. package/skills/e-code-review/reference/medium.md +153 -0
  200. package/skills/e-code-review/reference/xhigh.md +182 -0
  201. package/skills/e-simplify/SKILL.md +97 -0
  202. package/skills/fresh-branch/CLAUDE.md +2 -0
  203. package/skills/fresh-branch/SKILL.md +2 -0
  204. package/skills/fresh-branch/scripts/create_fresh_branch.py +78 -180
  205. package/skills/fresh-branch/scripts/fresh_branch_git_commands.py +285 -0
  206. package/skills/fresh-branch/scripts/fresh_branch_scripts_constants/fresh_branch_cli_constants.py +1 -0
  207. package/skills/fresh-branch/scripts/pytest.ini +4 -0
  208. package/skills/fresh-branch/scripts/test_create_fresh_branch.py +98 -0
  209. package/skills/fresh-branch/scripts/test_fresh_branch_git_commands.py +310 -0
  210. package/skills/issue-tracker/SKILL.md +92 -0
  211. package/skills/issue-tracker/reference/epic-and-sub-issue-model.md +55 -0
  212. package/skills/issue-tracker/reference/handoff-schema.md +64 -0
  213. package/skills/issue-tracker/reference/operation-matrix.md +41 -0
  214. package/skills/orchestrator/SKILL.md +162 -21
  215. package/skills/orchestrator/scripts/status_gate.py +625 -0
  216. package/skills/orchestrator/scripts/status_gate_constants/__init__.py +1 -0
  217. package/skills/orchestrator/scripts/status_gate_constants/config/__init__.py +1 -0
  218. package/skills/orchestrator/scripts/status_gate_constants/config/constants.py +47 -0
  219. package/skills/orchestrator/scripts/test_status_gate.py +439 -0
  220. package/skills/orchestrator-refresh/SKILL.md +110 -35
  221. package/skills/plan-to-pr/SKILL.md +155 -0
  222. package/skills/plan-to-pr/reference/final-validation-tasks.md +15 -0
  223. package/skills/plan-to-pr/reference/model-routing.md +36 -0
  224. package/skills/plan-to-pr/reference/packet-contract.md +43 -0
  225. package/skills/plan-to-pr/reference/packet-schema.json +57 -0
  226. package/skills/plan-to-pr/reference/process-inventory.md +22 -0
  227. package/skills/plan-to-pr/reference/review-loop.md +33 -0
  228. package/skills/plan-to-pr/reference/run-record.schema.json +27 -0
  229. package/skills/plan-to-pr/reference/self-audit-tasks.md +15 -0
  230. package/skills/plan-to-pr/reference/task-seeds.md +14 -0
  231. package/skills/plan-to-pr/reference/task-ticket.md +38 -0
  232. package/skills/plan-to-pr/scripts/config/__init__.py +1 -0
  233. package/skills/plan-to-pr/scripts/config/constants.py +193 -0
  234. package/skills/plan-to-pr/scripts/create_packet.py +173 -0
  235. package/skills/plan-to-pr/scripts/test_create_packet.py +102 -0
  236. package/skills/plan-to-pr/scripts/test_validate_packet.py +256 -0
  237. package/skills/plan-to-pr/scripts/test_validate_protocol.py +135 -0
  238. package/skills/plan-to-pr/scripts/test_validate_run.py +158 -0
  239. package/skills/plan-to-pr/scripts/validate_packet.py +655 -0
  240. package/skills/plan-to-pr/scripts/validate_protocol.py +622 -0
  241. package/skills/plan-to-pr/scripts/validate_run.py +173 -0
  242. package/skills/plan-to-pr/test_skill_contract.py +207 -0
  243. package/skills/plan-to-pr/test_task_ticket_contract.py +151 -0
  244. package/skills/pr-converge/SKILL.md +472 -469
  245. package/skills/pr-converge/reference/examples.md +3 -3
  246. package/skills/pr-converge/reference/fix-protocol.md +1 -1
  247. package/skills/pr-converge/reference/ground-rules.md +7 -4
  248. package/skills/pr-converge/reference/multi-pr-orchestration.md +4 -1
  249. package/skills/pr-converge/reference/per-tick.md +5 -5
  250. package/skills/pr-converge/reference/progress-checklist.md +1 -1
  251. package/skills/pr-converge/scripts/check_convergence_gates.py +279 -279
  252. package/skills/pr-converge/scripts/test_check_convergence_codex.py +507 -507
  253. package/skills/pr-converge/scripts/test_check_convergence_gates.py +84 -84
  254. package/skills/pr-converge/test_step5_host_branch.py +1 -1
  255. package/skills/pr-fix-protocol/SKILL.md +1 -1
  256. package/skills/privacy-hygiene/SKILL.md +68 -68
  257. package/skills/prototype/SKILL.md +86 -0
  258. package/skills/prototype/reference/honest-limitations.md +23 -0
  259. package/skills/prototype/reference/promotion-tasks.md +23 -0
  260. package/skills/prototype/scripts/build_sandbox_settings.py +249 -0
  261. package/skills/prototype/scripts/conftest.py +15 -0
  262. package/skills/prototype/scripts/launch_sandbox.py +205 -0
  263. package/skills/prototype/scripts/probe_sandbox_safety.py +311 -0
  264. package/skills/prototype/scripts/prototype_scripts_constants/__init__.py +1 -0
  265. package/skills/prototype/scripts/prototype_scripts_constants/config/__init__.py +0 -0
  266. package/skills/prototype/scripts/prototype_scripts_constants/config/build_sandbox_settings_constants.py +41 -0
  267. package/skills/prototype/scripts/prototype_scripts_constants/config/launch_sandbox_constants.py +23 -0
  268. package/skills/prototype/scripts/prototype_scripts_constants/config/probe_sandbox_safety_constants.py +45 -0
  269. package/skills/prototype/scripts/prototype_scripts_constants/config/prototype_common_constants.py +10 -0
  270. package/skills/prototype/scripts/test_build_sandbox_settings.py +275 -0
  271. package/skills/prototype/scripts/test_launch_sandbox.py +303 -0
  272. package/skills/prototype/scripts/test_probe_sandbox_safety.py +284 -0
  273. package/skills/prototype/workflows/promotion.md +27 -0
  274. package/skills/prototype/workflows/sandbox.md +35 -0
  275. package/skills/release-notes-html/SKILL.md +164 -0
  276. package/skills/skill-builder/CLAUDE.md +3 -3
  277. package/skills/skill-builder/SKILL.md +5 -5
  278. package/skills/skill-builder/references/CLAUDE.md +1 -1
  279. package/skills/skill-builder/references/delegation-map.md +3 -3
  280. package/skills/skill-builder/references/description-field.md +1 -1
  281. package/skills/skill-builder/references/skill-modularity.md +2 -3
  282. package/skills/skill-builder/workflows/CLAUDE.md +1 -1
  283. package/skills/skill-builder/workflows/improve-skill.md +1 -1
  284. package/skills/skill-builder/workflows/new-skill.md +2 -2
  285. package/skills/task-build/CLAUDE.md +8 -7
  286. package/skills/task-build/SKILL.md +16 -8
  287. package/skills/task-build/reference/tool-routing.md +19 -0
  288. package/skills/team-advisor/SKILL.md +2 -2
  289. package/scripts/test_invoke_code_review.py +0 -672
  290. package/skills/closeout/reference/issue-body-templates.md +0 -108
@@ -0,0 +1,622 @@
1
+ """Validate one skill protocol task-run record."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import hashlib
7
+ import re
8
+ import subprocess
9
+ import sys
10
+ from collections.abc import Mapping, Sequence
11
+ from pathlib import Path
12
+
13
+ from config.constants import (
14
+ ALL_ALLOWED_RECORD_FIELDS,
15
+ ALL_RECORD_FIELDS,
16
+ ALL_REPAIR_FIELDS,
17
+ ALL_REPAIR_TEXT_FIELDS,
18
+ ALL_REVIEW_FIELDS,
19
+ ALL_REVIEW_TEXT_FIELDS,
20
+ ALL_TOP_LEVEL_TEXT_FIELDS,
21
+ ALL_SUPPORTED_SCHEMA_TYPES,
22
+ ALL_VERIFICATION_FIELDS,
23
+ ALLOWED_REVIEW_MODEL,
24
+ ALLOWED_WORKER_EFFORT,
25
+ ALLOWED_WORKER_ROLE,
26
+ ARGUMENT_COUNT_REQUIRED,
27
+ ARGUMENT_COUNT_WITH_WORKTREE,
28
+ OPTION_ARGUMENT_INDEX,
29
+ WORKTREE_OPTION,
30
+ WORKTREE_PATH_ARGUMENT_INDEX,
31
+ COMMIT_HASH_ERROR_TEMPLATE,
32
+ COMMIT_HASH_PATTERN,
33
+ EXIT_CODE_INVALID_RECORD,
34
+ EXIT_CODE_VALID_RECORD,
35
+ JSON_ENCODING,
36
+ MISSING_FIELD_SEPARATOR,
37
+ MAXIMUM_COMMIT_HASH_LENGTH,
38
+ MINIMUM_COMMIT_HASH_LENGTH,
39
+ PROTOCOL_VALIDATION_PASSED,
40
+ RECORD_SCHEMA_FILENAME,
41
+ DOCS_PLANS_PATH_PATTERN,
42
+ FINDING_STATUS_CONFIRMED,
43
+ FINDING_STATUS_DISMISSED,
44
+ NATIVE_REVIEW_COMMAND,
45
+ PASSED_EVIDENCE_PREFIX,
46
+ REPAIR_STATUS_COMPLETE,
47
+ REPAIR_STATUS_NOT_REQUIRED,
48
+ SURFACE_HASH_PATTERN,
49
+ PATH_SEPARATOR_PATTERN,
50
+ SHA256_ALGORITHM,
51
+ )
52
+
53
+
54
+ class ProtocolValidationError(ValueError):
55
+ """Identify a task-run record contract violation."""
56
+
57
+
58
+ def _load_schema_object(schema_path: Path) -> Mapping[str, object]:
59
+ """Load one JSON schema object."""
60
+ try:
61
+ parsed_schema = json.loads(schema_path.read_text(encoding=JSON_ENCODING))
62
+ except json.JSONDecodeError as error:
63
+ raise ProtocolValidationError("record schema is invalid JSON") from error
64
+ except OSError as error:
65
+ raise ProtocolValidationError(f"cannot read record schema: {error}") from error
66
+ if not isinstance(parsed_schema, dict):
67
+ raise ProtocolValidationError("record schema must be a JSON object")
68
+ return parsed_schema
69
+
70
+
71
+ def _resolve_schema_reference(
72
+ all_schema_node: Mapping[str, object], all_schema_root: Mapping[str, object]
73
+ ) -> Mapping[str, object]:
74
+ """Resolve a local `$defs` schema reference."""
75
+ schema_reference = all_schema_node.get("$ref")
76
+ if schema_reference is None:
77
+ return all_schema_node
78
+ if not isinstance(schema_reference, str) or not schema_reference.startswith(
79
+ "#/$defs/"
80
+ ):
81
+ raise ProtocolValidationError("record schema has an unsupported reference")
82
+ definitions = all_schema_root.get("$defs")
83
+ if not isinstance(definitions, dict):
84
+ raise ProtocolValidationError("record schema has no definitions")
85
+ definition_name = schema_reference.removeprefix("#/$defs/")
86
+ definition = definitions.get(definition_name)
87
+ if not isinstance(definition, dict):
88
+ raise ProtocolValidationError(
89
+ f"record schema definition is missing: {definition_name}"
90
+ )
91
+ return definition
92
+
93
+
94
+ def _validate_schema_node(
95
+ candidate: object,
96
+ all_schema_node: Mapping[str, object],
97
+ all_schema_root: Mapping[str, object],
98
+ field_path: str,
99
+ ) -> None:
100
+ """Validate one record value against the supported schema keywords."""
101
+ resolved_schema = _resolve_schema_reference(all_schema_node, all_schema_root)
102
+ if "const" in resolved_schema and candidate != resolved_schema["const"]:
103
+ raise ProtocolValidationError(
104
+ f"{field_path} does not match its schema constant"
105
+ )
106
+ schema_type = resolved_schema.get("type")
107
+ if schema_type == "object":
108
+ _validate_schema_object(candidate, resolved_schema, all_schema_root, field_path)
109
+ return
110
+ if schema_type == "array":
111
+ _validate_schema_array(candidate, resolved_schema, all_schema_root, field_path)
112
+ return
113
+ if schema_type == "string":
114
+ _validate_schema_string(candidate, resolved_schema, field_path)
115
+ return
116
+ if schema_type == "boolean" and not isinstance(candidate, bool):
117
+ raise ProtocolValidationError(f"{field_path} must be a boolean")
118
+ if schema_type not in (*ALL_SUPPORTED_SCHEMA_TYPES, None):
119
+ raise ProtocolValidationError(f"{field_path} has an unsupported schema type")
120
+
121
+
122
+ def _validate_schema_object(
123
+ candidate: object,
124
+ all_schema_node: Mapping[str, object],
125
+ all_schema_root: Mapping[str, object],
126
+ field_path: str,
127
+ ) -> None:
128
+ """Validate an object schema node."""
129
+ if not isinstance(candidate, dict):
130
+ raise ProtocolValidationError(f"{field_path} must be an object")
131
+ required_fields = all_schema_node.get("required", [])
132
+ if not isinstance(required_fields, list):
133
+ raise ProtocolValidationError(f"{field_path} has invalid required fields")
134
+ for each_field_name in required_fields:
135
+ if isinstance(each_field_name, str) and each_field_name not in candidate:
136
+ raise ProtocolValidationError(f"{field_path} missing: {each_field_name}")
137
+ properties = all_schema_node.get("properties", {})
138
+ if not isinstance(properties, dict):
139
+ raise ProtocolValidationError(f"{field_path} has invalid properties")
140
+ if all_schema_node.get("additionalProperties") is False:
141
+ additional_fields = sorted(
142
+ each_field_name
143
+ for each_field_name in candidate
144
+ if each_field_name not in properties
145
+ )
146
+ if additional_fields:
147
+ raise ProtocolValidationError(
148
+ f"{field_path} has additional fields: "
149
+ f"{MISSING_FIELD_SEPARATOR.join(additional_fields)}"
150
+ )
151
+ for each_field_name, each_schema_node in properties.items():
152
+ if each_field_name in candidate and isinstance(each_schema_node, dict):
153
+ _validate_schema_node(
154
+ candidate[each_field_name],
155
+ each_schema_node,
156
+ all_schema_root,
157
+ f"{field_path}.{each_field_name}",
158
+ )
159
+
160
+
161
+ def _validate_schema_array(
162
+ candidate: object,
163
+ all_schema_node: Mapping[str, object],
164
+ all_schema_root: Mapping[str, object],
165
+ field_path: str,
166
+ ) -> None:
167
+ """Validate an array schema node."""
168
+ if not isinstance(candidate, list):
169
+ raise ProtocolValidationError(f"{field_path} must be an array")
170
+ minimum_array_entries = all_schema_node.get("minItems", 0)
171
+ if (
172
+ isinstance(minimum_array_entries, int)
173
+ and len(candidate) < minimum_array_entries
174
+ ):
175
+ raise ProtocolValidationError(
176
+ f"{field_path} must contain at least {minimum_array_entries} items"
177
+ )
178
+ array_entry_schema = all_schema_node.get("items")
179
+ if isinstance(array_entry_schema, dict):
180
+ for each_index, each_candidate in enumerate(candidate):
181
+ _validate_schema_node(
182
+ each_candidate,
183
+ array_entry_schema,
184
+ all_schema_root,
185
+ f"{field_path}[{each_index}]",
186
+ )
187
+
188
+
189
+ def _validate_schema_string(
190
+ candidate: object, all_schema_node: Mapping[str, object], field_path: str
191
+ ) -> None:
192
+ """Validate a string schema node."""
193
+ if not isinstance(candidate, str):
194
+ raise ProtocolValidationError(f"{field_path} must be a string")
195
+ minimum_length = all_schema_node.get("minLength", 0)
196
+ if isinstance(minimum_length, int) and len(candidate) < minimum_length:
197
+ raise ProtocolValidationError(f"{field_path} must not be empty")
198
+ pattern = all_schema_node.get("pattern")
199
+ if isinstance(pattern, str) and re.search(pattern, candidate) is None:
200
+ raise ProtocolValidationError(f"{field_path} does not match its schema pattern")
201
+
202
+
203
+ def _load_json_object(task_run_path: Path) -> Mapping[str, object]:
204
+ """Load one task-run object from JSON.
205
+
206
+ Args:
207
+ task_run_path: JSON file containing one task-run record.
208
+
209
+ Returns:
210
+ The decoded task-run mapping.
211
+
212
+ Raises:
213
+ ProtocolValidationError: If the file cannot be read or decoded as an object.
214
+ """
215
+ try:
216
+ parsed_task_run = json.loads(task_run_path.read_text(encoding=JSON_ENCODING))
217
+ except json.JSONDecodeError as error:
218
+ raise ProtocolValidationError("invalid JSON") from error
219
+ except UnicodeDecodeError as error:
220
+ raise ProtocolValidationError("invalid UTF-8") from error
221
+ except OSError as error:
222
+ raise ProtocolValidationError(
223
+ f"cannot read task-run record: {error}"
224
+ ) from error
225
+ if not isinstance(parsed_task_run, dict):
226
+ raise ProtocolValidationError("task-run record must be a JSON object")
227
+ return parsed_task_run
228
+
229
+
230
+ def _require_fields(
231
+ all_task_run_record: Mapping[str, object],
232
+ all_required_field_names: Sequence[str],
233
+ record_name: str,
234
+ ) -> None:
235
+ """Require named fields in a task-run mapping.
236
+
237
+ Args:
238
+ all_task_run_record: Mapping to inspect.
239
+ all_required_field_names: Field names that must be present.
240
+ record_name: Display name used in validation errors.
241
+
242
+ Returns:
243
+ None.
244
+
245
+ Raises:
246
+ ProtocolValidationError: If a required field is absent.
247
+ """
248
+ missing_field_names = [
249
+ each_field_name
250
+ for each_field_name in all_required_field_names
251
+ if each_field_name not in all_task_run_record
252
+ ]
253
+ if missing_field_names:
254
+ raise ProtocolValidationError(
255
+ f"{record_name} missing: "
256
+ f"{MISSING_FIELD_SEPARATOR.join(missing_field_names)}"
257
+ )
258
+
259
+
260
+ def _reject_additional_fields(
261
+ all_task_run_record: Mapping[str, object],
262
+ all_allowed_field_names: Sequence[str],
263
+ record_name: str,
264
+ ) -> None:
265
+ """Reject fields that are not named by the record schema.
266
+
267
+ Args:
268
+ all_task_run_record: Mapping to inspect.
269
+ all_allowed_field_names: Field names accepted by the schema.
270
+ record_name: Display name used in validation errors.
271
+
272
+ Returns:
273
+ None.
274
+
275
+ Raises:
276
+ ProtocolValidationError: If an unknown field is present.
277
+ """
278
+ allowed_field_names = set(all_allowed_field_names)
279
+ additional_field_names = sorted(
280
+ each_field_name
281
+ for each_field_name in all_task_run_record
282
+ if each_field_name not in allowed_field_names
283
+ )
284
+ if additional_field_names:
285
+ raise ProtocolValidationError(
286
+ f"{record_name} has additional fields: "
287
+ f"{MISSING_FIELD_SEPARATOR.join(additional_field_names)}"
288
+ )
289
+
290
+
291
+ def _require_text(
292
+ all_task_run_record: Mapping[str, object], field_name: str, record_name: str
293
+ ) -> None:
294
+ """Require a non-empty text field in a task-run mapping.
295
+
296
+ Args:
297
+ all_task_run_record: Mapping containing the field.
298
+ field_name: Name of the field to validate.
299
+ record_name: Display name used in validation errors.
300
+
301
+ Returns:
302
+ None.
303
+
304
+ Raises:
305
+ ProtocolValidationError: If the field is absent, non-text, or empty.
306
+ """
307
+ field_text = all_task_run_record[field_name]
308
+ if not isinstance(field_text, str) or not field_text:
309
+ raise ProtocolValidationError(
310
+ f"{record_name}.{field_name} must be a non-empty string"
311
+ )
312
+
313
+
314
+ def _require_string_list(
315
+ all_task_run_record: Mapping[str, object], field_name: str, record_name: str
316
+ ) -> list[str]:
317
+ """Require a list containing only non-empty strings.
318
+
319
+ Args:
320
+ all_task_run_record: Mapping containing the field.
321
+ field_name: Name of the field to validate.
322
+ record_name: Display name used in validation errors.
323
+
324
+ Returns:
325
+ None.
326
+
327
+ Raises:
328
+ ProtocolValidationError: If the field is not a string list.
329
+ """
330
+ all_field_entries = all_task_run_record[field_name]
331
+ if not isinstance(all_field_entries, list) or any(
332
+ not isinstance(each_entry, str) or not each_entry
333
+ for each_entry in all_field_entries
334
+ ):
335
+ raise ProtocolValidationError(
336
+ f"{record_name}.{field_name} must be a string list"
337
+ )
338
+ return all_field_entries
339
+
340
+
341
+ def _require_non_empty_string_list(
342
+ all_task_run_record: Mapping[str, object], field_name: str, record_name: str
343
+ ) -> None:
344
+ """Require a non-empty list containing only non-empty strings.
345
+
346
+ Args:
347
+ all_task_run_record: Mapping containing the field.
348
+ field_name: Name of the field to validate.
349
+ record_name: Display name used in validation errors.
350
+
351
+ Returns:
352
+ None.
353
+
354
+ Raises:
355
+ ProtocolValidationError: If the field is not a non-empty string list.
356
+ """
357
+ all_field_entries = _require_string_list(
358
+ all_task_run_record, field_name, record_name
359
+ )
360
+ if not all_field_entries:
361
+ raise ProtocolValidationError(f"{record_name}.{field_name} must not be empty")
362
+
363
+
364
+ def _require_record(
365
+ all_task_run_record: Mapping[str, object], field_name: str
366
+ ) -> Mapping[str, object]:
367
+ """Return a nested task-run mapping.
368
+
369
+ Args:
370
+ all_task_run_record: Mapping containing the nested record.
371
+ field_name: Name of the nested record field.
372
+
373
+ Returns:
374
+ The nested task-run mapping.
375
+
376
+ Raises:
377
+ ProtocolValidationError: If the nested field is not an object.
378
+ """
379
+ nested_task_run_record = all_task_run_record[field_name]
380
+ if not isinstance(nested_task_run_record, dict):
381
+ raise ProtocolValidationError(f"record.{field_name} must be an object")
382
+ return nested_task_run_record
383
+
384
+
385
+ def _validate_review_record(all_review_record: Mapping[str, object]) -> None:
386
+ """Validate the findings-only review section.
387
+
388
+ Args:
389
+ all_review_record: Review mapping from the task-run record.
390
+
391
+ Returns:
392
+ None.
393
+
394
+ Raises:
395
+ ProtocolValidationError: If the review section violates its contract.
396
+ """
397
+ _reject_additional_fields(all_review_record, ALL_REVIEW_FIELDS, "review_record")
398
+ _require_fields(all_review_record, ALL_REVIEW_FIELDS, "review_record")
399
+ for each_field_name in ALL_REVIEW_TEXT_FIELDS:
400
+ _require_text(all_review_record, each_field_name, "review_record")
401
+ findings = all_review_record["findings"]
402
+ if not isinstance(findings, list) or any(
403
+ not isinstance(each_finding, dict)
404
+ or not isinstance(each_finding.get("finding"), str)
405
+ or each_finding.get("disposition") not in {
406
+ FINDING_STATUS_CONFIRMED,
407
+ FINDING_STATUS_DISMISSED,
408
+ }
409
+ for each_finding in findings
410
+ ):
411
+ raise ProtocolValidationError("review_record.findings must be classified")
412
+ if all_review_record["findings_only"] is not True:
413
+ raise ProtocolValidationError("review_record.findings_only must be true")
414
+ if all_review_record["has_repair_flag"] is not False:
415
+ raise ProtocolValidationError("review_record.has_repair_flag must be false")
416
+
417
+
418
+ def _validate_repair_record(all_repair_record: Mapping[str, object]) -> None:
419
+ """Validate the confirmed-findings repair section.
420
+
421
+ Args:
422
+ all_repair_record: Repair mapping from the task-run record.
423
+
424
+ Returns:
425
+ None.
426
+
427
+ Raises:
428
+ ProtocolValidationError: If the repair section violates its contract.
429
+ """
430
+ _reject_additional_fields(all_repair_record, ALL_REPAIR_FIELDS, "repair_record")
431
+ _require_fields(all_repair_record, ALL_REPAIR_FIELDS, "repair_record")
432
+ for each_field_name in ALL_REPAIR_TEXT_FIELDS:
433
+ _require_text(all_repair_record, each_field_name, "repair_record")
434
+ _require_string_list(all_repair_record, "confirmed_findings", "repair_record")
435
+
436
+
437
+ def _validate_verification_record(
438
+ all_verification_record: Mapping[str, object], record_name: str
439
+ ) -> None:
440
+ """Validate one verification evidence section.
441
+
442
+ Args:
443
+ all_verification_record: Verification mapping from the task-run record.
444
+ record_name: Display name used in validation errors.
445
+
446
+ Returns:
447
+ None.
448
+
449
+ Raises:
450
+ ProtocolValidationError: If required evidence is missing or empty.
451
+ """
452
+ _reject_additional_fields(
453
+ all_verification_record, ALL_VERIFICATION_FIELDS, record_name
454
+ )
455
+ _require_fields(all_verification_record, ALL_VERIFICATION_FIELDS, record_name)
456
+ for each_field_name in ALL_VERIFICATION_FIELDS:
457
+ _require_text(all_verification_record, each_field_name, record_name)
458
+ field_text = all_verification_record[each_field_name]
459
+ if (
460
+ each_field_name != "surface_hash"
461
+ and isinstance(field_text, str)
462
+ and not field_text.lower().startswith(PASSED_EVIDENCE_PREFIX)
463
+ ):
464
+ raise ProtocolValidationError(
465
+ f"{record_name}.{each_field_name} must be explicit passed evidence"
466
+ )
467
+
468
+
469
+ def _normalize_allowed_path(raw_path: str) -> str:
470
+ normalized_path = PATH_SEPARATOR_PATTERN.sub("/", raw_path.strip())
471
+ normalized_path = normalized_path.removeprefix("./")
472
+ if not normalized_path or normalized_path.startswith("../") or "/../" in normalized_path:
473
+ raise ProtocolValidationError("allowed_files contains an unsafe path")
474
+ if normalized_path.startswith("/") or (
475
+ normalized_path[1:].startswith(":/")
476
+ ):
477
+ raise ProtocolValidationError("allowed_files contains an absolute path")
478
+ if DOCS_PLANS_PATH_PATTERN.match(normalized_path.lower()):
479
+ raise ProtocolValidationError("allowed_files cannot contain docs/plans paths")
480
+ return normalized_path
481
+
482
+
483
+ def _validate_semantics(all_task_run_record: Mapping[str, object]) -> None:
484
+ worker_route = all_task_run_record["worker_route"]
485
+ if worker_route != f"{ALLOWED_WORKER_ROLE}; effort={ALLOWED_WORKER_EFFORT}":
486
+ raise ProtocolValidationError("worker_route must be the fast low-effort Luna contract")
487
+ all_allowed_files = all_task_run_record["allowed_files"]
488
+ assert isinstance(all_allowed_files, list)
489
+ normalized_files = [_normalize_allowed_path(each_path) for each_path in all_allowed_files if isinstance(each_path, str)]
490
+ if len(normalized_files) != len(set(normalized_files)):
491
+ raise ProtocolValidationError("allowed_files contains duplicate paths")
492
+ review_record = _require_record(all_task_run_record, "review_record")
493
+ if review_record["resolved_model"] != ALLOWED_REVIEW_MODEL or review_record["effort"] != ALLOWED_WORKER_EFFORT:
494
+ raise ProtocolValidationError("review_record must use fast low-effort Luna")
495
+ command = review_record["command"]
496
+ if not isinstance(command, str):
497
+ raise ProtocolValidationError("review_record.command must be a string")
498
+ if command != NATIVE_REVIEW_COMMAND or "--fix" in command:
499
+ raise ProtocolValidationError("review_record.command must be exactly /e-code-review low")
500
+ findings = review_record["findings"]
501
+ assert isinstance(findings, list)
502
+ confirmed_findings = [each_finding["finding"] for each_finding in findings if each_finding["disposition"] == FINDING_STATUS_CONFIRMED]
503
+ repair_record = _require_record(all_task_run_record, "repair_record")
504
+ if repair_record["confirmed_findings"] != confirmed_findings:
505
+ raise ProtocolValidationError("repair_record must list exactly the confirmed findings")
506
+ if confirmed_findings and repair_record["repair_status"] != REPAIR_STATUS_COMPLETE:
507
+ raise ProtocolValidationError("confirmed findings require a completed repair")
508
+ if not confirmed_findings and repair_record["repair_status"] != REPAIR_STATUS_NOT_REQUIRED:
509
+ raise ProtocolValidationError("repair is permitted only when no findings are confirmed")
510
+ hashes = [
511
+ review_record["surface_hash"], repair_record["surface_hash"],
512
+ _require_record(all_task_run_record, "reverification_record")["surface_hash"],
513
+ _require_record(all_task_run_record, "verification_record")["surface_hash"],
514
+ ]
515
+ if any(not isinstance(each_hash, str) or SURFACE_HASH_PATTERN.fullmatch(each_hash) is None for each_hash in hashes) or len(set(hashes)) != 1:
516
+ raise ProtocolValidationError("review and verification surface hashes must match")
517
+
518
+
519
+ def _committed_surface_hash(worktree: Path, commit_hash: str, all_allowed_files: list[str]) -> str:
520
+ try:
521
+ completed = subprocess.run(
522
+ ["git", "diff", f"{commit_hash}^", commit_hash, "--", *all_allowed_files],
523
+ cwd=worktree, check=True, capture_output=True,
524
+ )
525
+ except (OSError, subprocess.CalledProcessError) as error:
526
+ raise ProtocolValidationError("cannot compute committed task surface") from error
527
+ return hashlib.new(SHA256_ALGORITHM, completed.stdout).hexdigest()
528
+
529
+
530
+ def validate_record(
531
+ all_task_run_record: Mapping[str, object], schema_path: Path
532
+ ) -> None:
533
+ """Validate a complete task-run record against its JSON schema file.
534
+
535
+ Args:
536
+ all_task_run_record: Task-run mapping to validate.
537
+ schema_path: Path to the task-run schema JSON file.
538
+
539
+ Returns:
540
+ None.
541
+
542
+ Raises:
543
+ ProtocolValidationError: If the schema or task-run record is invalid.
544
+ """
545
+ schema_object = _load_schema_object(schema_path)
546
+ _validate_schema_node(all_task_run_record, schema_object, schema_object, "record")
547
+ _reject_additional_fields(all_task_run_record, ALL_ALLOWED_RECORD_FIELDS, "record")
548
+ _require_fields(all_task_run_record, ALL_RECORD_FIELDS, "record")
549
+ for each_field_name in ALL_TOP_LEVEL_TEXT_FIELDS:
550
+ _require_text(all_task_run_record, each_field_name, "record")
551
+ _require_non_empty_string_list(all_task_run_record, "allowed_files", "record")
552
+ commit_hash = all_task_run_record["commit"]
553
+ if (
554
+ not isinstance(commit_hash, str)
555
+ or not MINIMUM_COMMIT_HASH_LENGTH
556
+ <= len(commit_hash)
557
+ <= MAXIMUM_COMMIT_HASH_LENGTH
558
+ or COMMIT_HASH_PATTERN.fullmatch(commit_hash) is None
559
+ ):
560
+ raise ProtocolValidationError(
561
+ COMMIT_HASH_ERROR_TEMPLATE
562
+ % (MINIMUM_COMMIT_HASH_LENGTH, MAXIMUM_COMMIT_HASH_LENGTH)
563
+ )
564
+ _validate_review_record(_require_record(all_task_run_record, "review_record"))
565
+ _validate_repair_record(_require_record(all_task_run_record, "repair_record"))
566
+ _validate_verification_record(
567
+ _require_record(all_task_run_record, "reverification_record"),
568
+ "reverification_record",
569
+ )
570
+ _validate_verification_record(
571
+ _require_record(all_task_run_record, "verification_record"),
572
+ "verification_record",
573
+ )
574
+ _validate_semantics(all_task_run_record)
575
+ worktree = all_task_run_record.get("worktree")
576
+ if isinstance(worktree, str):
577
+ all_allowed_files = [
578
+ _normalize_allowed_path(each_path)
579
+ for each_path in _require_string_list(
580
+ all_task_run_record, "allowed_files", "record"
581
+ )
582
+ ]
583
+ committed_hash = _committed_surface_hash(Path(worktree), str(all_task_run_record["commit"]), all_allowed_files)
584
+ recorded_hash = _require_record(all_task_run_record, "verification_record")["surface_hash"]
585
+ if committed_hash != recorded_hash:
586
+ raise ProtocolValidationError("verification surface hash does not match the committed diff")
587
+
588
+
589
+ def main(all_cli_arguments: Sequence[str]) -> int:
590
+ """Validate a task-run record from command-line arguments.
591
+
592
+ Args:
593
+ all_cli_arguments: Program name followed by one record path.
594
+
595
+ Returns:
596
+ Exit code `0` for a valid record or `2` for invalid input.
597
+ """
598
+ if len(all_cli_arguments) not in {
599
+ ARGUMENT_COUNT_REQUIRED,
600
+ ARGUMENT_COUNT_WITH_WORKTREE,
601
+ } or (
602
+ len(all_cli_arguments) == ARGUMENT_COUNT_WITH_WORKTREE
603
+ and all_cli_arguments[OPTION_ARGUMENT_INDEX] != WORKTREE_OPTION
604
+ ):
605
+ print("usage: validate_protocol.py <record.json> [--worktree PATH]", file=sys.stderr)
606
+ return EXIT_CODE_INVALID_RECORD
607
+ schema_path = Path(__file__).parent.parent / "reference" / RECORD_SCHEMA_FILENAME
608
+ try:
609
+ record = _load_json_object(Path(all_cli_arguments[1]))
610
+ if len(all_cli_arguments) == ARGUMENT_COUNT_WITH_WORKTREE:
611
+ record = dict(record)
612
+ record["worktree"] = all_cli_arguments[WORKTREE_PATH_ARGUMENT_INDEX]
613
+ validate_record(record, schema_path)
614
+ except ProtocolValidationError as error:
615
+ print(f"protocol validation failed: {error}", file=sys.stderr)
616
+ return EXIT_CODE_INVALID_RECORD
617
+ print(PROTOCOL_VALIDATION_PASSED)
618
+ return EXIT_CODE_VALID_RECORD
619
+
620
+
621
+ if __name__ == "__main__":
622
+ raise SystemExit(main(sys.argv))