okstra 0.205.1 → 0.206.1

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 (233) hide show
  1. package/README.md +2 -2
  2. package/dist/commands/lifecycle/install.mjs +2 -1
  3. package/dist/commands/lifecycle/install.mjs.map +1 -1
  4. package/docs/architecture.md +16 -16
  5. package/docs/cli.md +4 -4
  6. package/docs/contributor-change-matrix.md +3 -2
  7. package/docs/performance-improvement-plan-v2.md +1 -1
  8. package/docs/project-structure-overview.md +45 -21
  9. package/package.json +2 -3
  10. package/runtime/BUILD.json +2 -2
  11. package/runtime/bin/okstra-spawn-followups.py +2 -2
  12. package/runtime/prompts/launch.template.md +1 -1
  13. package/runtime/prompts/lead/context-loader.md +1 -1
  14. package/runtime/prompts/lead/convergence.md +3 -3
  15. package/runtime/prompts/lead/okstra-lead-contract.md +14 -55
  16. package/runtime/prompts/lead/phase-routing.md +82 -0
  17. package/runtime/prompts/lead/report-writer.md +2 -2
  18. package/runtime/prompts/lead/team-contract.md +1 -1
  19. package/runtime/prompts/profiles/_coding-conventions-preflight.md +1 -1
  20. package/runtime/prompts/profiles/_common-contract.md +1 -1
  21. package/runtime/prompts/profiles/_coverage-critic.md +1 -1
  22. package/runtime/prompts/profiles/forbidden-actions.json +0 -94
  23. package/runtime/python/okstra_ctl/agent/prompt_cli/corrections.py +1 -1
  24. package/runtime/python/okstra_ctl/agent/prompt_cli/materialize.py +10 -1
  25. package/runtime/python/okstra_ctl/analysis_inputs.py +0 -39
  26. package/runtime/python/okstra_ctl/analysis_scope.py +31 -0
  27. package/runtime/python/okstra_ctl/asset_roots.py +19 -0
  28. package/runtime/python/okstra_ctl/consumers.py +12 -0
  29. package/runtime/python/okstra_ctl/contract_graph.py +75 -10
  30. package/runtime/python/okstra_ctl/dispatch_state.py +22 -0
  31. package/runtime/python/okstra_ctl/doctor.py +15 -4
  32. package/runtime/python/okstra_ctl/execution_mutation_audit.py +27 -1
  33. package/runtime/python/okstra_ctl/handoff.py +11 -466
  34. package/runtime/python/okstra_ctl/handoff_error.py +5 -0
  35. package/runtime/python/okstra_ctl/implementation_direction.py +9 -485
  36. package/runtime/python/okstra_ctl/initial_prompt_materialization.py +8 -1
  37. package/runtime/python/okstra_ctl/next_phase.py +2 -2
  38. package/runtime/python/okstra_ctl/option_comparison.py +3 -165
  39. package/runtime/python/okstra_ctl/option_votes.py +3 -191
  40. package/runtime/python/okstra_ctl/paths.py +22 -6
  41. package/runtime/python/okstra_ctl/phases/__init__.py +4 -0
  42. package/runtime/python/okstra_ctl/phases/catalog.py +260 -0
  43. package/runtime/python/okstra_ctl/phases/change_impact_analysis/boundary.json +11 -0
  44. package/runtime/python/okstra_ctl/phases/change_impact_analysis/entry.py +39 -0
  45. package/runtime/python/okstra_ctl/{report_html/view_models/change_impact_analysis.py → phases/change_impact_analysis/report.py} +3 -3
  46. package/runtime/python/okstra_ctl/phases/change_impact_analysis/spec.md +26 -0
  47. package/runtime/python/okstra_ctl/phases/change_impact_analysis/validation.py +23 -0
  48. package/runtime/python/okstra_ctl/phases/error_analysis/__init__.py +1 -0
  49. package/runtime/python/okstra_ctl/phases/error_analysis/boundary.json +9 -0
  50. package/runtime/{prompts/profiles/error-analysis.md → python/okstra_ctl/phases/error_analysis/profile.md} +2 -2
  51. package/runtime/python/okstra_ctl/{report_html/view_models/error_analysis.py → phases/error_analysis/report.py} +9 -8
  52. package/runtime/{templates/reports → python/okstra_ctl/phases/error_analysis/report_assets}/error-analysis-input.template.md +1 -1
  53. package/{docs/task-process/error-analysis.md → runtime/python/okstra_ctl/phases/error_analysis/spec.md} +22 -7
  54. package/runtime/python/okstra_ctl/phases/error_analysis/validation.py +241 -0
  55. package/runtime/python/okstra_ctl/phases/feature_analysis/__init__.py +1 -0
  56. package/runtime/python/okstra_ctl/phases/feature_analysis/boundary.json +8 -0
  57. package/runtime/python/okstra_ctl/phases/feature_analysis/entry.py +63 -0
  58. package/runtime/python/okstra_ctl/{report_html/view_models/feature_analysis.py → phases/feature_analysis/report.py} +12 -5
  59. package/runtime/python/okstra_ctl/phases/feature_analysis/spec.md +22 -0
  60. package/runtime/python/okstra_ctl/phases/feature_analysis/validation.py +27 -0
  61. package/runtime/python/okstra_ctl/phases/feature_analysis/wizard.py +95 -0
  62. package/runtime/python/okstra_ctl/phases/final_verification/__init__.py +4 -0
  63. package/runtime/python/okstra_ctl/phases/final_verification/boundary.json +8 -0
  64. package/runtime/python/okstra_ctl/phases/final_verification/entry.py +166 -0
  65. package/runtime/{prompts/profiles/final-verification.md → python/okstra_ctl/phases/final_verification/profile.md} +5 -5
  66. package/runtime/python/okstra_ctl/{report_html/view_models/final_verification.py → phases/final_verification/report.py} +12 -3
  67. package/runtime/{templates/reports → python/okstra_ctl/phases/final_verification/report_assets}/final-verification-input.template.md +1 -1
  68. package/{docs/task-process/final-verification.md → runtime/python/okstra_ctl/phases/final_verification/spec.md} +42 -25
  69. package/runtime/python/okstra_ctl/phases/final_verification/target.py +296 -0
  70. package/runtime/python/okstra_ctl/phases/final_verification/validation.py +190 -0
  71. package/runtime/python/okstra_ctl/phases/final_verification/wizard.py +38 -0
  72. package/runtime/python/okstra_ctl/phases/implementation/__init__.py +1 -0
  73. package/runtime/python/okstra_ctl/phases/implementation/boundary.json +17 -0
  74. package/runtime/python/okstra_ctl/{implementation_stage.py → phases/implementation/entry.py} +22 -10
  75. package/runtime/{prompts/host-orchestration/implementation.md → python/okstra_ctl/phases/implementation/host-rules.md} +1 -1
  76. package/runtime/{prompts/profiles → python/okstra_ctl/phases/implementation/instructions}/_implementation-deliverable.md +1 -1
  77. package/runtime/{prompts/profiles → python/okstra_ctl/phases/implementation/instructions}/_implementation-executor.md +4 -3
  78. package/runtime/{prompts/profiles → python/okstra_ctl/phases/implementation/instructions}/_implementation-verifier.md +4 -4
  79. package/runtime/{prompts/profiles/implementation.md → python/okstra_ctl/phases/implementation/profile.md} +5 -5
  80. package/runtime/python/okstra_ctl/{report_html/view_models/implementation.py → phases/implementation/report.py} +3 -3
  81. package/runtime/{templates/reports → python/okstra_ctl/phases/implementation/report_assets}/implementation-input.template.md +1 -1
  82. package/{docs/task-process/implementation.md → runtime/python/okstra_ctl/phases/implementation/spec.md} +20 -8
  83. package/runtime/python/okstra_ctl/phases/implementation/validation.py +205 -0
  84. package/runtime/python/okstra_ctl/phases/implementation/wizard.py +39 -0
  85. package/runtime/python/okstra_ctl/phases/implementation_option_selection/__init__.py +1 -0
  86. package/runtime/python/okstra_ctl/phases/implementation_option_selection/authoring.py +80 -0
  87. package/runtime/python/okstra_ctl/phases/implementation_option_selection/boundary.json +10 -0
  88. package/runtime/python/okstra_ctl/phases/implementation_option_selection/comparison.py +168 -0
  89. package/runtime/python/okstra_ctl/phases/implementation_option_selection/entry.py +27 -0
  90. package/runtime/{prompts/profiles/implementation-option-selection.md → python/okstra_ctl/phases/implementation_option_selection/profile.md} +2 -2
  91. package/runtime/python/okstra_ctl/{report_html/view_models/implementation_option_selection.py → phases/implementation_option_selection/report.py} +2 -2
  92. package/{docs/task-process/implementation-option-selection.md → runtime/python/okstra_ctl/phases/implementation_option_selection/spec.md} +20 -7
  93. package/runtime/python/okstra_ctl/{implementation_options.py → phases/implementation_option_selection/validation.py} +3 -3
  94. package/runtime/python/okstra_ctl/phases/implementation_option_selection/votes.py +194 -0
  95. package/runtime/python/okstra_ctl/phases/implementation_planning/__init__.py +1 -0
  96. package/runtime/python/okstra_ctl/phases/implementation_planning/authoring.py +2345 -0
  97. package/runtime/python/okstra_ctl/phases/implementation_planning/boundary.json +12 -0
  98. package/runtime/python/okstra_ctl/phases/implementation_planning/entry.py +161 -0
  99. package/runtime/python/okstra_ctl/phases/implementation_planning/guidance.py +178 -0
  100. package/runtime/{prompts/lead → python/okstra_ctl/phases/implementation_planning/instructions}/plan-body-verification.md +61 -51
  101. package/runtime/python/okstra_ctl/phases/implementation_planning/plan_body.py +3295 -0
  102. package/runtime/{prompts/profiles/implementation-planning.md → python/okstra_ctl/phases/implementation_planning/profile.md} +74 -25
  103. package/runtime/python/okstra_ctl/phases/implementation_planning/report.py +237 -0
  104. package/runtime/{templates/reports → python/okstra_ctl/phases/implementation_planning/report_assets}/implementation-planning-input.template.md +2 -2
  105. package/{docs/task-process/implementation-planning.md → runtime/python/okstra_ctl/phases/implementation_planning/spec.md} +30 -6
  106. package/runtime/python/okstra_ctl/phases/implementation_planning/validation.py +597 -0
  107. package/runtime/python/okstra_ctl/phases/implementation_planning/wizard.py +166 -0
  108. package/runtime/python/okstra_ctl/phases/improvement_discovery/boundary.json +12 -0
  109. package/runtime/python/okstra_ctl/{improvement_lenses.py → phases/improvement_discovery/lenses.py} +1 -6
  110. package/runtime/{prompts/profiles/improvement-discovery.md → python/okstra_ctl/phases/improvement_discovery/profile.md} +5 -5
  111. package/runtime/python/okstra_ctl/{report_html/view_models/improvement_discovery.py → phases/improvement_discovery/report.py} +3 -3
  112. package/runtime/{templates/reports → python/okstra_ctl/phases/improvement_discovery/report_assets}/improvement-discovery-input.template.md +1 -2
  113. package/runtime/python/okstra_ctl/phases/improvement_discovery/spec.md +29 -0
  114. package/runtime/{validators/validate_improvement_report.py → python/okstra_ctl/phases/improvement_discovery/validation.py} +5 -14
  115. package/runtime/python/okstra_ctl/phases/project_analysis/__init__.py +1 -0
  116. package/runtime/python/okstra_ctl/phases/project_analysis/boundary.json +8 -0
  117. package/runtime/python/okstra_ctl/phases/project_analysis/entry.py +11 -0
  118. package/runtime/python/okstra_ctl/{report_html/view_models/project_analysis.py → phases/project_analysis/report.py} +3 -3
  119. package/runtime/python/okstra_ctl/phases/project_analysis/spec.md +33 -0
  120. package/runtime/python/okstra_ctl/phases/project_analysis/validation.py +55 -0
  121. package/runtime/python/okstra_ctl/phases/release_handoff/__init__.py +1 -0
  122. package/runtime/python/okstra_ctl/phases/release_handoff/boundary.json +17 -0
  123. package/runtime/python/okstra_ctl/phases/release_handoff/entry.py +147 -0
  124. package/runtime/python/okstra_ctl/phases/release_handoff/operations.py +446 -0
  125. package/runtime/{prompts/profiles/release-handoff.md → python/okstra_ctl/phases/release_handoff/profile.md} +3 -3
  126. package/runtime/python/okstra_ctl/{report_html/view_models/release_handoff.py → phases/release_handoff/report.py} +3 -3
  127. package/runtime/{templates/reports → python/okstra_ctl/phases/release_handoff/report_assets}/release-handoff-input.template.md +1 -1
  128. package/{docs/task-process/release-handoff.md → runtime/python/okstra_ctl/phases/release_handoff/spec.md} +22 -9
  129. package/runtime/python/okstra_ctl/phases/release_handoff/wizard.py +84 -0
  130. package/runtime/python/okstra_ctl/phases/requirements_discovery/__init__.py +1 -0
  131. package/runtime/python/okstra_ctl/phases/requirements_discovery/boundary.json +9 -0
  132. package/runtime/{prompts/profiles/requirements-discovery.md → python/okstra_ctl/phases/requirements_discovery/profile.md} +2 -3
  133. package/runtime/python/okstra_ctl/{report_html/view_models/requirements_discovery.py → phases/requirements_discovery/report.py} +3 -3
  134. package/{docs/task-process/requirements-discovery.md → runtime/python/okstra_ctl/phases/requirements_discovery/spec.md} +25 -6
  135. package/runtime/{validators/validate_fanout.py → python/okstra_ctl/phases/requirements_discovery/validation.py} +11 -12
  136. package/runtime/python/okstra_ctl/phases/technical_verification/__init__.py +1 -0
  137. package/runtime/python/okstra_ctl/phases/technical_verification/boundary.json +9 -0
  138. package/runtime/python/okstra_ctl/phases/technical_verification/entry.py +100 -0
  139. package/runtime/{prompts/profiles/technical-verification.md → python/okstra_ctl/phases/technical_verification/profile.md} +1 -1
  140. package/runtime/python/okstra_ctl/{report_html/view_models/technical_verification.py → phases/technical_verification/report.py} +2 -2
  141. package/runtime/python/okstra_ctl/phases/technical_verification/spec.md +37 -0
  142. package/runtime/python/okstra_ctl/phases/technical_verification/validation.py +90 -0
  143. package/runtime/python/okstra_ctl/plan_approval.py +70 -0
  144. package/runtime/python/okstra_ctl/plan_items_cli.py +2 -2130
  145. package/runtime/python/okstra_ctl/profile_show.py +9 -3
  146. package/runtime/python/okstra_ctl/render.py +9 -2
  147. package/runtime/python/okstra_ctl/render_final_report.py +3 -2
  148. package/runtime/python/okstra_ctl/report_assembly.py +14 -93
  149. package/runtime/python/okstra_ctl/report_html/context_links.py +1 -1
  150. package/runtime/python/okstra_ctl/report_html/render.py +3 -2
  151. package/runtime/python/okstra_ctl/report_html/router.py +9 -33
  152. package/runtime/python/okstra_ctl/report_projections.py +1 -36
  153. package/runtime/python/okstra_ctl/report_routing.py +23 -0
  154. package/runtime/python/okstra_ctl/report_synthesis_packet.py +4 -73
  155. package/runtime/python/okstra_ctl/report_template_loader.py +35 -0
  156. package/runtime/python/okstra_ctl/report_validation_identity.py +38 -0
  157. package/runtime/python/okstra_ctl/report_views.py +18 -2
  158. package/runtime/python/okstra_ctl/run.py +113 -503
  159. package/runtime/python/okstra_ctl/stage_map.py +13 -0
  160. package/runtime/python/okstra_ctl/stage_targets.py +9 -286
  161. package/runtime/python/okstra_ctl/technical_verification_facts.py +52 -0
  162. package/runtime/python/okstra_ctl/user_response.py +199 -4
  163. package/runtime/python/okstra_ctl/verification_target.py +1 -1
  164. package/runtime/python/okstra_ctl/wizard/__init__.py +31 -31
  165. package/runtime/python/okstra_ctl/wizard/api.py +18 -0
  166. package/runtime/python/okstra_ctl/wizard/outcome.py +3 -12
  167. package/runtime/python/okstra_ctl/wizard/registry.py +20 -12
  168. package/runtime/python/okstra_ctl/wizard/state.py +6 -2
  169. package/runtime/python/okstra_ctl/wizard/steps_analysis.py +0 -97
  170. package/runtime/python/okstra_ctl/wizard/steps_plan.py +27 -278
  171. package/runtime/python/okstra_ctl/wizard/steps_roles.py +2 -1
  172. package/runtime/python/okstra_ctl/work_categories.py +1 -1
  173. package/runtime/python/okstra_ctl/worker_prompt_contract.py +36 -0
  174. package/runtime/python/okstra_ctl/workflow.py +26 -143
  175. package/runtime/skills/okstra-brief-gen/SKILL.md +3 -3
  176. package/runtime/skills/okstra-run/SKILL.md +1 -1
  177. package/runtime/skills/okstra-user-response/SKILL.md +23 -4
  178. package/runtime/templates/reports/quick-input.template.md +1 -1
  179. package/runtime/templates/reports/task-brief.template.md +1 -1
  180. package/runtime/validators/validate-brief.py +2 -2
  181. package/runtime/validators/validate-run.py +287 -4091
  182. package/runtime/validators/validate_analysis_report.py +14 -126
  183. package/docs/task-process/README.md +0 -82
  184. package/docs/task-process/common-flow.md +0 -173
  185. package/runtime/python/okstra_ctl/report_html/view_models/implementation_planning.py +0 -147
  186. package/runtime/python/okstra_ctl/technical_verification.py +0 -195
  187. /package/runtime/{prompts/profiles/change-impact-analysis.json → python/okstra_ctl/phases/change_impact_analysis/profile.json} +0 -0
  188. /package/runtime/{prompts/profiles/change-impact-analysis.md → python/okstra_ctl/phases/change_impact_analysis/profile.md} +0 -0
  189. /package/runtime/{templates/reports → python/okstra_ctl/phases/change_impact_analysis/report_assets}/change-impact-analysis-input.template.md +0 -0
  190. /package/runtime/{templates/reports/html/tasks → python/okstra_ctl/phases/change_impact_analysis/report_assets}/change-impact-analysis.template.html +0 -0
  191. /package/runtime/{templates/reports/md/tasks → python/okstra_ctl/phases/change_impact_analysis/report_assets}/change-impact-analysis.template.md +0 -0
  192. /package/runtime/{prompts/profiles/error-analysis.json → python/okstra_ctl/phases/error_analysis/profile.json} +0 -0
  193. /package/runtime/{templates/reports/html/tasks → python/okstra_ctl/phases/error_analysis/report_assets}/error-analysis.template.html +0 -0
  194. /package/runtime/{templates/reports/md/tasks → python/okstra_ctl/phases/error_analysis/report_assets}/error-analysis.template.md +0 -0
  195. /package/runtime/{prompts/profiles/feature-analysis.json → python/okstra_ctl/phases/feature_analysis/profile.json} +0 -0
  196. /package/runtime/{prompts/profiles/feature-analysis.md → python/okstra_ctl/phases/feature_analysis/profile.md} +0 -0
  197. /package/runtime/{templates/reports → python/okstra_ctl/phases/feature_analysis/report_assets}/feature-analysis-input.template.md +0 -0
  198. /package/runtime/{templates/reports/html/tasks → python/okstra_ctl/phases/feature_analysis/report_assets}/feature-analysis.template.html +0 -0
  199. /package/runtime/{templates/reports/md/tasks → python/okstra_ctl/phases/feature_analysis/report_assets}/feature-analysis.template.md +0 -0
  200. /package/runtime/{prompts/profiles/final-verification.json → python/okstra_ctl/phases/final_verification/profile.json} +0 -0
  201. /package/runtime/{templates/reports/html/tasks → python/okstra_ctl/phases/final_verification/report_assets}/final-verification.template.html +0 -0
  202. /package/runtime/{templates/reports/md/tasks → python/okstra_ctl/phases/final_verification/report_assets}/final-verification.template.md +0 -0
  203. /package/runtime/{prompts/profiles → python/okstra_ctl/phases/implementation/instructions}/_implementation-diff-review.md +0 -0
  204. /package/runtime/{prompts/profiles → python/okstra_ctl/phases/implementation/instructions}/_implementation-self-check.md +0 -0
  205. /package/runtime/{prompts/profiles/implementation.json → python/okstra_ctl/phases/implementation/profile.json} +0 -0
  206. /package/runtime/{templates/reports/html/tasks → python/okstra_ctl/phases/implementation/report_assets}/implementation.template.html +0 -0
  207. /package/runtime/{templates/reports/md/tasks → python/okstra_ctl/phases/implementation/report_assets}/implementation.template.md +0 -0
  208. /package/runtime/{prompts/profiles/implementation-option-selection.json → python/okstra_ctl/phases/implementation_option_selection/profile.json} +0 -0
  209. /package/runtime/{templates/reports/html/tasks → python/okstra_ctl/phases/implementation_option_selection/report_assets}/implementation-option-selection.template.html +0 -0
  210. /package/runtime/{templates/reports/md/tasks → python/okstra_ctl/phases/implementation_option_selection/report_assets}/implementation-option-selection.template.md +0 -0
  211. /package/runtime/{prompts/host-orchestration/implementation-planning.md → python/okstra_ctl/phases/implementation_planning/host-rules.md} +0 -0
  212. /package/runtime/{prompts/profiles/implementation-planning.json → python/okstra_ctl/phases/implementation_planning/profile.json} +0 -0
  213. /package/runtime/{templates/reports/html/tasks → python/okstra_ctl/phases/implementation_planning/report_assets}/implementation-planning.template.html +0 -0
  214. /package/runtime/{templates/reports/md/tasks → python/okstra_ctl/phases/implementation_planning/report_assets}/implementation-planning.template.md +0 -0
  215. /package/runtime/{prompts/profiles/improvement-discovery.json → python/okstra_ctl/phases/improvement_discovery/profile.json} +0 -0
  216. /package/runtime/{templates/reports/html/tasks → python/okstra_ctl/phases/improvement_discovery/report_assets}/improvement-discovery.template.html +0 -0
  217. /package/runtime/{templates/reports/md/tasks → python/okstra_ctl/phases/improvement_discovery/report_assets}/improvement-discovery.template.md +0 -0
  218. /package/runtime/{prompts/profiles/project-analysis.json → python/okstra_ctl/phases/project_analysis/profile.json} +0 -0
  219. /package/runtime/{prompts/profiles/project-analysis.md → python/okstra_ctl/phases/project_analysis/profile.md} +0 -0
  220. /package/runtime/{templates/reports → python/okstra_ctl/phases/project_analysis/report_assets}/project-analysis-input.template.md +0 -0
  221. /package/runtime/{templates/reports/html/tasks → python/okstra_ctl/phases/project_analysis/report_assets}/project-analysis.template.html +0 -0
  222. /package/runtime/{templates/reports/md/tasks → python/okstra_ctl/phases/project_analysis/report_assets}/project-analysis.template.md +0 -0
  223. /package/runtime/{prompts/profiles/release-handoff.json → python/okstra_ctl/phases/release_handoff/profile.json} +0 -0
  224. /package/runtime/{templates/reports/html/tasks → python/okstra_ctl/phases/release_handoff/report_assets}/release-handoff.template.html +0 -0
  225. /package/runtime/{templates/reports/md/tasks → python/okstra_ctl/phases/release_handoff/report_assets}/release-handoff.template.md +0 -0
  226. /package/runtime/python/okstra_ctl/{fanout.py → phases/requirements_discovery/fanout.py} +0 -0
  227. /package/runtime/{prompts/profiles/requirements-discovery.json → python/okstra_ctl/phases/requirements_discovery/profile.json} +0 -0
  228. /package/runtime/{templates/reports → python/okstra_ctl/phases/requirements_discovery/report_assets}/fan-out-unit.template.md +0 -0
  229. /package/runtime/{templates/reports/html/tasks → python/okstra_ctl/phases/requirements_discovery/report_assets}/requirements-discovery.template.html +0 -0
  230. /package/runtime/{templates/reports/md/tasks → python/okstra_ctl/phases/requirements_discovery/report_assets}/requirements-discovery.template.md +0 -0
  231. /package/runtime/{prompts/profiles/technical-verification.json → python/okstra_ctl/phases/technical_verification/profile.json} +0 -0
  232. /package/runtime/{templates/reports/html/tasks → python/okstra_ctl/phases/technical_verification/report_assets}/technical-verification.template.html +0 -0
  233. /package/runtime/{templates/reports/md/tasks → python/okstra_ctl/phases/technical_verification/report_assets}/technical-verification.template.md +0 -0
@@ -0,0 +1,3295 @@
1
+ """구현 계획 본문·재검증·승인 근거의 전용 판정."""
2
+
3
+ from __future__ import annotations
4
+ from okstra_ctl.json_boundary import JsonBoundaryError, load_owned_object
5
+
6
+
7
+ import re
8
+
9
+
10
+ from pathlib import Path
11
+
12
+
13
+ from okstra_ctl.conformance import ( # noqa: E402
14
+ declared_stage_surface_gaps,
15
+ exempt_stage_surface_conflicts,
16
+ parse_conformance_tests as _parse_conformance_tests,
17
+ )
18
+
19
+
20
+ from okstra_ctl.build_tools import ( # noqa: E402
21
+ command_invokes_build_tool,
22
+ resolve_build_tool_tokens,
23
+ )
24
+
25
+
26
+ from okstra_ctl.clarification_items import ( # noqa: E402
27
+ clarification_disposition,
28
+ incorporated_clarification_ids,
29
+ row_blocks_progress,
30
+ )
31
+
32
+
33
+ from okstra_ctl.final_report_paths import ( # noqa: E402
34
+ final_report_data_path as _data_path_for,
35
+ )
36
+
37
+
38
+ from okstra_ctl.plan_items import ( # noqa: E402
39
+ CRITIC_WORKER_ID,
40
+ advisory_plan_body_gating,
41
+ requires_plan_repair,
42
+ analyser_key as _analyser_key,
43
+ is_critic_worker,
44
+ lead_decision_basis,
45
+ self_fix_rounds,
46
+ stage_scope_bucket as _item_stage_scope_bucket,
47
+ voting_analyser_keys,
48
+ )
49
+
50
+
51
+ from okstra_ctl.incremental_scope import ( # noqa: E402
52
+ coverage_row_blocked_on,
53
+ stages_for_clarification,
54
+ )
55
+
56
+
57
+ from okstra_ctl.design_prep import ( # noqa: E402
58
+ DesignPrepError,
59
+ _planning_seq as _design_prep_planning_seq,
60
+ _render_request as _render_design_prep_request,
61
+ _report_language as _design_prep_report_language,
62
+ _request_identity as _design_prep_request_identity,
63
+ )
64
+
65
+
66
+ from okstra_ctl.design_surfaces import DesignSurfaceError # noqa: E402
67
+
68
+
69
+ from okstra_ctl.plan_items import ( # noqa: E402
70
+ expected_plan_item_ids,
71
+ extract_plan_items,
72
+ )
73
+
74
+
75
+ from okstra_ctl.report_validation_identity import _report_run_seq, _report_task_type
76
+
77
+ # Plan-body gate outcomes ranked by how favorable each is to approval.
78
+ # A higher rank claims a healthier verification result. The recompute check
79
+ # below fails only when the *declared* gate outranks what the recorded
80
+ # per-worker verdicts support — i.e. the lead claimed a better outcome than
81
+ # the votes justify. A lead writing a conservatively *worse* gate is allowed,
82
+ # so genuine edge cases in this recompute never manufacture false failures.
83
+ _PLAN_GATE_RANK = {
84
+ "aborted-non-result": 0,
85
+ "blocked-by-disagreement": 0,
86
+ "passed-with-dissent": 1,
87
+ "passed": 2,
88
+ }
89
+
90
+
91
+ # Breakage kinds where a single DISAGREE blocks the gate on its own (no majority
92
+ # needed), because the defect is concrete, safety-critical, and adversarially
93
+ # verifiable: `a` = cited path/symbol mismatch. `b`/`c`/`e` still need a
94
+ # majority — `b` in particular is prone to planning-vs-implementation
95
+ # environment false positives.
96
+ _SINGLE_VOTE_BLOCKING_KINDS = {"a"}
97
+
98
+
99
+ # Rollback ordering (`d`) is executed by a human, not by okstra's workers or
100
+ # verifiers, so a rollback-ordering dissent is recorded but never gates
101
+ # approval: it is dropped from the blocking-disagree tally entirely, so an item
102
+ # whose only DISAGREEs are advisory-only can never rise above `has-dissent`.
103
+ _ADVISORY_ONLY_KINDS = {"d"}
104
+
105
+
106
+ # Stop reasons that justify promoting a still-broken planner-fixable item to the
107
+ # user: the self-fix budget ran out, or a round produced no net resolution so
108
+ # further rounds would repeat themselves.
109
+ #
110
+ # `cause-group-recurrence` is the legacy spelling of that same exhaustion
111
+ # (plan-body-verification.md "Loop termination"). A pre-activity-contract report
112
+ # carrying it is a report whose loop stopped because the cause kept recurring —
113
+ # refusing it here left such a run with no exit at all: the loop may not run
114
+ # again, and the surviving item may not be promoted either.
115
+ _SELF_FIX_EXHAUSTED_REASONS = frozenset(
116
+ {"max-rounds-reached", "no-progress", "cause-group-recurrence"}
117
+ )
118
+
119
+
120
+ def _is_variation_point_item(item: dict) -> bool:
121
+ """Whether this is a `P-Var-*` variation-point item, which is majority-gated
122
+ (`prompts/lead/plan-body-verification.md` "`P-Var-<N>` … is majority-gated").
123
+ Whether a behavior has two implementations, and whether the plan extracted the
124
+ right interface for it, is a design judgement — it lacks the concrete certainty
125
+ of kind `a`, where a verifier points at two spelled-out references that
126
+ contradict each other. So kind `a` carries no extra weight on a P-Var item: it
127
+ neither single-vote-blocks nor counts as correctness-critical, exactly like the
128
+ `b` / `c` / `e` kinds the prompt routes P-Var defects to. Only a
129
+ `majority-disagree` gates it — that part is unchanged.
130
+ """
131
+ return str(item.get("id") or "").upper().startswith("P-VAR")
132
+
133
+
134
+ def _single_vote_dissents(item: dict, kinds: set[str]) -> list[dict]:
135
+ """이 항목에서 1표 차단을 주장하는 DISAGREE 행들."""
136
+ return [
137
+ row
138
+ for row in (item.get("verdicts") or [])
139
+ if isinstance(row, dict)
140
+ and str(row.get("verdict") or "").strip().upper() == "DISAGREE"
141
+ and str(row.get("breakageKind") or "").strip().lower() in kinds
142
+ ]
143
+
144
+
145
+ def _single_vote_block_survives(item: dict, kinds: set[str]) -> bool:
146
+ """1표 차단이 성립하는지.
147
+
148
+ 1표 차단에는 근거가 있다 — 명시된 두 인용이 서로 모순이라는 것은 한 명이
149
+ 실측으로 확정할 수 있는 사실이고, 사실을 다수결로 기각하면 안 된다. 문제는
150
+ 1표라는 것이 아니라 **1표에 재현 요구가 없었다**는 것이다. "이 경로는 존재하지
151
+ 않는다" 라고 쓰기만 하면 그대로 차단이 됐다.
152
+
153
+ 이제 주장이 스스로 `fact` 를 선언하고 okstra 가 그것을 재현했을 때만 1표로
154
+ 막는다. 선언했는데 재현되지 않았거나 `judgement` 였다면 정족수로 내려간다.
155
+
156
+ 아무 행도 `claimKind` 를 선언하지 않았으면 종전대로 막는다. 그 필드를 실을 수
157
+ 없던 시절의 판정을 뒤에서 뒤집지 않기 위해서다 — 도입은 완화 방향으로만
158
+ 작동하고, 선언한 주장만 재현을 요구받는다.
159
+ """
160
+ dissents = _single_vote_dissents(item, kinds)
161
+ declared = [row for row in dissents if row.get("claimKind")]
162
+ if not declared:
163
+ return bool(dissents)
164
+ return any(
165
+ str(row.get("claimKind") or "") == "fact"
166
+ and str(row.get("reproductionResult") or "") == "reproduced"
167
+ for row in declared
168
+ )
169
+
170
+
171
+ def _critic_non_error_verdicts(item: dict) -> list[dict]:
172
+ """현재 기록된 비판 검토자의 최신 유효 판정."""
173
+ rows = [row for row in item.get("verdicts", []) if isinstance(row, dict)]
174
+ critic = [
175
+ row
176
+ for row in rows
177
+ if is_critic_worker(row.get("worker", ""))
178
+ and str(row.get("verdict", "")).upper() in {"AGREE", "SUPPLEMENT", "DISAGREE"}
179
+ ]
180
+ latest = max((row.get("round", 1) for row in critic), default=0)
181
+ return [row for row in critic if row.get("round", 1) == latest]
182
+
183
+
184
+ def _critic_gate_class(item: dict) -> str | None:
185
+ """비판 검토자의 교정 권한은 분석자의 표수나 동수 여부에 의존하지 않는다."""
186
+ critic = _critic_non_error_verdicts(item)
187
+ if not critic:
188
+ return None
189
+ critic_dissent = [
190
+ row for row in critic if str(row.get("verdict", "")).upper() == "DISAGREE"
191
+ ]
192
+ if critic_dissent:
193
+ if str(item.get("id", "")).upper().startswith("P-RB"):
194
+ return "has-dissent"
195
+ return (
196
+ "majority-disagree"
197
+ if any(
198
+ str(row.get("breakageKind", "")).lower() not in _ADVISORY_ONLY_KINDS
199
+ for row in critic_dissent
200
+ )
201
+ else "has-dissent"
202
+ )
203
+ dissent = any(
204
+ str(row.get("verdict", "")).upper() == "DISAGREE"
205
+ for row in item.get("verdicts", [])
206
+ )
207
+ return "has-dissent" if dissent else "full-consensus"
208
+
209
+
210
+ def _classify_plan_item_gate(item: dict) -> str:
211
+ """Recompute one plan item's gate class from its per-worker verdicts,
212
+ per `prompts/lead/plan-body-verification.md` "Round protocol". Returns one of
213
+ ``majority-disagree`` / ``needs-reverify`` / ``has-dissent`` /
214
+ ``full-consensus`` / ``all-non-result``. Blocking-kind minority dissent
215
+ (``dissent-isolated`` / ``partial-consensus`` on ``b``/``c``/``e``) is
216
+ ``majority-disagree`` so the user gate sees it. ``has-dissent`` remains
217
+ advisory-only, rollback items, and a single-vote kind that lost its
218
+ reproduction. An analyser 1-1 is ``needs-reverify`` until ``critic-worker``
219
+ settles it.
220
+ """
221
+ corrected = _critic_gate_class(item)
222
+ if corrected is not None:
223
+ return corrected
224
+ tokens = [
225
+ (
226
+ str(v.get("verdict") or "").strip().upper(),
227
+ str(v.get("breakageKind") or "").strip().lower(),
228
+ )
229
+ for v in (item.get("verdicts") or [])
230
+ if isinstance(v, dict) and not is_critic_worker(str(v.get("worker") or ""))
231
+ ]
232
+ non_error = [(vd, bk) for (vd, bk) in tokens if vd and vd != "VERIFICATION-ERROR"]
233
+ if not non_error:
234
+ return "all-non-result"
235
+ disagree = [(vd, bk) for (vd, bk) in non_error if vd == "DISAGREE"]
236
+ agree = [(vd, bk) for (vd, bk) in non_error if vd in ("AGREE", "SUPPLEMENT")]
237
+ if not disagree:
238
+ return "full-consensus"
239
+ # Rollback is a human-run operation, so rollback dissent never blocks the
240
+ # gate — closed from two angles so a verifier cannot re-block it by relabelling:
241
+ # (1) a whole rollback plan item (`P-Rb-*`) is advisory regardless of
242
+ # breakage kind — otherwise a `DISAGREE(b)` "rollback command is
243
+ # ambiguous" would sail past the kind-`d` exemption and block;
244
+ # (2) a rollback-ordering dissent (`d`) is advisory on ANY item, since a
245
+ # rollback-order defect raised against a non-rollback item is still a
246
+ # human-run concern.
247
+ # Both are recorded as dissent and fold into `has-dissent`, never blocking.
248
+ if str(item.get("id") or "").upper().startswith("P-RB"):
249
+ return "has-dissent"
250
+ blocking_disagree = [
251
+ (vd, bk) for (vd, bk) in disagree if bk not in _ADVISORY_ONLY_KINDS
252
+ ]
253
+ if not blocking_disagree:
254
+ return "has-dissent"
255
+ blocking_kinds = {bk for (_vd, bk) in blocking_disagree if bk}
256
+ # Single-vote-blocking kinds: one confirmed DISAGREE on a concrete,
257
+ # safety-critical, adversarially-verifiable defect is enough to block, even
258
+ # in a two-worker roster — a lone correct dissent must not be outvoted here.
259
+ # `a` for any item except `P-Var-*` (majority-gated, see
260
+ # `_is_variation_point_item`); `f` only for P-Req items (requirement coverage).
261
+ is_req = str(item.get("id") or "").upper().startswith("P-REQ")
262
+ single_vote_kinds = set(_SINGLE_VOTE_BLOCKING_KINDS) | ({"f"} if is_req else set())
263
+ if (
264
+ not _is_variation_point_item(item)
265
+ and blocking_kinds & single_vote_kinds
266
+ and _single_vote_block_survives(item, single_vote_kinds)
267
+ ):
268
+ # "One confirmed DISAGREE" presupposes the item was actually
269
+ # cross-verified. When the peer returned a non-result nothing confirmed
270
+ # the dissent, so blocking here would reproduce the same
271
+ # worker-failure-makes-the-gate-stricter paradox the majority branch
272
+ # below guards against. Route it to a re-verify round instead.
273
+ if len(non_error) < 2:
274
+ return "needs-reverify"
275
+ return "majority-disagree"
276
+ # Otherwise a genuine majority is required — and a majority needs at least
277
+ # two participating votes, so a lone surviving DISAGREE (its peer returned a
278
+ # non-result) does NOT block. That fixes the paradox where a worker failure
279
+ # made the gate stricter than a healthy roster would.
280
+ if len(non_error) >= 2 and len(blocking_disagree) > len(agree):
281
+ return "majority-disagree"
282
+ if len(blocking_disagree) == len(agree) and len(non_error) >= 2:
283
+ return "needs-reverify"
284
+ if (
285
+ len(non_error) >= 2
286
+ and blocking_disagree
287
+ and (not (blocking_kinds & single_vote_kinds) or _is_variation_point_item(item))
288
+ ):
289
+ # 판단 종류의 소수 반대는 표로 기각하지 않는다. 양쪽이 표를 냈으면
290
+ # 사용자가 고른다. 재현에 실패한 1표 종류 `a`/`f` 는 위에서 이미
291
+ # 근거를 잃었으므로 이 분기에 안 들어온다.
292
+ return "majority-disagree"
293
+ return "has-dissent"
294
+
295
+
296
+ def _is_even_analyser_split(item: dict) -> bool:
297
+ tokens = [
298
+ str(row.get("verdict") or "").strip().upper()
299
+ for row in (item.get("verdicts") or [])
300
+ if isinstance(row, dict)
301
+ and not is_critic_worker(str(row.get("worker") or ""))
302
+ and str(row.get("verdict") or "").strip().upper()
303
+ not in ("", "VERIFICATION-ERROR")
304
+ ]
305
+ if len(tokens) < 2:
306
+ return False
307
+ disagree = sum(1 for token in tokens if token == "DISAGREE")
308
+ agree = sum(1 for token in tokens if token in {"AGREE", "SUPPLEMENT"})
309
+ return disagree == agree and disagree > 0
310
+
311
+
312
+ def _is_unsettled_tie(item: dict) -> bool:
313
+ """분석자는 갈렸고 critic 표가 아직 없는 동수 항목."""
314
+ return _is_even_analyser_split(item) and not _critic_non_error_verdicts(item)
315
+
316
+
317
+ def _disagree_breakage_kinds(item: dict) -> set[str]:
318
+ return {
319
+ str(v.get("breakageKind") or "").strip().lower()
320
+ for v in (item.get("verdicts") or [])
321
+ if isinstance(v, dict)
322
+ and str(v.get("verdict") or "").strip().upper() == "DISAGREE"
323
+ and str(v.get("breakageKind") or "").strip()
324
+ }
325
+
326
+
327
+ def _has_planner_fixable_majority(item: dict) -> bool:
328
+ disagrees = [
329
+ v
330
+ for v in (item.get("verdicts") or [])
331
+ if isinstance(v, dict) and str(v.get("verdict") or "").upper() == "DISAGREE"
332
+ ]
333
+ fixable = [v for v in disagrees if v.get("fixability") == "planner-fixable"]
334
+ return bool(disagrees) and len(fixable) * 2 > len(disagrees)
335
+
336
+
337
+ def _is_correctness_critical(item: dict) -> bool:
338
+ """Whether this item's defect would make `implementation` produce wrong or
339
+ unsafe code — the single-vote-blocking kind `a` (cited path/symbol mismatch)
340
+ on any item but `P-Var-*`, or `f` (requirement-coverage mismatch) on a
341
+ `P-Req-*` item.
342
+ Kinds `b`/`c`/`e` are plan-prose defects: they degrade the document, not the
343
+ resulting code. Rollback ordering (`d`) is advisory — a human runs the
344
+ rollback — so it never counts as correctness-critical. A `P-Var-*` item is
345
+ majority-gated end to end, so a kind-`a` dissent on one is no more critical
346
+ than the `b`/`e` its defect should have been raised under; otherwise the same
347
+ mis-tag that no longer single-vote-blocks would still veto the downgrade.
348
+ """
349
+ if _is_variation_point_item(item):
350
+ return False
351
+ kinds = _disagree_breakage_kinds(item)
352
+ is_req = str(item.get("id") or "").upper().startswith("P-REQ")
353
+ return bool(kinds & _SINGLE_VOTE_BLOCKING_KINDS) or (is_req and "f" in kinds)
354
+
355
+
356
+ def _self_fix_budget_exhausted(pbv: dict) -> bool:
357
+ rounds_applied = pbv.get("selfFixRoundsApplied")
358
+ return (
359
+ isinstance(rounds_applied, int)
360
+ and rounds_applied >= 1
361
+ and pbv.get("selfFixStopReason") in _SELF_FIX_EXHAUSTED_REASONS
362
+ )
363
+
364
+
365
+ def _state_classification(item: dict, gate_class: str) -> str:
366
+ """This item's `planItems[].rounds[].classification` for the state file.
367
+
368
+ Blocking-kind `dissent-isolated` / `partial-consensus` is already
369
+ `majority-disagree` at the gate. `has-dissent` that remains is advisory
370
+ or a single-vote kind that lost reproduction; the state file then splits
371
+ that remainder into `dissent-isolated` vs `partial-consensus`.
372
+
373
+ *gate_class* is passed in rather than recomputed so that the caller's
374
+ effective classification — which may have been downgraded by
375
+ `_is_dissent_downgraded` — is the one this translates.
376
+
377
+ `contested` never appears: it is only meaningful at `maxRounds > 1`, and at
378
+ the default `maxRounds=1` the round protocol folds any otherwise-unresolved
379
+ item into `partial-consensus`.
380
+ """
381
+ if gate_class == "all-non-result":
382
+ # No non-error vote at all is the `needs-reverify` shape taken to its
383
+ # limit — "fewer than 2 participating votes" covers zero.
384
+ return "needs-reverify"
385
+ if gate_class != "has-dissent":
386
+ return gate_class
387
+ dissenting = sum(
388
+ 1
389
+ for vote in (item.get("verdicts") or [])
390
+ if isinstance(vote, dict)
391
+ and str(vote.get("verdict") or "").strip().upper() == "DISAGREE"
392
+ )
393
+ return "dissent-isolated" if dissenting == 1 else "partial-consensus"
394
+
395
+
396
+ def _clarification_ids_on_activity(activity: dict) -> set[str]:
397
+ refs: set[str] = set()
398
+ for key in ("clarificationRefs", "evidenceRefs"):
399
+ for value in activity.get(key) or []:
400
+ if isinstance(value, str) and _APPROVAL_CLARIFICATION_ID_RE.fullmatch(
401
+ value
402
+ ):
403
+ refs.add(value)
404
+ return refs
405
+
406
+
407
+ def _plan_item_ids_for_clarification(
408
+ row: dict,
409
+ context: dict,
410
+ data: dict,
411
+ ) -> list[str]:
412
+ """이 C 행이 가리키는 계획 항목.
413
+
414
+ 계약 3.0 `approvalContext` 는 `planItemIds` 를 갖지 않는다. 활동
415
+ `evidenceRefs` / `clarificationRefs` 와 `planItems[].clarificationRefs` 가
416
+ 역추적이다. 이 C 만 인용한 활동을 묶음 활동보다 앞세운다.
417
+ """
418
+ linked = [
419
+ item_id
420
+ for item_id in (context.get("planItemIds") or [])
421
+ if isinstance(item_id, str) and item_id
422
+ ]
423
+ if linked:
424
+ return linked
425
+ row_id = str(row.get("id") or "")
426
+ if not row_id:
427
+ return []
428
+ singleton: list[str] = []
429
+ bulk: list[str] = []
430
+ for activity in data.get("agentActivity") or []:
431
+ if not isinstance(activity, dict):
432
+ continue
433
+ refs = _clarification_ids_on_activity(activity)
434
+ if row_id not in refs:
435
+ continue
436
+ ids = [
437
+ item_id
438
+ for item_id in (activity.get("planItemIds") or [])
439
+ if isinstance(item_id, str) and item_id
440
+ ]
441
+ if refs == {row_id}:
442
+ singleton.extend(ids)
443
+ else:
444
+ bulk.extend(ids)
445
+ if singleton or bulk:
446
+ return singleton or bulk
447
+ items = (
448
+ (data.get("implementationPlanning") or {}).get("planBodyVerification") or {}
449
+ ).get("planItems") or []
450
+ return [
451
+ str(item.get("id") or "")
452
+ for item in items
453
+ if isinstance(item, dict)
454
+ and row_id
455
+ in {
456
+ ref for ref in (item.get("clarificationRefs") or []) if isinstance(ref, str)
457
+ }
458
+ and item.get("id")
459
+ ]
460
+
461
+
462
+ def _user_accepted_plan_item_ids(data: dict) -> set[str]:
463
+ """사용자가 진행 처분을 고른 승인 행이 가리키는 계획 항목.
464
+
465
+ DISAGREE 표는 그대로 남는다. 게이트만 `has-dissent` 로 내린다.
466
+ """
467
+ accepted: set[str] = set()
468
+ for row in data.get("clarificationItems") or []:
469
+ if not isinstance(row, dict) or row.get("blocks") != "approval":
470
+ continue
471
+ if row_blocks_progress(
472
+ str(row.get("status") or ""), clarification_disposition(row)
473
+ ):
474
+ continue
475
+ context = row.get("approvalContext")
476
+ if not isinstance(context, dict):
477
+ context = {}
478
+ accepted.update(_plan_item_ids_for_clarification(row, context, data))
479
+ return accepted
480
+
481
+
482
+ def _resolved_noncritical_dissent_ids(data: dict) -> set[str]:
483
+ """호환 별칭. 새 코드는 `_user_accepted_plan_item_ids` 를 쓴다."""
484
+ return _user_accepted_plan_item_ids(data)
485
+
486
+
487
+ def _plan_item_decision_authority(item: dict, pbv: dict) -> str | None:
488
+ """자동 수정 이후의 설계 판단만 리드가 결정하며 사실·사용자 권한은 남긴다."""
489
+ classification = _classify_plan_item_gate(item)
490
+ votes = [row for row in item.get("verdicts", []) if isinstance(row, dict)]
491
+ non_result = any(
492
+ row.get("verdict") not in {"AGREE", "SUPPLEMENT", "DISAGREE"} for row in votes
493
+ )
494
+ if (
495
+ classification not in {"majority-disagree", "needs-reverify", "all-non-result"}
496
+ and not non_result
497
+ ):
498
+ return None
499
+ if _stage_scope_bucket(item, pbv) != "in-scope" or item.get("block") == "record":
500
+ return None
501
+ disagrees = [row for row in votes if row.get("verdict") == "DISAGREE"]
502
+ verified = item.get("contentHash")
503
+ if (
504
+ not self_fix_rounds(pbv)
505
+ or pbv.get("gating") is False
506
+ or not verified
507
+ or item.get("verifiedContentHash") != verified
508
+ or _is_correctness_critical(item)
509
+ or not disagrees
510
+ or len(voting_analyser_keys([item])) < 2
511
+ or non_result
512
+ or any(
513
+ row.get("claimKind") not in {None, "judgement"}
514
+ or row.get("fixability") != "planner-fixable"
515
+ or row.get("breakageKind") not in {"b", "c", "e"}
516
+ for row in disagrees
517
+ )
518
+ ):
519
+ return "user"
520
+ return "lead"
521
+
522
+
523
+ def _lead_decision_applies(item: dict, pbv: dict) -> bool:
524
+ decision = item.get("leadDecision")
525
+ return (
526
+ isinstance(decision, dict)
527
+ and bool(str(decision.get("decision") or "").strip())
528
+ and decision.get("basisHash") == lead_decision_basis(item)
529
+ and _plan_item_decision_authority(item, pbv) == "lead"
530
+ )
531
+
532
+
533
+ def _is_dissent_downgraded(
534
+ item: dict,
535
+ pbv: dict,
536
+ accepted_item_ids: set[str],
537
+ ) -> bool:
538
+ """유효한 리드 결정 또는 사용자 진행 처분은 반대 표를 보존하며 차단을 해소한다."""
539
+ return _lead_decision_applies(item, pbv) or (
540
+ _classify_plan_item_gate(item) == "majority-disagree"
541
+ and str(item.get("id") or "") in accepted_item_ids
542
+ )
543
+
544
+
545
+ def _stage_scope_bucket(item: dict, pbv: dict) -> str:
546
+ """Whether this item has standing to block the stage about to start.
547
+
548
+ The plan covers every stage; implementation runs one at a time. Judging all
549
+ of them at once means a defect in a stage nobody has reached, or in one
550
+ already frozen, stops the next stage from starting — and a frozen stage's
551
+ item cannot be fixed at all, because the Stage Ledger forbids editing its
552
+ commands. Measured on one run, 9 of 13 blockers were that shape, 6 of them
553
+ frozen.
554
+
555
+ Returns `in-scope` (may block), `observed` (only frozen stages), or
556
+ `deferred` (only stages not yet startable). Anything unresolvable is
557
+ `in-scope`: an absent ledger is no basis to narrow. Plan-wide items
558
+ (`P-Opt-*`, `P-Var-*`, `P-Dep-*`, `P-Dir-1`) with no `stageScope` stay
559
+ in-scope. An unscoped `P-Val-*` / `P-Req-*` / `P-Rb-*` stays in-scope only
560
+ until a stage is `done`; after that it is `deferred` so a re-plan does not
561
+ re-score the whole checklist.
562
+
563
+ 디스패치 큐와 같은 함수를 쓴다. 검증기가 다른 통을 내면 워커가 안 본
564
+ 항목이 승인을 막거나, 본 항목이 게이트에서 빠진다.
565
+ """
566
+ ledger = pbv.get("stageLedger")
567
+ return _item_stage_scope_bucket(
568
+ item,
569
+ ledger if isinstance(ledger, dict) else None,
570
+ )
571
+
572
+
573
+ def _set_aside_reason(item: dict, pbv: dict, accepted_item_ids: set[str]) -> str | None:
574
+ """Why this item stopped blocking, or ``None`` if it never did.
575
+
576
+ A gate that passes while defects were set aside has to say which ones and on
577
+ what grounds. Without that the two halves of the acceptance condition — the
578
+ next stage can start, and the known risks are written down — collapse into
579
+ the first, and a defect deferred for a good reason is indistinguishable in
580
+ the record from one nobody found.
581
+ """
582
+ raw = (
583
+ "has-dissent"
584
+ if _is_dissent_downgraded(item, pbv, accepted_item_ids)
585
+ else _classify_plan_item_gate(item)
586
+ )
587
+ if raw != "majority-disagree":
588
+ return None
589
+ bucket = _stage_scope_bucket(item, pbv)
590
+ if bucket != "in-scope":
591
+ return bucket
592
+ return "record" if str(item.get("block") or "") == "record" else None
593
+
594
+
595
+ def _set_aside_register(pbv: dict, accepted_item_ids: set[str]) -> list[dict]:
596
+ """Every set-aside item, in id order, as the gate records them."""
597
+ register = [
598
+ {"id": str(item.get("id") or ""), "reason": reason}
599
+ for item in (pbv.get("planItems") or [])
600
+ if isinstance(item, dict)
601
+ for reason in [_set_aside_reason(item, pbv, accepted_item_ids)]
602
+ if reason is not None
603
+ ]
604
+ return sorted(register, key=lambda row: row["id"])
605
+
606
+
607
+ def _plan_item_gate_class(
608
+ item: dict,
609
+ pbv: dict,
610
+ accepted_item_ids: set[str],
611
+ ) -> str:
612
+ """The gate class for one item, after stage scope is applied.
613
+
614
+ An out-of-scope blocker is not dropped — it lands on `has-dissent`, so the
615
+ gate still reads `passed-with-dissent` rather than `passed` and the record
616
+ says something is outstanding. Silently scoring it `passed` would hide the
617
+ defect instead of deferring it.
618
+ """
619
+ classification = (
620
+ "has-dissent"
621
+ if _is_dissent_downgraded(item, pbv, accepted_item_ids)
622
+ else _classify_plan_item_gate(item)
623
+ )
624
+ if classification != "majority-disagree":
625
+ return classification
626
+ if _stage_scope_bucket(item, pbv) != "in-scope":
627
+ return "has-dissent"
628
+ if str(item.get("block") or "") == "record":
629
+ # 자기 기록의 부정확은 기록되고 다음 run 의 입력이 되지, 구현 착수를 막지
630
+ # 않는다. 요구사항이 실제로 안 만들어지는 경우는 이 경로가 아니라
631
+ # `_independent_coverage_blockers` 의 `coverage-gap` 이 계속 막는다.
632
+ return "has-dissent"
633
+ return classification
634
+
635
+
636
+ def _recompute_plan_body_gate(
637
+ pbv: dict,
638
+ accepted_item_ids: set[str] | None = None,
639
+ ) -> str | None:
640
+ """Recompute the whole §5.5.9 gate value from ``planItems[].verdicts``.
641
+ Returns a value in ``PLAN_VERIFY_GATE_VALUES`` or ``None`` when there are
642
+ no plan items to judge (disabled / empty round)."""
643
+ accepted = accepted_item_ids or set()
644
+ classes = [
645
+ _plan_item_gate_class(it, pbv, accepted)
646
+ for it in (pbv.get("planItems") or [])
647
+ if isinstance(it, dict)
648
+ and (_stage_scope_bucket(it, pbv) == "in-scope" or it.get("verdicts"))
649
+ ]
650
+ if not classes:
651
+ return None
652
+ if all(c == "all-non-result" for c in classes):
653
+ return "aborted-non-result"
654
+ if pbv.get("gating") is False and not requires_plan_repair(pbv):
655
+ if any(
656
+ c
657
+ in ("majority-disagree", "has-dissent", "needs-reverify", "all-non-result")
658
+ for c in classes
659
+ ):
660
+ return "passed-with-dissent"
661
+ return "passed"
662
+ if any(c == "majority-disagree" for c in classes):
663
+ return "blocked-by-disagreement"
664
+ if any(c in ("has-dissent", "needs-reverify", "all-non-result") for c in classes):
665
+ # `all-non-result` belongs here for the same reason `needs-reverify`
666
+ # does — it IS that shape with zero participating votes instead of one
667
+ # (`_state_classification` maps it there, and the contract's step 5
668
+ # lists `needs-reverify` under `passed-with-dissent`). Left out, an
669
+ # item no verifier could judge scored `passed`: the all-error case
670
+ # already reads `needs-reverify` in the state file while the gate it
671
+ # feeds says every item reached consensus.
672
+ return "passed-with-dissent"
673
+ return "passed"
674
+
675
+
676
+ def _validate_plan_body_gate_recompute(
677
+ data: dict,
678
+ failures: list[str],
679
+ accepted_item_ids: set[str] | None = None,
680
+ ) -> None:
681
+ """H1 — the declared `Gate result` must not claim a healthier outcome than
682
+ the recorded per-worker verdicts support. Closes the forgery hole where a
683
+ lead writes `gateResult: passed` while workers actually voted DISAGREE:
684
+ the verdicts live in `planItems[].verdicts`, so the gate is recomputable
685
+ and no longer depends on the lead's honesty alone.
686
+ """
687
+ ip = data.get("implementationPlanning")
688
+ if not isinstance(ip, dict):
689
+ return
690
+ pbv = ip.get("planBodyVerification")
691
+ if not isinstance(pbv, dict):
692
+ return
693
+ declared = str(pbv.get("gateResult") or "").strip().lower()
694
+ accepted = (
695
+ _resolved_noncritical_dissent_ids(data)
696
+ if accepted_item_ids is None
697
+ else accepted_item_ids
698
+ )
699
+ recomputed = _recompute_plan_body_gate(pbv, accepted)
700
+ if recomputed is None or declared not in _PLAN_GATE_RANK:
701
+ return
702
+ if _PLAN_GATE_RANK[declared] > _PLAN_GATE_RANK[recomputed]:
703
+ failures.append(
704
+ "final-report data.json: implementationPlanning.planBodyVerification "
705
+ f"`gateResult` is `{declared}` but the recorded planItems[].verdicts "
706
+ f"only support `{recomputed}` (a majority DISAGREE, a DISAGREE(f) on "
707
+ "a P-Req item, or all-non-result dispatches were recorded). The gate "
708
+ "value must honestly aggregate the worker votes — do not upgrade it "
709
+ "to unblock the run (plan-body-verification.md Round protocol)."
710
+ )
711
+
712
+
713
+ def _cited_clarification_id(row: dict) -> str | None:
714
+ """The `C-NNN` a coverage row's `status` / `approvalDisposition` cites."""
715
+ for field in ("status", "approvalDisposition"):
716
+ value = str(row.get(field) or "").strip()
717
+ if value.startswith("blocked "):
718
+ return value.split(" ", 1)[1].strip()
719
+ return None
720
+
721
+
722
+ def _blocks_approval(row: dict) -> bool:
723
+ """Whether one Requirement Coverage row blocks approval on its face, per
724
+ `prompts/profiles/implementation-planning.md` §"Requirement Coverage": a
725
+ `gap`, a plain `blocked C-NNN`, or a deviation whose approval disposition
726
+ is blocked.
727
+ """
728
+ status = str(row.get("status") or "").strip()
729
+ if status == "gap" or status.startswith("blocked C-"):
730
+ return True
731
+ disposition = str(row.get("approvalDisposition") or "").strip()
732
+ return status == "documented-deviation" and disposition.startswith("blocked C-")
733
+
734
+
735
+ def _plan_item_clarification_ids(item: object) -> set[str]:
736
+ """이 plan item 이 가리키는 `C-NNN` 들.
737
+
738
+ 계약 v3 에서 리포트 정본의 이 링크는 복수형 `clarificationRefs[]` 다 —
739
+ `report_assembly` 가 활동 원장의 `clarificationRefs[]` + `planItemIds[]` 에서
740
+ 유도해 쓰고, v3.0 스키마의 `planItems[]` 는 `additionalProperties: false` 아래
741
+ 그 이름만 허용한다. 단수형 `clarificationId` 는 lead 가 쓰는 plan-body 상태
742
+ 파일과 v2 리포트에 남아 있으므로 읽을 때는 둘 다 받는다
743
+ (`incremental_scope` 가 이미 그렇게 한다).
744
+ """
745
+ if not isinstance(item, dict):
746
+ return set()
747
+ ids = {
748
+ str(ref).strip()
749
+ for ref in (item.get("clarificationRefs") or [])
750
+ if str(ref).strip()
751
+ }
752
+ single = item.get("clarificationId")
753
+ if isinstance(single, str) and single.strip():
754
+ ids.add(single.strip())
755
+ return ids
756
+
757
+
758
+ def _plan_body_promoted_clarification_ids(pbv: dict) -> set[str]:
759
+ """`C-NNN` ids this run's own plan-body round created by promoting a
760
+ majority-disagree item (step 8). Used to break the Requirement Coverage
761
+ ↔ Clarification cycle: a coverage row citing one of these echoes a blocker
762
+ the gate already counted, rather than contributing an independent one.
763
+ """
764
+ return {
765
+ clarification_id
766
+ for item in (pbv.get("planItems") or [])
767
+ for clarification_id in _plan_item_clarification_ids(item)
768
+ }
769
+
770
+
771
+ def _independent_coverage_blockers(ip: dict, pbv: dict) -> list[str]:
772
+ """Coverage rows that block the gate on their own — excluding rows whose
773
+ blocker is a `C-NNN` this same run's plan-body round promoted."""
774
+ promoted = _plan_body_promoted_clarification_ids(pbv)
775
+ return [
776
+ str(row.get("id") or "<unknown>")
777
+ for row in (ip.get("requirementCoverage") or [])
778
+ if isinstance(row, dict)
779
+ and _blocks_approval(row)
780
+ and _cited_clarification_id(row) not in promoted
781
+ ]
782
+
783
+
784
+ def _gate_blocking_causes(
785
+ pbv: dict,
786
+ coverage_blockers: list[str],
787
+ accepted_item_ids: set[str] | None = None,
788
+ ) -> set[str]:
789
+ """Which inputs actually block approval, as `gateBlockedBy` enum values."""
790
+ causes = set()
791
+ recomputed = _recompute_plan_body_gate(pbv, accepted_item_ids)
792
+ if pbv.get("gating") is False and not requires_plan_repair(pbv):
793
+ if recomputed == "aborted-non-result":
794
+ causes.add("non-result")
795
+ return causes
796
+ if recomputed == "blocked-by-disagreement":
797
+ causes.add("majority-disagree")
798
+ elif recomputed == "aborted-non-result":
799
+ causes.add("non-result")
800
+ if coverage_blockers:
801
+ causes.add("coverage-gap")
802
+ return causes
803
+
804
+
805
+ _APPROVAL_CLARIFICATION_ID_RE = re.compile(r"^C-\d{3,}$")
806
+
807
+
808
+ _SELF_FIX_NOTE_ROUND_RE = re.compile(r"self-fixed in round\s*(\d+)", re.IGNORECASE)
809
+
810
+
811
+ def _items_resolved_in_round(plan_items: object, round_number: int) -> set[str]:
812
+ resolved: set[str] = set()
813
+ for item in plan_items if isinstance(plan_items, list) else []:
814
+ if not isinstance(item, dict):
815
+ continue
816
+ match = _SELF_FIX_NOTE_ROUND_RE.search(str(item.get("selfFixNote") or ""))
817
+ if match and int(match.group(1)) == round_number:
818
+ resolved.add(str(item.get("id")))
819
+ return resolved
820
+
821
+
822
+ def _detect_self_fix_recurrence(pbv: dict) -> list[str]:
823
+ """Rounds that re-target ground the previous round already worked, unresolved.
824
+
825
+ `no-progress` is judged at the round's end from what it resolved. Repeating
826
+ the previous round's *unresolved* remainder is the same conclusion reached
827
+ one dispatch earlier — the observed shape was two rounds spent on one
828
+ identical seven-item set. This names that shape so the loop can exit on it
829
+ rather than paying for the round that proves it.
830
+
831
+ Advisory only. Narrowing onto what the last round genuinely left open is
832
+ legitimate progress, and the rule separating that from re-digging the same
833
+ hole is not settled (design D-1), so this reports rather than fails.
834
+ """
835
+ groups = pbv.get("selfFixGroups") if isinstance(pbv, dict) else None
836
+ if not isinstance(groups, list):
837
+ return []
838
+ by_round: dict[int, set[str]] = {}
839
+ for group in groups:
840
+ if not isinstance(group, dict) or not isinstance(group.get("round"), int):
841
+ continue
842
+ ids = {str(i) for i in group.get("itemIds") or []}
843
+ by_round.setdefault(group["round"], set()).update(ids)
844
+
845
+ warnings: list[str] = []
846
+ plan_items = pbv.get("planItems")
847
+ for round_number in sorted(by_round)[1:]:
848
+ previous = by_round.get(round_number - 1)
849
+ if not previous:
850
+ continue
851
+ unresolved = previous - _items_resolved_in_round(plan_items, round_number - 1)
852
+ current = by_round[round_number]
853
+ if current and current <= unresolved:
854
+ warnings.append(
855
+ f"self-fix round {round_number} re-targets only items round "
856
+ f"{round_number - 1} left unresolved ({', '.join(sorted(current))}) "
857
+ "— the previous round's correction did not move this cause. "
858
+ "Consider exiting with `no-progress` instead of spending the "
859
+ "remaining budget on the same ground."
860
+ )
861
+ return warnings
862
+
863
+
864
+ def _validate_participating_analysers(data: dict, failures: list[str]) -> None:
865
+ """The gate's own arithmetic base, checked against the votes it ran on.
866
+
867
+ A majority over two votes and a majority over three are different claims,
868
+ and a shrunken roster loosens the gate silently: with two analysers,
869
+ 1-AGREE/1-DISAGREE is a tie, so it never reaches `majority-disagree`. The
870
+ field only reports; the arithmetic is unchanged. It is recomputable from
871
+ the recorded verdicts, so a figure the table denies is a defect.
872
+
873
+ 재계산은 `okstra_ctl.plan_items.voting_analyser_keys` 하나뿐이고, 기록하는
874
+ 쪽(`okstra plan-items complete-round`)도 같은 함수를 부른다. 두 곳이 각자
875
+ 세던 동안 생산자는 이번 라운드 큐만, 이쪽은 전 항목·전 라운드를 세서
876
+ critic 이 동수만 가른 라운드에서 값이 갈렸다.
877
+ """
878
+ pbv = (data.get("implementationPlanning") or {}).get("planBodyVerification") or {}
879
+ declared = pbv.get("participatingAnalysers")
880
+ if not isinstance(declared, dict):
881
+ return
882
+
883
+ rostered = declared.get("rostered")
884
+ voting = declared.get("voting")
885
+ if not isinstance(rostered, int) or not isinstance(voting, int):
886
+ failures.append(
887
+ "final-report data.json: planBodyVerification.participatingAnalysers "
888
+ "needs integer `rostered` and `voting`."
889
+ )
890
+ return
891
+ if voting > rostered:
892
+ failures.append(
893
+ "final-report data.json: planBodyVerification.participatingAnalysers "
894
+ f"claims {voting} voting of {rostered} rostered — more workers voted "
895
+ "than were on the roster."
896
+ )
897
+ return
898
+
899
+ observed = voting_analyser_keys(pbv.get("planItems") or [])
900
+ if observed and voting != len(observed):
901
+ failures.append(
902
+ "final-report data.json: planBodyVerification.participatingAnalysers "
903
+ f"declares {voting} voting analyser(s) but the recorded verdicts carry "
904
+ f"{len(observed)} ({', '.join(sorted(observed))}). A worker whose "
905
+ "dispatch returned no result is excluded from the gate arithmetic and "
906
+ "must not be counted here either."
907
+ )
908
+
909
+
910
+ def _validate_gate_blocked_by(
911
+ data: dict,
912
+ failures: list[str],
913
+ accepted_item_ids: set[str] | None = None,
914
+ ) -> None:
915
+ """선언된 `gateResult` 가 실제로 남아 있는 차단 원인과 맞는지 본다.
916
+
917
+ 승인을 막는 입력은 둘이다 — `majority-disagree` 플랜 항목, 그리고
918
+ Requirement Coverage 의 `gap` / `blocked C-NNN` 행. 두 갈래를 남긴다:
919
+ (a) 막는 원인이 있는데 `passed` 계열을 선언한 경우, (b) 막는 원인이 하나도
920
+ 없는데 차단 값을 그대로 둔 경우. 둘 다 실측 사고에서 나왔다 — (b) 는 gate
921
+ 토큰이 1라운드 값에 멈춰 프로젝트 전체에서 run 을 못 열게 만들었다.
922
+ """
923
+ ip = data.get("implementationPlanning")
924
+ if not isinstance(ip, dict):
925
+ return
926
+ pbv = ip.get("planBodyVerification")
927
+ if not isinstance(pbv, dict):
928
+ return
929
+ round_count = pbv.get("roundCount")
930
+ if not isinstance(round_count, int) or round_count < 1:
931
+ return
932
+
933
+ declared_gate = str(pbv.get("gateResult") or "").strip().lower()
934
+ coverage_blockers = _independent_coverage_blockers(ip, pbv)
935
+ accepted = (
936
+ _resolved_noncritical_dissent_ids(data)
937
+ if accepted_item_ids is None
938
+ else accepted_item_ids
939
+ )
940
+ actual_causes = _gate_blocking_causes(pbv, coverage_blockers, accepted)
941
+
942
+ if actual_causes and declared_gate in ("passed", "passed-with-dissent"):
943
+ failures.append(
944
+ "final-report data.json: implementationPlanning.planBodyVerification "
945
+ f"`gateResult` is `{declared_gate}` but "
946
+ f"{sorted(actual_causes)} blocks approval "
947
+ f"(coverage rows: {coverage_blockers or 'none'}). A Requirement "
948
+ "Coverage `gap` / `blocked C-NNN` row blocks the gate independently "
949
+ "of the worker verdicts (implementation-planning.md "
950
+ '§"Requirement Coverage").'
951
+ )
952
+ return
953
+
954
+ if not actual_causes and _PLAN_GATE_RANK.get(declared_gate) == 0:
955
+ # A blocking value with nothing left blocking it. The two checks around
956
+ # this one both walk from a recorded cause outward, so a gate that
957
+ # simply stopped being updated fell between them: a self-fix loop
958
+ # resolved every majority-disagree item, `gateBlockedBy` emptied
959
+ # correctly, and the gate token stayed at its round-1 value. The plan
960
+ # was approvable and nothing said so — run-prep refused the approval,
961
+ # and the refusal propagated far enough to take the run wizard down
962
+ # with it, so no run could be started in that project at all. Rescoring
963
+ # with `okstra plan-verify` and recording what it returns is the fix;
964
+ # the round is not complete until that call agrees with the report.
965
+ failures.append(
966
+ "final-report data.json: implementationPlanning.planBodyVerification "
967
+ f"`gateResult` is `{declared_gate}` but nothing blocks approval — "
968
+ "no plan item is `majority-disagree`, no dispatch was a non-result, "
969
+ "and no Requirement Coverage row blocks independently. A gate that "
970
+ "withholds approval with no recorded cause is almost always a value "
971
+ "left behind by an earlier round: rescore with `okstra plan-verify` "
972
+ "and record its `gate.recomputed` "
973
+ '(plan-body-verification.md §"Round protocol" step 5).'
974
+ )
975
+
976
+ # 선언 `gateBlockedBy` 집합과 재계산 집합을 대조하던 갈래는 삭제했다.
977
+ # 생산자(`okstra plan-items complete-round`)가 이 검증기의 계산 함수를
978
+ # 그대로 import 해 필드를 쓰므로 값이 갈릴 자리가 없고, 그 필드를 읽어
979
+ # 실행을 구동하는 소비자도 없다.
980
+
981
+
982
+ def _has_clarification_backtrace(
983
+ row_id: str, plan_items: object, coverage: object
984
+ ) -> bool:
985
+ """Whether the plan records anything this clarification blocks.
986
+
987
+ Two link shapes, both authored by the same run: the `P-*` plan item that
988
+ carries the `clarificationId`, and the requirement-coverage row blocked on
989
+ the id. `incremental-scope` resolves impacted stages from exactly these
990
+ two, and the coverage side goes through its predicate so the gate and the
991
+ resolver cannot disagree about what counts as a link.
992
+ """
993
+ if isinstance(plan_items, list) and any(
994
+ row_id in _plan_item_clarification_ids(item) for item in plan_items
995
+ ):
996
+ return True
997
+ return isinstance(coverage, list) and any(
998
+ coverage_row_blocked_on(row, row_id) for row in coverage
999
+ )
1000
+
1001
+
1002
+ def _validate_approval_clarification_backtrace(data: dict, failures: list[str]) -> None:
1003
+ """An approval blocker must record what it blocks.
1004
+
1005
+ `_validate_plan_body_clarification_matching` already walks the other
1006
+ direction — a majority-disagree plan item must cite a `blocks: approval`
1007
+ row. Nothing walked this way, so a row could withhold approval while
1008
+ recording no blast radius at all. The cost lands on the re-run:
1009
+ `incremental-scope` resolves impacted stages from these links and will not
1010
+ silently narrow past an id that traces to no stage, so this report fails
1011
+ rather than forcing a full re-run.
1012
+
1013
+ The link must also *resolve to a stage*, which is the thing the re-run
1014
+ actually reads. Checking only that a link exists let a row satisfy this
1015
+ gate while the next re-run still could not place the answer: `P-Req-*`
1016
+ and `P-Val-*` ids are numbered by position in their own array, so they
1017
+ carry no stage, and a blocked coverage row whose `coveredBy` is prose
1018
+ cites none either.
1019
+ """
1020
+ if (data.get("header") or {}).get("taskType") != "implementation-planning":
1021
+ return
1022
+ planning = data.get("implementationPlanning")
1023
+ if not isinstance(planning, dict):
1024
+ return
1025
+ coverage = planning.get("requirementCoverage")
1026
+ verification = planning.get("planBodyVerification")
1027
+ plan_items = (
1028
+ verification.get("planItems") if isinstance(verification, dict) else None
1029
+ )
1030
+ for row in data.get("clarificationItems") or []:
1031
+ if not isinstance(row, dict) or row.get("blocks") != "approval":
1032
+ continue
1033
+ if str(row.get("status") or "") in {"answered", "resolved"}:
1034
+ continue
1035
+ row_id = str(row.get("id") or "<unknown>")
1036
+ if not _has_clarification_backtrace(row_id, plan_items, coverage):
1037
+ failures.append(
1038
+ f"final-report data.json: clarification `{row_id}` blocks approval "
1039
+ "but has no back-trace into the plan — no plan item carries it as "
1040
+ "`clarificationId`, and no requirement-coverage row is `blocked "
1041
+ f"{row_id}` in its `status` or `approvalDisposition`. An item that "
1042
+ "withholds approval without recording what it affects cannot "
1043
+ "place the next re-run's scope; this report fails rather than "
1044
+ "forcing a full re-run."
1045
+ )
1046
+ continue
1047
+ if stages_for_clarification(data, row_id):
1048
+ continue
1049
+ failures.append(
1050
+ f"final-report data.json: clarification `{row_id}` blocks approval "
1051
+ "and is linked, but the link resolves to no stage. `incremental-"
1052
+ "scope` reads the stage from a `P-Step-<stage>.<step>` / `P-Prep-"
1053
+ "S<stage>-<kind>` plan-item id, from `stageScope` / `stageRefs` on "
1054
+ "the linked plan item or coverage row, or from a `Stage N` citation "
1055
+ f"in the blocked coverage row's `coveredBy`. A `P-Req-*` / `P-Val-*` "
1056
+ "id carries no stage number, so a row linked only that way must "
1057
+ "carry `stageRefs` or cite the stage in `coveredBy`. A blocker "
1058
+ "whose blast radius resolves to no stage cannot auto-narrow the "
1059
+ "next re-run; this report fails rather than forcing a full re-run."
1060
+ )
1061
+
1062
+
1063
+ def _validate_self_fix_grouping(data: dict, failures: list[str]) -> None:
1064
+ """A self-fix round must be instructed by cause, not as a flat item list.
1065
+
1066
+ Blocked items are usually several derivatives of one defect. Instructed
1067
+ item-by-item, each patch corrects its own section and leaves the sibling
1068
+ sections still asserting the old value, so the next round re-finds the same
1069
+ family and the budget drains without converging. Recording the grouping
1070
+ makes the lead commit to a diagnosis and makes a one-group-per-item
1071
+ non-diagnosis visible in the artifact rather than invisible in a prompt.
1072
+
1073
+ Recording rounds here also ties `selfFixRoundsApplied` to work that exists
1074
+ in the data: it was a free-floating self-reported integer, yet
1075
+ `_validate_self_fix_before_clarification` gates promotion on its value.
1076
+ """
1077
+ ip = data.get("implementationPlanning")
1078
+ if not isinstance(ip, dict):
1079
+ return
1080
+ pbv = ip.get("planBodyVerification")
1081
+ if not isinstance(pbv, dict):
1082
+ return
1083
+ rounds_applied = pbv.get("selfFixRoundsApplied")
1084
+ if not isinstance(rounds_applied, int) or rounds_applied < 1:
1085
+ return
1086
+
1087
+ groups = [g for g in (pbv.get("selfFixGroups") or []) if isinstance(g, dict)]
1088
+ if not groups:
1089
+ failures.append(
1090
+ "final-report data.json: planBodyVerification declares "
1091
+ f"`selfFixRoundsApplied`={rounds_applied} but records no "
1092
+ "`selfFixGroups`. Each round's targets MUST be grouped by common "
1093
+ "cause before being handed to report-writer — a flat item list "
1094
+ "makes every patch leave its siblings' contradictions standing "
1095
+ '(plan-body-verification.md §"Round protocol" step 7).'
1096
+ )
1097
+ return
1098
+
1099
+ rounds = [g.get("round") for g in groups if isinstance(g.get("round"), int)]
1100
+ if len(set(rounds)) > 1:
1101
+ failures.append(
1102
+ "final-report data.json: automatic self-fix is limited to one rewrite; "
1103
+ "resolve remaining items through lead decisions or user confirmation."
1104
+ )
1105
+ if rounds and max(rounds) != rounds_applied:
1106
+ failures.append(
1107
+ "final-report data.json: planBodyVerification "
1108
+ f"`selfFixRoundsApplied`={rounds_applied} does not match the highest "
1109
+ f"round recorded in `selfFixGroups` ({max(rounds)}). The round count "
1110
+ "must be derivable from recorded work, not asserted independently of "
1111
+ "it — promotion eligibility is gated on this number."
1112
+ )
1113
+
1114
+ known_ids = {
1115
+ str(item.get("id")).strip()
1116
+ for item in (pbv.get("planItems") or [])
1117
+ if isinstance(item, dict) and str(item.get("id") or "").strip()
1118
+ }
1119
+ grouped_ids = [
1120
+ str(item_id).strip()
1121
+ for group in groups
1122
+ for item_id in (group.get("itemIds") or [])
1123
+ if str(item_id or "").strip()
1124
+ ]
1125
+ unknown = sorted({i for i in grouped_ids if i not in known_ids})
1126
+ if unknown:
1127
+ failures.append(
1128
+ "final-report data.json: planBodyVerification.selfFixGroups targets "
1129
+ f"plan item(s) {unknown} that do not exist in `planItems`."
1130
+ )
1131
+
1132
+ fixed_ids = {
1133
+ str(item.get("id")).strip()
1134
+ for item in (pbv.get("planItems") or [])
1135
+ if isinstance(item, dict)
1136
+ and str(item.get("selfFixNote") or "").strip()
1137
+ and str(item.get("id") or "").strip()
1138
+ }
1139
+ ungrouped = sorted(fixed_ids - set(grouped_ids))
1140
+ if ungrouped:
1141
+ failures.append(
1142
+ "final-report data.json: plan item(s) "
1143
+ f"{ungrouped} carry a `selfFixNote` but appear in no "
1144
+ "`selfFixGroups` entry. Every item a round corrected must be "
1145
+ "attributable to the cause group it was instructed under."
1146
+ )
1147
+
1148
+
1149
+ _ANSWERED_CLARIFICATION_STATUSES = frozenset({"answered", "resolved"})
1150
+
1151
+
1152
+ def _answered_clarification_ids(data: dict) -> list[str]:
1153
+ """Clarifications this run incorporated an answer for — the rows whose
1154
+ answers can invalidate statements the previous run wrote."""
1155
+ return [
1156
+ str(row.get("id")).strip()
1157
+ for row in (data.get("clarificationItems") or [])
1158
+ if isinstance(row, dict)
1159
+ and str(row.get("status") or "").strip() in _ANSWERED_CLARIFICATION_STATUSES
1160
+ and str(row.get("userInput") or "").strip()
1161
+ and str(row.get("id") or "").strip()
1162
+ ]
1163
+
1164
+
1165
+ def _validate_supersession_ledger(
1166
+ data: dict,
1167
+ failures: list[str],
1168
+ *,
1169
+ carried: dict | None = None,
1170
+ new_plan: bool = False,
1171
+ ) -> None:
1172
+ """Incorporating an answer means retiring what it invalidates, not only
1173
+ adding what it decides.
1174
+
1175
+ `new_plan` marks a plan built from a selected direction. Prepare seeds
1176
+ that run's ledger with every answer the option-selection record carried
1177
+ (2026-09-05), and a first plan has no earlier statement those answers
1178
+ could retire — an entry per carried row would be `no-dependent-statement`
1179
+ by construction. Those ids are exempt; answers the plan itself raised and
1180
+ settled still need their entry.
1181
+
1182
+ A re-run reconciles each `C-*` row's `Status` and writes the new decision
1183
+ into the plan, but nothing required it to remove the sentences the answer
1184
+ made false. The result is one plan carrying two opposite instructions for
1185
+ the same symbol — the implementer then has to guess which one is live, and
1186
+ the §5.5.9 round correctly blocks on it. This check makes the writer state,
1187
+ per answered clarification, what it retired or why nothing was contingent
1188
+ on that answer. The claim's *truth* is what the §5.5.9 adversarial round
1189
+ tests; this only forces the claim to exist and be attributable.
1190
+ """
1191
+ ip = data.get("implementationPlanning")
1192
+ if not isinstance(ip, dict):
1193
+ return
1194
+ answered = set(_answered_clarification_ids(data))
1195
+ if new_plan:
1196
+ answered.difference_update((carried or {}).keys())
1197
+ else:
1198
+ answered.update((carried or {}).keys())
1199
+ if not answered:
1200
+ return
1201
+ ledger = [e for e in (ip.get("supersessionLedger") or []) if isinstance(e, dict)]
1202
+ covered = {
1203
+ str(entry.get("clarificationId") or "").strip()
1204
+ for entry in ledger
1205
+ if str(entry.get("clarificationId") or "").strip()
1206
+ }
1207
+ missing = [cid for cid in answered if cid not in covered]
1208
+ if missing:
1209
+ failures.append(
1210
+ "final-report data.json: implementationPlanning.supersessionLedger has "
1211
+ f"no entry for answered clarification(s) {sorted(missing)}. Every "
1212
+ "answer this run incorporated MUST record what it superseded "
1213
+ "(`disposition: superseded` with the retired statement and the "
1214
+ "sections revised) or state that no plan statement was contingent "
1215
+ "on it (`disposition: no-dependent-statement` with a rationale). "
1216
+ "Adding the new decision while leaving the contradicting sentence "
1217
+ "in place is what puts two opposite instructions in one plan "
1218
+ '(_common-contract.md §"clarification response carry-in").'
1219
+ )
1220
+ stale = covered - answered
1221
+ carry_in = data.get("clarificationCarryIn")
1222
+ # 이월 원장은 이전 런에서 받은 답을 이번 런이 반영한 기록이다.
1223
+ # 이번 런 clarificationItems 에 userInput 이 없다고 stale 로 보면
1224
+ # C-024 같은 이월 행이 "this run did not answer" 가 된다.
1225
+ if (
1226
+ stale
1227
+ and isinstance(carry_in, dict)
1228
+ and str(carry_in.get("sourceFile") or "").strip()
1229
+ ):
1230
+ stale = set()
1231
+ if stale:
1232
+ failures.append(
1233
+ "final-report data.json: implementationPlanning.supersessionLedger "
1234
+ f"cites {sorted(stale)}, which this run did not answer. A ledger "
1235
+ "entry must correspond 1:1 to a clarification whose answer this "
1236
+ "run incorporated."
1237
+ )
1238
+
1239
+
1240
+ def _validate_round_recorded_verdicts(data: dict, failures: list[str]) -> None:
1241
+ """A round that ran must leave the votes it ran on — item by item.
1242
+
1243
+ The gate is re-derived from `planItems[].verdicts[]`, so an empty table
1244
+ removes the very evidence the recompute judges. A *healthier* declared gate
1245
+ is already caught — empty verdicts recompute to `aborted-non-result`, which
1246
+ every passing value outranks. What slipped through was the conservative
1247
+ declaration: a lead writing `aborted-non-result` over an empty table
1248
+ produces a gate nothing can audit, indistinguishable from a round that was
1249
+ dispatched and whose results were never transcribed.
1250
+
1251
+ The per-item form is what survives a self-fix loop. Round 2+ queues are
1252
+ targeted, so an item the planner adds mid-loop and never puts in one keeps
1253
+ an empty `verdicts[]` while every neighbour carries votes — and nothing
1254
+ downstream reads that as a gap. An empty table classifies `all-non-result`
1255
+ (`_classify_plan_item_gate`), which states as `needs-reverify`, which
1256
+ `_recompute_plan_body_gate` folds into `passed-with-dissent`: a plan item
1257
+ no verifier ever judged leaves the gate in a passing value. The whole-table
1258
+ check could not see it, since it stands down the moment any one item has a
1259
+ vote.
1260
+
1261
+ An unjudged item is distinguishable from a legitimately unresolved one, and
1262
+ the difference is what is recorded rather than what is missing. A peer that
1263
+ returned nothing is a `verification-error` VOTE (§"Round protocol" step 3),
1264
+ so an all-error item still carries rows and still folds to `needs-reverify`
1265
+ on purpose. An empty table means no dispatch was accounted for at all.
1266
+ """
1267
+ ip = data.get("implementationPlanning")
1268
+ if not isinstance(ip, dict):
1269
+ return
1270
+ pbv = ip.get("planBodyVerification")
1271
+ if not isinstance(pbv, dict):
1272
+ return
1273
+ round_count = pbv.get("roundCount")
1274
+ if not isinstance(round_count, int) or round_count < 1:
1275
+ return
1276
+ items = [
1277
+ it
1278
+ for it in (pbv.get("planItems") or [])
1279
+ if isinstance(it, dict) and _stage_scope_bucket(it, pbv) == "in-scope"
1280
+ ]
1281
+ if not items:
1282
+ return
1283
+ empty = [str(it.get("id") or "<unnamed>") for it in items if not it.get("verdicts")]
1284
+ if not empty:
1285
+ return
1286
+ if len(empty) == len(items):
1287
+ failures.append(
1288
+ "final-report data.json: planBodyVerification declares "
1289
+ f"`roundCount`={round_count} but every one of the {len(items)} "
1290
+ "`planItems[]` carries an empty `verdicts[]`. A round that ran MUST "
1291
+ "record the votes it produced — the gate is re-derived from this "
1292
+ "table, so an empty one leaves the declared `gateResult` unauditable. "
1293
+ "A dispatch that returned nothing is recorded as `verification-error`, "
1294
+ 'not omitted (plan-body-verification.md §"Round protocol" step 4).'
1295
+ )
1296
+ return
1297
+ shown = ", ".join(f"`{item_id}`" for item_id in empty[:5])
1298
+ more = f" and {len(empty) - 5} more" if len(empty) > 5 else ""
1299
+ failures.append(
1300
+ "final-report data.json: planBodyVerification declares "
1301
+ f"`roundCount`={round_count} but {len(empty)} of {len(items)} "
1302
+ f"`planItems[]` carry an empty `verdicts[]`: {shown}{more}. Every "
1303
+ "extracted plan item MUST be judged by the round — an item with no "
1304
+ "vote at all is not a dissent the gate can weigh, it is a plan item "
1305
+ "nobody verified, and it currently folds into `passed-with-dissent` "
1306
+ "alongside items that were properly cross-checked. Either dispatch it "
1307
+ "in this round's queue, or record the non-result as a "
1308
+ "`verification-error` verdict per plan-body-verification.md "
1309
+ '§"Round protocol" step 3 — an item is never left with no row.'
1310
+ )
1311
+
1312
+
1313
+ def _plan_items_routed_to_a_user_decision(data: dict) -> set[str]:
1314
+ """`blocks: approval` C 행과 이어진 계획 항목 id.
1315
+
1316
+ 처분 여부는 보지 않는다. 행이 존재한다는 것 자체가 그 항목이 사용자 결정
1317
+ 채널로 나갔다는 뜻이고, 아직 답이 없는 행은 `row_blocks_progress` 가
1318
+ 승인을 막는다 — `_validate_v3_approval_context` 가 그 상태의 `approved:
1319
+ true` 를 거부한다. 링크는 양방향으로 읽는다: 항목 쪽 `clarificationRefs`
1320
+ 와, 행 → 활동 원장 역추적(`_plan_item_ids_for_clarification`). 계약 3.0
1321
+ 리포트는 후자로만 이어지는 경우가 있다.
1322
+ """
1323
+ rows = [r for r in (data.get("clarificationItems") or []) if isinstance(r, dict)]
1324
+ approval_rows = [
1325
+ row for row in rows if row.get("blocks") == "approval" and row.get("id")
1326
+ ]
1327
+ approval_ids = {str(row["id"]) for row in approval_rows}
1328
+ linked: set[str] = set()
1329
+ items = (
1330
+ (data.get("implementationPlanning") or {}).get("planBodyVerification") or {}
1331
+ ).get("planItems") or []
1332
+ for item in items:
1333
+ if isinstance(item, dict) and _plan_item_clarification_ids(item) & approval_ids:
1334
+ linked.add(str(item.get("id") or "").strip())
1335
+ for row in approval_rows:
1336
+ context = row.get("approvalContext")
1337
+ linked.update(
1338
+ str(item_id).strip()
1339
+ for item_id in _plan_item_ids_for_clarification(
1340
+ row, context if isinstance(context, dict) else {}, data
1341
+ )
1342
+ )
1343
+ linked.discard("")
1344
+ return linked
1345
+
1346
+
1347
+ def _validate_unresolved_tie_was_reverified(
1348
+ data: dict,
1349
+ failures: list[str],
1350
+ ) -> None:
1351
+ """A split panel is settled by critic-worker or by the user, not by silence.
1352
+
1353
+ The gate needs a strict majority to block, so a panel splitting evenly on a
1354
+ blocking kind reaches neither consensus nor `majority-disagree`. That state
1355
+ is classified `needs-reverify`, which `_recompute_plan_body_gate` folds into
1356
+ `passed-with-dissent` — so without a settlement the split passes with nobody
1357
+ deciding it.
1358
+
1359
+ 두 가지 해소가 있고 로스터가 어느 쪽인지 정한다. critic 이 배정된 run 은
1360
+ `critic-worker` 표가 가른다. critic 이 없는 로스터(`invocationAssignments`
1361
+ 에 `critic/*` 없음, `okstra_ctl.plan_items.critic_is_rostered`)는 라운드
1362
+ 안에 가를 표가 아예 없으므로 `next_dispatch` 가 `user-decision` 을 내고
1363
+ 리드가 항목마다 승인 결정을 연다. 그 결정 행이 이 항목의 해소다. 검증기는
1364
+ 로스터를 볼 수 없으므로(이 검사의 입력은 리포트 정본뿐) 둘 중 하나가
1365
+ 기록되어 있으면 해소로 읽고, 둘 다 없을 때만 발화한다.
1366
+
1367
+ 같은 조건을 다른 술어로 한 번 더 세던 두 번째 동수 검사를 여기로 합쳤다.
1368
+ 그쪽 술어는 판정 행의 `round` 를 1 로 눌러 놓고 `_is_unsettled_tie` 를
1369
+ 불렀는데, `_is_even_analyser_split` 는 `round` 를 보지 않으므로 두 술어의
1370
+ 값이 언제나 같았다 — 같은 항목이 두 번 실패로 올라왔다. 여기 남은 조건이
1371
+ 두 집합의 합집합이다.
1372
+ """
1373
+ ip = data.get("implementationPlanning")
1374
+ if not isinstance(ip, dict):
1375
+ return
1376
+ pbv = ip.get("planBodyVerification")
1377
+ if not isinstance(pbv, dict):
1378
+ return
1379
+ # 사용자 결정 채널로 나간 항목은 제외한다. 진행 처분이 이미 붙은 항목
1380
+ # (accept-risk 등)도 여기 포함된다 — 리드 계약이 accept-risk 를 "게이트를
1381
+ # 끝내고 재검증 AGREE 를 요구하지 않는" 처분으로 정의하므로, 계속 실패를
1382
+ # 올리면 승인 처분으로 빠져나갈 수 없는 규칙이 된다.
1383
+ decided = _plan_items_routed_to_a_user_decision(data)
1384
+ unsettled = sorted(
1385
+ {
1386
+ str(item.get("id") or "").strip()
1387
+ for item in pbv.get("planItems") or []
1388
+ if isinstance(item, dict)
1389
+ and not item.get("carriedForwardFromSeq")
1390
+ and str(item.get("id") or "").strip() not in decided
1391
+ and not _lead_decision_applies(item, pbv)
1392
+ and _stage_scope_bucket(item, pbv) == "in-scope"
1393
+ and _is_unsettled_tie(item)
1394
+ }
1395
+ )
1396
+ if not unsettled:
1397
+ return
1398
+ failures.append(
1399
+ "final-report data.json: plan item(s) "
1400
+ f"{unsettled} carry an even split on a blocking breakage kind and "
1401
+ f"have no `{CRITIC_WORKER_ID}` vote and no `blocks: approval` "
1402
+ "clarification row. A tie is not consensus. With a critic on the "
1403
+ f"roster, dispatch `{CRITIC_WORKER_ID}` on those items only (`okstra "
1404
+ "plan-items prepare --tie-vote`), read its answer with `okstra "
1405
+ "plan-items collect-verdicts --items <the --tie-vote plan-items "
1406
+ f"artifact> --result {CRITIC_WORKER_ID}=<path> --output <envelope>`, "
1407
+ "then record it with `okstra plan-items apply-verdicts --append "
1408
+ "--round 2`. Skipping collect-verdicts and pointing apply-verdicts at "
1409
+ "the raw result is refused: this round's queue is the tie items, not "
1410
+ "the round's full dispatch queue. Critic AGREE settles the split; "
1411
+ "critic DISAGREE blocks. With no critic on the roster `okstra "
1412
+ "plan-items next-dispatch` answers `user-decision` instead: open one "
1413
+ "`okstra approval-decision open` per item (classification "
1414
+ "`noncritical-dissent`) and write the matching `## 1. Clarification "
1415
+ "Items` row, and that row settles the tie here while it gates approval."
1416
+ )
1417
+
1418
+
1419
+ def _validate_advisory_plan_body_gating(data: dict, failures: list[str]) -> None:
1420
+ """gating=false 는 검출 표면 0 + 스테이지 1 일 때만 받는다."""
1421
+ ip = data.get("implementationPlanning")
1422
+ if not isinstance(ip, dict):
1423
+ return
1424
+ pbv = ip.get("planBodyVerification")
1425
+ if not isinstance(pbv, dict) or pbv.get("gating") is not False:
1426
+ return
1427
+ facts = (
1428
+ ip.get("designPreparation") is not None
1429
+ or ip.get("stageMap")
1430
+ or ip.get("stages")
1431
+ )
1432
+ if requires_plan_repair(pbv):
1433
+ failures.append(
1434
+ "plan-body-verification: objective verification defects require gating=true; repair the affected items"
1435
+ )
1436
+ if facts and not advisory_plan_body_gating(ip):
1437
+ failures.append(
1438
+ "final-report data.json: implementationPlanning.planBodyVerification "
1439
+ "`gating` is false, but that is only legal when "
1440
+ "designPreparation.mode is `no-design-inputs` (empty items) and the "
1441
+ "Stage Map has exactly one row. Two-or-more stages, a PREP item, or "
1442
+ "non-empty designPreparation items keep the gating contract."
1443
+ )
1444
+ applied = pbv.get("selfFixRoundsApplied")
1445
+ if isinstance(applied, int) and applied > 0:
1446
+ failures.append(
1447
+ "final-report data.json: implementationPlanning.planBodyVerification "
1448
+ "`gating` is false, so the self-fix loop must not run "
1449
+ f"(`selfFixRoundsApplied`={applied}). Keep extraction and one "
1450
+ "verification round."
1451
+ )
1452
+
1453
+
1454
+ def _validate_verdict_rounds_outlive_self_fix(
1455
+ data: dict,
1456
+ failures: list[str],
1457
+ ) -> None:
1458
+ """A verdict must judge the plan the gate is about to pass.
1459
+
1460
+ Rounds interleave with rewrites: round 1, self-fix 1, round 2, self-fix 2 …
1461
+ so a verdict cast in round R judged the text as it stood after self-fix
1462
+ R-1. If any self-fix ran afterwards — `selfFixRoundsApplied >= R` — that
1463
+ text has changed and the verdict is stale by construction. No semantic
1464
+ analysis is needed to know that; the arithmetic settles it.
1465
+
1466
+ The sibling `_validate_verdicts_match_current_subjects` cannot see this. It
1467
+ compares each row's own recorded `subject`, which catches a positional shift
1468
+ but not the case that matters here: an item whose own wording never changed
1469
+ while the stage it points at was rewritten under it. Observed on a real run
1470
+ — the gate read `passed-with-dissent` with zero blockers, and re-running one
1471
+ round flipped 3 of 27 items to `majority-disagree`, all correctness-critical,
1472
+ because their surviving verdicts predated two self-fix rounds.
1473
+
1474
+ Scoped to items this run verified: a `carriedForwardFromSeq` row belongs to
1475
+ the prior run's record and is judged by that run's seq, not this one's
1476
+ rounds.
1477
+ """
1478
+ ip = data.get("implementationPlanning")
1479
+ if not isinstance(ip, dict):
1480
+ return
1481
+ pbv = ip.get("planBodyVerification")
1482
+ if not isinstance(pbv, dict):
1483
+ return
1484
+ applied = pbv.get("selfFixRoundsApplied")
1485
+ if not isinstance(applied, int) or applied < 1:
1486
+ # With no rewrite after any round there is nothing a verdict can be
1487
+ # stale against, and an unstamped row is then simply unremarkable.
1488
+ return
1489
+
1490
+ stale: list[str] = []
1491
+ unstamped: list[str] = []
1492
+ for item in pbv.get("planItems") or []:
1493
+ if not isinstance(item, dict) or item.get("carriedForwardFromSeq"):
1494
+ continue
1495
+ if _stage_scope_bucket(item, pbv) != "in-scope":
1496
+ continue
1497
+ item_id = str(item.get("id") or "").strip()
1498
+ verified = item.get("verifiedContentHash")
1499
+ current = item.get("contentHash")
1500
+ if (
1501
+ isinstance(verified, str)
1502
+ and isinstance(current, str)
1503
+ and verified == current
1504
+ ):
1505
+ # 본문이 같으면 라운드 번호가 self-fix 이전이어도 같은 텍스트다.
1506
+ continue
1507
+ for verdict in item.get("verdicts") or []:
1508
+ if not isinstance(verdict, dict):
1509
+ continue
1510
+ round_number = verdict.get("round")
1511
+ if not isinstance(round_number, int) or isinstance(round_number, bool):
1512
+ unstamped.append(item_id)
1513
+ elif round_number <= applied:
1514
+ stale.append(item_id)
1515
+ if unstamped:
1516
+ failures.append(
1517
+ f"final-report data.json: plan item(s) {sorted(set(unstamped))} carry "
1518
+ f"a verdict with no `round`, and {applied} self-fix round(s) rewrote "
1519
+ "the plan. Without the round there is no way to tell whether the "
1520
+ "verdict judged the current text or a version two rewrites old. "
1521
+ "Re-record the round's votes with `okstra plan-items apply-verdicts "
1522
+ "--round <N>`."
1523
+ )
1524
+ if stale:
1525
+ failures.append(
1526
+ f"final-report data.json: plan item(s) {sorted(set(stale))} carry a "
1527
+ f"verdict from a round at or before self-fix round {applied}, so the "
1528
+ "text they judged has since been rewritten. The gate is computed "
1529
+ "from these votes, so passing on them declares a plan verified that "
1530
+ "nobody verified. Re-verify those items in a round after the last "
1531
+ 'self-fix (plan-body-verification.md §"Round protocol" step 7).'
1532
+ )
1533
+
1534
+
1535
+ def _validate_verdicts_match_current_subjects(
1536
+ data: dict,
1537
+ failures: list[str],
1538
+ ) -> None:
1539
+ """A verdict must still be attached to the element it was cast on.
1540
+
1541
+ `P-*` ids are positional (`plan_items.py` numbers rows by array index), so
1542
+ when a self-fix round deletes a plan element every later row shifts up one.
1543
+ A dangling id at the tail is already caught by
1544
+ `_validate_plan_item_extraction_completeness`, but the shift itself is not:
1545
+ the id set still matches while each surviving verdict now points at its
1546
+ neighbour. The recorded `subject` is what makes the shift visible — it is a
1547
+ snapshot of the row the worker actually judged.
1548
+ """
1549
+ ip = data.get("implementationPlanning")
1550
+ if not isinstance(ip, dict):
1551
+ return
1552
+ pbv = ip.get("planBodyVerification")
1553
+ if not isinstance(pbv, dict):
1554
+ return
1555
+ round_count = pbv.get("roundCount")
1556
+ if not isinstance(round_count, int) or round_count < 1:
1557
+ return
1558
+ try:
1559
+ current = {
1560
+ str(item["id"]): str(item.get("subject") or "")
1561
+ for item in extract_plan_items(ip)
1562
+ }
1563
+ except Exception: # noqa: BLE001
1564
+ # Extraction failure is already reported by the completeness check;
1565
+ # do not double-report it here as a spurious subject mismatch.
1566
+ return
1567
+
1568
+ drifted = []
1569
+ for item in pbv.get("planItems") or []:
1570
+ if not isinstance(item, dict):
1571
+ continue
1572
+ item_id = str(item.get("id") or "").strip()
1573
+ recorded = str(item.get("subject") or "").strip()
1574
+ expected = current.get(item_id)
1575
+ if expected is None or not recorded:
1576
+ continue
1577
+ if recorded != expected.strip():
1578
+ drifted.append(item_id)
1579
+ if drifted:
1580
+ failures.append(
1581
+ f"final-report data.json: plan item(s) {sorted(drifted)} carry a "
1582
+ "`subject` that no longer matches the plan element at that "
1583
+ "position. `P-*` ids are positional, so deleting an element during "
1584
+ "self-fix shifts every later row and silently re-points its "
1585
+ "verdicts at a different element — a recorded blocker then refers "
1586
+ "to something the reader cannot find. Re-extract the plan items "
1587
+ "and re-verify the shifted ones instead of carrying the old votes "
1588
+ 'forward (plan-body-verification.md §"Round protocol" step 7).'
1589
+ )
1590
+
1591
+
1592
+ def _validate_aborted_gate_has_clarification(data: dict, failures: list[str]) -> None:
1593
+ """A gate nobody can act on is a stalled task.
1594
+
1595
+ `aborted-non-result` correctly refuses approval and run-prep fail-closes
1596
+ the `implementation` entry, but the clarification matcher only walks
1597
+ `majority-disagree` items — and an aborted round has none. So the report
1598
+ stated no blocker, `okstra-user-response` had nothing to present, and the
1599
+ run stalled with no remedy until someone read the gate value by hand.
1600
+ """
1601
+ ip = data.get("implementationPlanning")
1602
+ if not isinstance(ip, dict):
1603
+ return
1604
+ pbv = ip.get("planBodyVerification")
1605
+ if not isinstance(pbv, dict):
1606
+ return
1607
+ if str(pbv.get("gateResult") or "").strip() != "aborted-non-result":
1608
+ return
1609
+ has_open_blocker = any(
1610
+ isinstance(row, dict)
1611
+ and row.get("blocks") == "approval"
1612
+ and str(row.get("status") or "").strip() == "open"
1613
+ for row in (data.get("clarificationItems") or [])
1614
+ )
1615
+ if not has_open_blocker:
1616
+ failures.append(
1617
+ "final-report data.json: planBodyVerification `gateResult` is "
1618
+ "`aborted-non-result` but no open `Blocks=approval` clarification "
1619
+ "row explains it. An aborted round blocks approval without "
1620
+ "producing any majority-disagree item, so without this row the "
1621
+ "report names no blocker, `okstra-user-response` has nothing to "
1622
+ "present, and the task stalls with no stated remedy. Add a row "
1623
+ "naming which dispatches returned no result and what re-running "
1624
+ 'them requires (plan-body-verification.md §"Round protocol").'
1625
+ )
1626
+
1627
+
1628
+ def _plan_verify_result_workers(report_path: Path, task_type: str) -> set[str] | None:
1629
+ """Worker roles that actually returned a plan-body reverify result.
1630
+
1631
+ Result files are named
1632
+ ``<role-slug>-plan-verify-r<N>-<task-type>-<seq>.md`` per
1633
+ `plan-body-verification.md` §"Round protocol" step 3, and the role slug is
1634
+ ``<role>-plan-verify-r<N>``. Returns ``None`` when the directory is absent
1635
+ so the caller can distinguish "no artifacts to check against" from "nobody
1636
+ voted".
1637
+ """
1638
+ worker_results_dir = report_path.parent.parent / "worker-results"
1639
+ if not worker_results_dir.is_dir():
1640
+ return None
1641
+ # Scoped to this run's seq for the same reason the audit check is: the
1642
+ # directory accumulates every run, so an unscoped glob would let a prior
1643
+ # run's result file vouch for a vote this run never collected.
1644
+ seqs = _plan_verify_seq_aliases(report_path)
1645
+ workers = set()
1646
+ for seq in seqs or {_report_run_seq(report_path) or "*"}:
1647
+ for path in worker_results_dir.glob(f"*-plan-verify-r*-{task_type}-{seq}.md"):
1648
+ role = path.name.split("-plan-verify-r", 1)[0]
1649
+ if role:
1650
+ workers.add(role)
1651
+ return workers
1652
+
1653
+
1654
+ def _manifest_seq_for_report(report_path: Path, category: str) -> set[str]:
1655
+ """이 리포트 seq 를 낸 매니페스트가 그 카테고리에 기록한 seq 들.
1656
+
1657
+ `paths.compute_run_paths` 는 7개 카테고리 seq 를 디렉터리별로 따로 스캔해
1658
+ 배정한다(`paths.next_run_seq`). 같은 run dir 로 재실행하면 카테고리마다
1659
+ 다른 속도로 올라가 reports 017 / state 025 같은 분기가 실제로 생긴다.
1660
+ 매니페스트의 `runSequencesByCategory` 만이 그 분기를 한 런으로 묶는 기록이다.
1661
+ """
1662
+ seq = _report_run_seq(report_path)
1663
+ manifests_dir = report_path.parent.parent / "manifests"
1664
+ if not seq or not manifests_dir.is_dir():
1665
+ return set()
1666
+ found: set[str] = set()
1667
+ for path in manifests_dir.glob("run-manifest-*.json"):
1668
+ try:
1669
+ payload = load_owned_object(path, artifact="run manifest")
1670
+ except JsonBoundaryError:
1671
+ continue
1672
+ categories = payload.get("runSequencesByCategory")
1673
+ if not isinstance(categories, dict):
1674
+ continue
1675
+ if str(categories.get("reports") or "") != seq:
1676
+ continue
1677
+ value = str(categories.get(category) or "").strip()
1678
+ if value:
1679
+ found.add(value)
1680
+ return found
1681
+
1682
+
1683
+ def _plan_verify_seq_aliases(report_path: Path) -> set[str]:
1684
+ """이 리포트 seq 와, 같은 리포트를 가리키는 런의 workerResults seq.
1685
+
1686
+ reports 와 workerResults 가 갈라지면 워커는 018 로 쓰고 검사는 014 만
1687
+ 본다. 같은 리포트를 연 매니페스트의 두 seq 를 모두 인정한다.
1688
+ """
1689
+ seq = _report_run_seq(report_path)
1690
+ aliases: set[str] = {seq} if seq else set()
1691
+ return aliases | _manifest_seq_for_report(report_path, "workerResults")
1692
+
1693
+
1694
+ def _plan_verify_dispatched_results(
1695
+ report_path: Path, task_type: str
1696
+ ) -> dict[str, set[str]] | None:
1697
+ """이 런이 실제로 디스패치한 plan-body 재검증 결과 파일명(역할별).
1698
+
1699
+ 파일명을 seq 로 되짚는 대신 **기록된 디스패치**에서 읽는다. `okstra team`
1700
+ 은 워커를 띄울 때마다 team-state 에 `workerDispatches[]` 행을 남기고
1701
+ (`dispatch_core._dispatch_record` — `kind`, `workerResultPath` 포함), 그
1702
+ `workerResultPath` 는 리드가 디스패치 요청에 실어 보낸 경로 그대로다
1703
+ (`dispatch_state.py` 의 `require_string(item, "workerResultPath")`). 결과
1704
+ 파일이 어느 seq 로 쓰였든 그 기록이 정답을 들고 있다.
1705
+
1706
+ plan-body 상태 파일(`plan-body-verification-<task-type>-<seq>.json`)의
1707
+ 라운드/판정 행에는 파일명이 없어서 이 용도로 못 쓴다.
1708
+
1709
+ team-state 를 못 찾으면 ``None`` — 호출자가 seq 글롭으로 내려간다.
1710
+ 빈 dict 은 "기록은 있는데 재검증 디스패치가 한 건도 없다" 로, 라운드가
1711
+ 아예 안 돈 경우다.
1712
+ """
1713
+ state_dir = report_path.parent.parent / "state"
1714
+ if not state_dir.is_dir():
1715
+ return None
1716
+ seqs = {_report_run_seq(report_path) or ""} | _manifest_seq_for_report(
1717
+ report_path, "state"
1718
+ )
1719
+ dispatched: dict[str, set[str]] = {}
1720
+ seen_state = False
1721
+ for seq in sorted(s for s in seqs if s):
1722
+ path = state_dir / f"team-state-{task_type}-{seq}.json"
1723
+ if not path.is_file():
1724
+ continue
1725
+ try:
1726
+ payload = load_owned_object(path, artifact="team state")
1727
+ except JsonBoundaryError:
1728
+ continue
1729
+ seen_state = True
1730
+ for row in payload.get("workerDispatches") or []:
1731
+ if not isinstance(row, dict):
1732
+ continue
1733
+ # critic 동수 라운드는 `kind: "critic"` 으로 나간다 — 결과 파일명은
1734
+ # 같은 `-plan-verify-r<N>-` 꼴이다. reverify 계열만 세면 동수가 있던
1735
+ # run 마다 critic 표가 "디스패치 기록 없음" 으로 오탐된다(2026-09-09,
1736
+ # fontsninja-v3-site dev-10627 planning 002). 계획 본문 라운드 자신의
1737
+ # kind 인 `plan-verify-r<N>` 도 같은 이유로 센다.
1738
+ kind = str(row.get("kind") or "")
1739
+ if not (
1740
+ kind.startswith("reverify-r")
1741
+ or kind.startswith("plan-verify-r")
1742
+ or kind == "critic"
1743
+ ):
1744
+ continue
1745
+ name = Path(str(row.get("workerResultPath") or "")).name
1746
+ if "-plan-verify-r" not in name:
1747
+ continue
1748
+ role = name.split("-plan-verify-r", 1)[0]
1749
+ if role:
1750
+ dispatched.setdefault(role, set()).add(name)
1751
+ return dispatched if seen_state else None
1752
+
1753
+
1754
+ def _plan_verify_seq_near_misses(report_path: Path, task_type: str) -> list[str]:
1755
+ """이 런의 것으로 인정되지 않은, 같은 디렉터리의 plan-verify 결과 파일.
1756
+
1757
+ "파일이 없다" 와 "파일은 있는데 이 런의 seq 가 아니다" 는 해소책이 다르다.
1758
+ 앞의 것은 디스패치를 다시 돌려야 하고, 뒤의 것은 이미 나온 결과로 게이트를
1759
+ 다시 계산해야 한다. 다만 재실행이 누적된 디렉터리에서는 이 목록이 수백 건이
1760
+ 되므로, 호출부가 표본만 싣는다(`_unbacked_remedy_clause`).
1761
+ """
1762
+ seq = _report_run_seq(report_path)
1763
+ if not seq:
1764
+ return []
1765
+ directory = report_path.parent.parent / "worker-results"
1766
+ accepted: set[Path] = set()
1767
+ for alias in _plan_verify_seq_aliases(report_path):
1768
+ accepted.update(directory.glob(f"*-plan-verify-r*-{task_type}-{alias}.md"))
1769
+ for names in (
1770
+ _plan_verify_dispatched_results(report_path, task_type) or {}
1771
+ ).values():
1772
+ accepted.update(directory / name for name in names)
1773
+ return sorted(
1774
+ path.name
1775
+ for path in directory.glob(f"*-plan-verify-r*-{task_type}-*.md")
1776
+ if path not in accepted
1777
+ )
1778
+
1779
+
1780
+ def _validate_plan_body_verdict_provenance(
1781
+ data: dict,
1782
+ report_path: Path,
1783
+ failures: list[str],
1784
+ ) -> None:
1785
+ """A recorded verdict must trace back to a worker that was actually asked.
1786
+
1787
+ Every §5.5.9 gate computation reads `planItems[].verdicts[]` out of the
1788
+ data.json the lead authored, and nothing tied a vote to a dispatch. A lead
1789
+ that skipped the round entirely and wrote `AGREE` for two workers produced
1790
+ `gateResult: passed`, a flippable `approved:`, and a clean validator run —
1791
+ the same self-report weakness `selfFixRoundsApplied` had, but on the votes
1792
+ the whole gate is computed from.
1793
+ """
1794
+ ip = data.get("implementationPlanning")
1795
+ if not isinstance(ip, dict):
1796
+ return
1797
+ pbv = ip.get("planBodyVerification")
1798
+ if not isinstance(pbv, dict):
1799
+ return
1800
+ round_count = pbv.get("roundCount")
1801
+ if not isinstance(round_count, int) or round_count < 1:
1802
+ return
1803
+
1804
+ voters = {
1805
+ str(v.get("worker") or "").strip()
1806
+ for item in (pbv.get("planItems") or [])
1807
+ if isinstance(item, dict)
1808
+ for v in (item.get("verdicts") or [])
1809
+ if isinstance(v, dict) and str(v.get("worker") or "").strip()
1810
+ }
1811
+ if not voters:
1812
+ return
1813
+
1814
+ task_type = str(
1815
+ (data.get("header") or {}).get("taskType") or ""
1816
+ ) or _report_task_type(report_path)
1817
+ if not task_type:
1818
+ # Neither source names it, so the glob below would be built from an
1819
+ # empty segment and match nothing — reporting every verdict as unbacked
1820
+ # on the strength of a path this check could not construct.
1821
+ return
1822
+ results_dir = report_path.parent.parent / "worker-results"
1823
+ recorded = _plan_verify_dispatched_results(report_path, task_type)
1824
+ if recorded is None:
1825
+ # 기록된 디스패치가 없다 — seq 글롭으로 내려간다.
1826
+ dispatched = _plan_verify_result_workers(report_path, task_type)
1827
+ if dispatched is None:
1828
+ return
1829
+ source = (
1830
+ f"no team-state for this run was readable under `runs/{task_type}/"
1831
+ f"state/`, so this fell back to globbing `*-plan-verify-r*-"
1832
+ f"{task_type}-<seq>.md` under `runs/{task_type}/worker-results/` "
1833
+ f"for seq(s) {sorted(_plan_verify_seq_aliases(report_path))}"
1834
+ )
1835
+ returned = {_analyser_key(name) for name in dispatched}
1836
+ never_dispatched: set[str] = set()
1837
+ else:
1838
+ # 기록된 디스패치가 정답이다. 투표가 뒷받침되려면 (1) 그 역할로 나간
1839
+ # 재검증 디스패치 기록이 있고 (2) 그 기록이 적어 둔 결과 파일이 디스크에
1840
+ # 실제로 있어야 한다. seq 는 어디에도 안 쓴다 — 갈라진 seq 로 나간
1841
+ # 디스패치도 기록에는 자기 파일명 그대로 남아 있다.
1842
+ source = (
1843
+ f"resolved from the `workerDispatches[]` rows this run's team-state "
1844
+ f"recorded under `runs/{task_type}/state/` (each row's own "
1845
+ f"`workerResultPath`, not a seq glob)"
1846
+ )
1847
+ recorded_keys = {_analyser_key(role) for role in recorded}
1848
+ returned = {
1849
+ _analyser_key(role)
1850
+ for role, names in recorded.items()
1851
+ if any((results_dir / name).is_file() for name in names)
1852
+ }
1853
+ never_dispatched = {
1854
+ _analyser_key(voter)
1855
+ for voter in voters
1856
+ if _analyser_key(voter) not in recorded_keys
1857
+ }
1858
+ # 파일명 슬러그와 투표 키를 같은 축으로 놓는다. 결과 파일명은 cmux 어댑터가
1859
+ # `-worker-` 토큰을 요구하는데(`workerResultPath`) 투표 키는 워커 id 그대로다.
1860
+ # 워커 id 가 `-worker` 로 끝나던 기본 로스터에서는 두 규칙이 우연히 같은
1861
+ # 이름을 냈지만, `grok-planner` 처럼 역할 접미사가 붙은 id 에서는 두 규칙을
1862
+ # 동시에 만족하는 이름이 존재하지 않는다.
1863
+ unbacked = sorted(voter for voter in voters if _analyser_key(voter) not in returned)
1864
+ if unbacked:
1865
+ no_dispatch = sorted(
1866
+ voter for voter in unbacked if _analyser_key(voter) in never_dispatched
1867
+ )
1868
+ failures.append(
1869
+ "final-report data.json: planBodyVerification records verdicts from "
1870
+ f"{unbacked} but no plan-body reverify result file backs them — "
1871
+ f"{source}."
1872
+ + _unbacked_remedy_clause(report_path, task_type, no_dispatch)
1873
+ + " A vote the gate is computed from MUST trace back to a dispatch "
1874
+ "that actually returned — otherwise the round can be skipped and "
1875
+ 'the gate still read `passed` (plan-body-verification.md §"Round '
1876
+ 'protocol" step 3).'
1877
+ )
1878
+
1879
+
1880
+ _NEAR_MISS_SAMPLE = 6
1881
+
1882
+
1883
+ def _unbacked_remedy_clause(
1884
+ report_path: Path, task_type: str, no_dispatch: list[str]
1885
+ ) -> str:
1886
+ """뒷받침 없는 투표에 남길 실제 갈래와 정당한 해소책.
1887
+
1888
+ 파일 이름을 바꾸라는 안내를 여기서 걷어냈다. 그 안내는 실행됐고(디렉터리에
1889
+ 개명 사본이 남았다), 개명은 어느 디스패치가 그 결과를 냈는지를 지워 검사가
1890
+ 막으려던 바로 그 상태 — 대조할 기록이 없는 투표 — 를 만든다.
1891
+ """
1892
+ parts: list[str] = []
1893
+ if no_dispatch:
1894
+ parts.append(
1895
+ f" No reverify dispatch was recorded at all for {no_dispatch}, so "
1896
+ "those verdicts are unbacked: either the round genuinely never ran "
1897
+ "(re-dispatch it, or record `verification-error` for the workers "
1898
+ "that produced no result) or it ran outside `okstra team dispatch` "
1899
+ "and left no `workerDispatches[]` row, which is itself the "
1900
+ "violation."
1901
+ )
1902
+ near = _plan_verify_seq_near_misses(report_path, task_type)
1903
+ if near:
1904
+ sample = near[:_NEAR_MISS_SAMPLE]
1905
+ more = (
1906
+ f" (+{len(near) - len(sample)} more; the directory accumulates "
1907
+ "every rerun of this task-type)"
1908
+ if len(near) > len(sample)
1909
+ else ""
1910
+ )
1911
+ parts.append(
1912
+ f" The directory does hold plan-verify results under other "
1913
+ f"sequences — {sample}{more}. If one of those is this round's "
1914
+ "output, the round was dispatched under a sequence this report does "
1915
+ "not carry: recompute the gate from the files that exist (re-run "
1916
+ "`okstra plan-items apply-verdicts --result <worker>=<file>` "
1917
+ "against them and re-record the round) so the verdicts and their "
1918
+ "evidence agree. Do NOT rename a result file to this report's seq — "
1919
+ "renaming destroys the link between a vote and the dispatch that "
1920
+ "produced it, which is exactly what this check reads."
1921
+ )
1922
+ return "".join(parts)
1923
+
1924
+
1925
+ _UNIFORM_VERIFIER_MIN_ITEMS = 5
1926
+
1927
+
1928
+ def _detect_uniform_verifier(pbv: dict) -> list[str]:
1929
+ """Verifiers whose every vote in the round was the same verdict.
1930
+
1931
+ `participatingAnalysers` counts whether a worker voted, not whether the
1932
+ votes carried information. A verifier that answers AGREE to every item is
1933
+ counted as a third opinion while contributing no refutation signal, so the
1934
+ report reads as a three-way cross-check backed by two. (fontsninja-nlpvibe
1935
+ `nlpvibe-vs-fontradar-baseline` seq 001: 63/63 AGREE off six inspected
1936
+ evidence paths, on a round where the two other analysers jointly refuted a
1937
+ real defect.)
1938
+
1939
+ Advisory only. A unanimous round is a legitimate outcome, and any ratio
1940
+ strict enough to catch a rubber stamp also fails honest agreement, so this
1941
+ reports the counts and leaves the judgement to the reader.
1942
+ """
1943
+ items = pbv.get("planItems") if isinstance(pbv, dict) else None
1944
+ if not isinstance(items, list):
1945
+ return []
1946
+ verdicts_by_worker: dict[str, set[str]] = {}
1947
+ counts: dict[str, int] = {}
1948
+ for item in items:
1949
+ if not isinstance(item, dict):
1950
+ continue
1951
+ for verdict in item.get("verdicts") or []:
1952
+ if not isinstance(verdict, dict):
1953
+ continue
1954
+ worker = str(verdict.get("worker") or "").strip()
1955
+ value = str(verdict.get("verdict") or "").strip()
1956
+ if not worker or not value or value == "verification-error":
1957
+ continue
1958
+ verdicts_by_worker.setdefault(worker, set()).add(value)
1959
+ counts[worker] = counts.get(worker, 0) + 1
1960
+ warnings = []
1961
+ for worker in sorted(verdicts_by_worker):
1962
+ distinct = verdicts_by_worker[worker]
1963
+ total = counts[worker]
1964
+ if len(distinct) != 1 or total < _UNIFORM_VERIFIER_MIN_ITEMS:
1965
+ continue
1966
+ warnings.append(
1967
+ f"plan-body verification: {worker} returned `{next(iter(distinct))}` "
1968
+ f"for all {total} items it voted on, so this round's refutation "
1969
+ "signal came from its peers alone. Confirm the worker actually "
1970
+ "opened the cited evidence (its `-audit-` sidecar lists what it "
1971
+ "read) before reading the gate as a full cross-check."
1972
+ )
1973
+ return warnings
1974
+
1975
+
1976
+ def _validate_plan_item_extraction_completeness(
1977
+ data: dict,
1978
+ failures: list[str],
1979
+ ) -> None:
1980
+ """Require the exact deterministic P-* extraction when a round ran."""
1981
+ ip = data.get("implementationPlanning")
1982
+ if not isinstance(ip, dict):
1983
+ return
1984
+ pbv = ip.get("planBodyVerification")
1985
+ if not isinstance(pbv, dict):
1986
+ return
1987
+ round_count = pbv.get("roundCount")
1988
+ if not isinstance(round_count, int) or round_count < 1:
1989
+ return
1990
+ try:
1991
+ expected_sequence = expected_plan_item_ids(ip)
1992
+ except Exception as exc: # noqa: BLE001
1993
+ failures.append(
1994
+ f"final-report data.json: deterministic plan-item extraction failed: {exc}"
1995
+ )
1996
+ return
1997
+
1998
+ actual_sequence = [
1999
+ str(item.get("id") or "").strip()
2000
+ for item in (pbv.get("planItems") or [])
2001
+ if isinstance(item, dict)
2002
+ ]
2003
+ expected_ids = set(expected_sequence)
2004
+ actual_ids = set(actual_sequence)
2005
+ missing = expected_ids - actual_ids
2006
+ unexpected = actual_ids - expected_ids
2007
+ duplicate_ids = {
2008
+ item_id for item_id in actual_ids if actual_sequence.count(item_id) > 1
2009
+ }
2010
+
2011
+ if missing:
2012
+ failures.append(
2013
+ "final-report data.json: planBodyVerification.planItems is missing "
2014
+ "deterministically extracted verdict item ID(s): "
2015
+ + ", ".join(sorted(missing))
2016
+ )
2017
+ if unexpected:
2018
+ failures.append(
2019
+ "final-report data.json: planBodyVerification.planItems contains "
2020
+ "unexpected verdict item ID(s): " + ", ".join(sorted(unexpected))
2021
+ )
2022
+ if duplicate_ids:
2023
+ failures.append(
2024
+ "final-report data.json: planBodyVerification.planItems contains "
2025
+ "duplicate verdict item ID(s): " + ", ".join(sorted(duplicate_ids))
2026
+ )
2027
+
2028
+
2029
+ def _validate_variation_point_analysis(
2030
+ vpa: object,
2031
+ architecture_style: str,
2032
+ failures: list[str],
2033
+ ) -> None:
2034
+ """Conditional rules + architecture-style overlay for variation points.
2035
+
2036
+ The schema enforces shape only. Two layers of meaning sit on top:
2037
+
2038
+ Layer 1 is style-agnostic. Declaring "no variation exists" is a claim that
2039
+ needs a written reason, and it must not be paired with declared points —
2040
+ `plan_items._extract_variation_point_items` emits a lone `P-Var-0` in that
2041
+ branch and drops them, so the contradiction would silently exempt every
2042
+ declared point from per-point verification. `extract: true` is a claim in
2043
+ the same way: it names the interface the next implementation plugs into and
2044
+ the Stage Map stage that builds it, so both fields have to be filled. The
2045
+ schema cannot carry this as a `minLength` — the empty string is the natural
2046
+ shape of an `extract: false` decision.
2047
+
2048
+ Layer 2 fires only for a project that declares `architecture.style`
2049
+ `hexagonal`: extracting a variation point there means introducing a port,
2050
+ not a helper. An unconfigured project resolves to `none` and keeps layer-1
2051
+ behaviour only.
2052
+ """
2053
+ if not isinstance(vpa, dict):
2054
+ failures.append("variationPointAnalysis is missing or not an object")
2055
+ return
2056
+ # Schema violations are reported, not raised, so this check still runs on a
2057
+ # malformed block. A type guard here keeps a bad field from aborting the
2058
+ # whole validation and discarding every failure collected so far.
2059
+ raw_points = vpa.get("points")
2060
+ if raw_points is not None and not isinstance(raw_points, list):
2061
+ failures.append("variationPointAnalysis: points must be an array")
2062
+ return
2063
+ points = raw_points or []
2064
+ if not bool(vpa.get("hasMultipleImplementations")):
2065
+ rationale = vpa.get("noVariationRationale")
2066
+ if not isinstance(rationale, str) or not rationale.strip():
2067
+ failures.append(
2068
+ "variationPointAnalysis: hasMultipleImplementations=false "
2069
+ "requires a non-empty noVariationRationale"
2070
+ )
2071
+ if points:
2072
+ failures.append(
2073
+ "variationPointAnalysis: hasMultipleImplementations=false but "
2074
+ "points is non-empty — declared variation points would be "
2075
+ "silently dropped"
2076
+ )
2077
+ return
2078
+ if not points:
2079
+ failures.append(
2080
+ "variationPointAnalysis: hasMultipleImplementations=true requires "
2081
+ "at least one point"
2082
+ )
2083
+ return
2084
+ for index, point in enumerate(points, start=1):
2085
+ if not isinstance(point, dict):
2086
+ failures.append(f"variationPointAnalysis point {index}: must be an object")
2087
+ continue
2088
+ decision = point.get("extractionDecision")
2089
+ if not isinstance(decision, dict):
2090
+ continue # shape is the schema's job; don't double-report it.
2091
+ if not decision.get("extract"):
2092
+ continue
2093
+ for field in ("interfaceKind", "coveredBy"):
2094
+ value = decision.get(field)
2095
+ if not isinstance(value, str) or not value.strip():
2096
+ failures.append(
2097
+ f"variationPointAnalysis point {index}: extract=true "
2098
+ f"requires a non-empty {field}, got {value!r}"
2099
+ )
2100
+ if (
2101
+ architecture_style == "hexagonal"
2102
+ and decision.get("interfaceKind") != "port"
2103
+ ):
2104
+ failures.append(
2105
+ f"variationPointAnalysis point {index}: architecture style "
2106
+ f"'hexagonal' requires interfaceKind 'port', got "
2107
+ f"{decision.get('interfaceKind')!r}"
2108
+ )
2109
+
2110
+
2111
+ _DESIGN_PREP_CONTRACT = "implementation-design-prep-v1"
2112
+
2113
+
2114
+ _DESIGN_PREP_REQUEST_STATUSES = {"provisional", "blocked"}
2115
+
2116
+
2117
+ _DESIGN_PREP_TERMINAL_STATUSES = {"ready", "not-applicable"}
2118
+
2119
+
2120
+ def _design_prep_rows(
2121
+ planning: dict,
2122
+ failures: list[str],
2123
+ ) -> tuple[list[dict], dict[str, dict]] | None:
2124
+ preparation = planning.get("designPreparation")
2125
+ if not isinstance(preparation, dict):
2126
+ failures.append(
2127
+ "final-report data.json: implementationPlanning.designPreparation "
2128
+ "is malformed; expected an object"
2129
+ )
2130
+ return None
2131
+ raw_items = preparation.get("items")
2132
+ if not isinstance(raw_items, list) or any(
2133
+ not isinstance(item, dict) for item in raw_items
2134
+ ):
2135
+ failures.append(
2136
+ "final-report data.json: implementationPlanning.designPreparation.items "
2137
+ "is malformed; expected an array of objects"
2138
+ )
2139
+ return None
2140
+ items = list(raw_items)
2141
+ items_by_id: dict[str, dict] = {}
2142
+ for item in items:
2143
+ item_id = item.get("id")
2144
+ if not isinstance(item_id, str) or not item_id:
2145
+ failures.append(
2146
+ "final-report data.json: designPreparation item has malformed id"
2147
+ )
2148
+ continue
2149
+ if item_id in items_by_id:
2150
+ failures.append(
2151
+ f"final-report data.json: duplicate designPreparation item {item_id}"
2152
+ )
2153
+ items_by_id[item_id] = item
2154
+ return items, items_by_id
2155
+
2156
+
2157
+ def _validate_design_prep_states(items: list[dict], failures: list[str]) -> None:
2158
+ for item in items:
2159
+ item_id = str(item.get("id") or "<missing>")
2160
+ stage_refs = item.get("stageRefs") or []
2161
+ review_at = item.get("reviewAt")
2162
+ if isinstance(review_at, dict) and "stage" in review_at:
2163
+ if review_at.get("stage") not in stage_refs:
2164
+ failures.append(
2165
+ f"final-report data.json: {item_id}.reviewAt.stage must belong "
2166
+ "to stageRefs"
2167
+ )
2168
+ status = item.get("status")
2169
+ if status == "blocked" and (
2170
+ not isinstance(item.get("humanConfirmation"), dict)
2171
+ or item["humanConfirmation"].get("required") is not True
2172
+ ):
2173
+ failures.append(
2174
+ f"final-report data.json: blocked {item_id} requires "
2175
+ "humanConfirmation.required=true"
2176
+ )
2177
+ if status in _DESIGN_PREP_REQUEST_STATUSES and not isinstance(
2178
+ item.get("requestPath"), str
2179
+ ):
2180
+ failures.append(
2181
+ f"final-report data.json: {status} {item_id} requires requestPath"
2182
+ )
2183
+ if status in _DESIGN_PREP_TERMINAL_STATUSES and "requestPath" in item:
2184
+ failures.append(
2185
+ f"final-report data.json: terminal {item_id} must not carry requestPath"
2186
+ )
2187
+
2188
+
2189
+ def _validate_design_prep_requests(
2190
+ data: dict,
2191
+ report_path: Path,
2192
+ items: list[dict],
2193
+ failures: list[str],
2194
+ ) -> None:
2195
+ data_path = _data_path_for(report_path)
2196
+ planning_seq = _design_prep_planning_seq(data_path)
2197
+ report_language = _design_prep_report_language(data)
2198
+ for item in items:
2199
+ if item.get("status") not in _DESIGN_PREP_REQUEST_STATUSES:
2200
+ continue
2201
+ item_id = str(item.get("id") or "<missing>")
2202
+ target, expected = _render_design_prep_request(
2203
+ data_path=data_path,
2204
+ planning_seq=planning_seq,
2205
+ report_language=report_language,
2206
+ item=item,
2207
+ )
2208
+ if not target.is_file():
2209
+ failures.append(
2210
+ f"final-report data.json: design-prep request is missing for {item_id}: "
2211
+ f"{target}"
2212
+ )
2213
+ continue
2214
+ try:
2215
+ actual = target.read_bytes()
2216
+ except OSError as exc:
2217
+ failures.append(
2218
+ f"final-report data.json: cannot read design-prep request for "
2219
+ f"{item_id}: {exc}"
2220
+ )
2221
+ continue
2222
+ # 동일성 판정은 조립(`design_prep._request_conflicts`)과 같은 신원을 쓴다.
2223
+ # 두 곳이 다른 기준을 걸면 조립이 통과시킨 파일을 검증이 stale 로 떨어뜨려,
2224
+ # run 이 고칠 수 없는 실패에 갇힌다 — 이월된 요청은 발행 리포트 줄만
2225
+ # 다르고, 그 줄을 현행화하려면 이전 run 의 기록을 덮어써야 한다.
2226
+ if _design_prep_request_identity(actual) != _design_prep_request_identity(
2227
+ expected
2228
+ ):
2229
+ failures.append(
2230
+ f"final-report data.json: design-prep request content or "
2231
+ f"fingerprint is stale for {item_id}: {target}"
2232
+ )
2233
+
2234
+
2235
+ def _validate_design_prep_contract(
2236
+ data: dict,
2237
+ report_path: Path | None,
2238
+ report_contracts: set[str],
2239
+ failures: list[str],
2240
+ ) -> list[str]:
2241
+ warnings: list[str] = []
2242
+ planning = data.get("implementationPlanning")
2243
+ if not isinstance(planning, dict):
2244
+ if _DESIGN_PREP_CONTRACT in report_contracts:
2245
+ failures.append(
2246
+ "final-report data.json: implementationPlanning is malformed"
2247
+ )
2248
+ return warnings
2249
+ preparation = planning.get("designPreparation")
2250
+ if not isinstance(preparation, dict):
2251
+ if _DESIGN_PREP_CONTRACT in report_contracts:
2252
+ failures.append(
2253
+ "final-report data.json: marker implementation-design-prep-v1 "
2254
+ "requires implementationPlanning.designPreparation"
2255
+ )
2256
+ else:
2257
+ warnings.append("legacy-unassessed")
2258
+ return warnings
2259
+ if _DESIGN_PREP_CONTRACT not in report_contracts:
2260
+ return warnings
2261
+ try:
2262
+ # 탐지기 재실행 대조와 prep 항목 ↔ coverage 양방향 참조 대조는
2263
+ # 삭제했다. 둘 다 `design_snapshot.build` 가 한 번에 만든 값을 같은
2264
+ # 입력으로 되계산해 자기 자신과 맞춰 보는 항등식이었다.
2265
+ parsed = _design_prep_rows(planning, failures)
2266
+ if parsed is None:
2267
+ return warnings
2268
+ items, _ = parsed
2269
+ _validate_design_prep_states(items, failures)
2270
+ if report_path is not None:
2271
+ _validate_design_prep_requests(data, report_path, items, failures)
2272
+ except (
2273
+ DesignSurfaceError,
2274
+ DesignPrepError,
2275
+ KeyError,
2276
+ TypeError,
2277
+ ValueError,
2278
+ ) as exc:
2279
+ failures.append(
2280
+ f"final-report data.json: design-preparation contract is malformed: {exc}"
2281
+ )
2282
+ except Exception as exc: # noqa: BLE001
2283
+ failures.append(
2284
+ "final-report data.json: design-preparation validation failed closed "
2285
+ f"on malformed input: {exc}"
2286
+ )
2287
+ return warnings
2288
+
2289
+
2290
+ # A `subject` this short or shaped like a bare `P-Opt-1` id is a placeholder,
2291
+ # not the plain-language "what this item is" label §5.5.9 renders as a heading.
2292
+ _MIN_SUBJECT_LEN = 3
2293
+
2294
+
2295
+ _BARE_PLAN_ITEM_ID_RE = re.compile(
2296
+ r"^P-(?:Opt|Step|Dep|Val|Rb|Req)-\d+$", re.IGNORECASE
2297
+ )
2298
+
2299
+
2300
+ def _validate_plan_item_subject_substance(data: dict, failures: list[str]) -> None:
2301
+ """H2 follow-up — `planItems[].subject` must be a real label, not a
2302
+ placeholder. The schema only enforces non-empty, so `"x"` or a copied
2303
+ `P-Opt-1` id would otherwise slip through and defeat the whole point of the
2304
+ subject (letting a reader see *what* each AGREE/DISAGREE is about).
2305
+ """
2306
+ ip = data.get("implementationPlanning")
2307
+ if not isinstance(ip, dict):
2308
+ return
2309
+ pbv = ip.get("planBodyVerification")
2310
+ if not isinstance(pbv, dict):
2311
+ return
2312
+ for item in pbv.get("planItems") or []:
2313
+ if not isinstance(item, dict):
2314
+ continue
2315
+ item_id = str(item.get("id") or "").strip()
2316
+ subject = str(item.get("subject") or "").strip()
2317
+ if (
2318
+ len(subject) < _MIN_SUBJECT_LEN
2319
+ or subject == item_id
2320
+ or _BARE_PLAN_ITEM_ID_RE.match(subject)
2321
+ ):
2322
+ failures.append(
2323
+ f"final-report data.json: plan item `{item_id or '<unknown>'}` has a "
2324
+ f"placeholder subject `{subject}`. Give a plain-language label of "
2325
+ "what the item is (e.g. 'Option A: upload v2 를 신규 모듈로 분리') so "
2326
+ "the §5.5.9 reader knows what each verdict is about "
2327
+ "(plan-body-verification.md Plan-item extraction)."
2328
+ )
2329
+
2330
+
2331
+ def _validate_plan_body_clarification_matching(
2332
+ data: dict,
2333
+ failures: list[str],
2334
+ accepted_item_ids: set[str] | None = None,
2335
+ ) -> None:
2336
+ """H5 — every plan item whose *gate class after stage scope* is
2337
+ `majority-disagree` must point at an existing `blocks: approval`
2338
+ clarification row. Observed / deferred / record items stay in `setAside`
2339
+ and must not become a new C row — that is what grew the clarification
2340
+ list while the next stage was already executable.
2341
+ """
2342
+ ip = data.get("implementationPlanning")
2343
+ if not isinstance(ip, dict):
2344
+ return
2345
+ pbv = ip.get("planBodyVerification")
2346
+ if not isinstance(pbv, dict):
2347
+ return
2348
+ round_count = pbv.get("roundCount")
2349
+ if not isinstance(round_count, int) or round_count < 1:
2350
+ return
2351
+ # `gating=false` 는 이 라운드를 자문으로 돌린다 — plan-body-verification.md
2352
+ # "If `false`, the round is advisory-only and never blocks approval".
2353
+ # 게이트 계산은 이미 그것을 존중한다(`_recompute_plan_body_gate`,
2354
+ # `_gate_blocking_causes`). 이 검사만 그 상태를 안 보면 자문 라운드가
2355
+ # 승인 차단 행을 강제하게 되어, 막지 않기로 한 판정이 다시 막는다.
2356
+ if pbv.get("gating") is False and not requires_plan_repair(pbv):
2357
+ return
2358
+ accepted = (
2359
+ _resolved_noncritical_dissent_ids(data)
2360
+ if accepted_item_ids is None
2361
+ else accepted_item_ids
2362
+ )
2363
+ clar_rows = [
2364
+ r for r in (data.get("clarificationItems") or []) if isinstance(r, dict)
2365
+ ]
2366
+ all_ids = {r.get("id") for r in clar_rows if r.get("id")}
2367
+ approval_ids = {
2368
+ r.get("id") for r in clar_rows if r.get("blocks") == "approval" and r.get("id")
2369
+ }
2370
+ for item in pbv.get("planItems") or []:
2371
+ if not isinstance(item, dict):
2372
+ continue
2373
+ if _plan_item_gate_class(item, pbv, accepted) != "majority-disagree":
2374
+ continue
2375
+ item_id = item.get("id") or "<unknown>"
2376
+ cids = _plan_item_clarification_ids(item)
2377
+ if not cids:
2378
+ failures.append(
2379
+ f"final-report data.json: plan item `{item_id}` is majority-disagree "
2380
+ "but carries no `clarificationRefs`. A blocking disagreement MUST "
2381
+ "surface as a `## 1. Clarification Items` row (blocks=approval) so "
2382
+ "the user sees the blocker (implementation-planning.md self-review "
2383
+ "step 12). Report assembly derives this link from the activity "
2384
+ "ledger's `clarificationRefs[]` + `planItemIds[]`, so record the "
2385
+ "decision through `okstra approval-decision` rather than editing "
2386
+ "the report."
2387
+ )
2388
+ continue
2389
+ for cid in sorted(cids - approval_ids):
2390
+ reason = (
2391
+ "references a non-existent §1 row"
2392
+ if cid not in all_ids
2393
+ else "references a §1 row whose `blocks` is not `approval`"
2394
+ )
2395
+ failures.append(
2396
+ f"final-report data.json: plan item `{item_id}` (majority-disagree) "
2397
+ f"has clarificationRefs entry `{cid}` which {reason}. Every "
2398
+ "majority-disagree item MUST reach a `blocks: approval` "
2399
+ "Clarification row."
2400
+ )
2401
+
2402
+
2403
+ def _validate_self_fix_before_clarification(data: dict, failures: list[str]) -> None:
2404
+ """A planner-fixable defect MUST exhaust the self-fix budget before it is
2405
+ promoted to a `## 1. Clarification Items` row. Closes the hole where the
2406
+ lead dumps a fixable plan defect (abbreviated path, prose command,
2407
+ placeholder, coverage remap) onto the user instead of having report-writer
2408
+ correct it (plan-body-verification.md "Self-fix round").
2409
+ """
2410
+ ip = data.get("implementationPlanning")
2411
+ if not isinstance(ip, dict):
2412
+ return
2413
+ pbv = ip.get("planBodyVerification")
2414
+ if not isinstance(pbv, dict):
2415
+ return
2416
+ round_count = pbv.get("roundCount")
2417
+ if not isinstance(round_count, int) or round_count < 1:
2418
+ return
2419
+ # `gating=false` 는 이 라운드를 자문으로 돌린다 — plan-body-verification.md
2420
+ # "If `false`, the round is advisory-only and never blocks approval" 이고
2421
+ # 같은 행이 "does not run the self-fix loop" 라고 못박는다.
2422
+ #
2423
+ # 이 검사가 그 상태를 안 보면 `_validate_advisory_plan_body_gating` 과 정면
2424
+ # 충돌한다: 그쪽은 `gating=false` 에서 `selfFixRoundsApplied > 0` 을 실패로
2425
+ # 잡는데 이 검사는 `>= 1` 을 요구한다. 한 필드에 반대 방향 요구가 걸리므로
2426
+ # 자문 라운드에 planner-fixable 과반 반대가 하나라도 나오면 통과 가능한
2427
+ # 값이 없다. `okstra plan-items complete-round` 도 자문 라운드의 self-fix
2428
+ # 기록을 거부하므로(plan_items_cli.py) 우회로도 없다.
2429
+ if pbv.get("gating") is False and not requires_plan_repair(pbv):
2430
+ return
2431
+ if _self_fix_budget_exhausted(pbv):
2432
+ return
2433
+ rounds_applied = pbv.get("selfFixRoundsApplied")
2434
+ stop_reason = pbv.get("selfFixStopReason")
2435
+ for item in pbv.get("planItems") or []:
2436
+ if not isinstance(item, dict):
2437
+ continue
2438
+ if _plan_item_gate_class(item, pbv, set()) != "majority-disagree":
2439
+ continue
2440
+ if _has_planner_fixable_majority(item):
2441
+ allowed = " / ".join(sorted(_SELF_FIX_EXHAUSTED_REASONS))
2442
+ failures.append(
2443
+ "final-report data.json: plan item "
2444
+ f"`{item.get('id') or '<unknown>'}` is majority-disagree with a "
2445
+ f"planner-fixable majority but the self-fix budget is not "
2446
+ f"exhausted (`selfFixRoundsApplied`={rounds_applied!r}, "
2447
+ f"`selfFixStopReason`={stop_reason!r}; need >=1 rounds and a stop "
2448
+ f"reason of {allowed}). A planner-fixable defect MUST be corrected "
2449
+ "by report-writer self-fix rounds until the budget runs out or a "
2450
+ "round makes no progress, before it becomes a clarification row "
2451
+ "(plan-body-verification.md Self-fix round)."
2452
+ )
2453
+
2454
+
2455
+ # Allowed `fixability` values, mirroring the schema enum
2456
+ # (schemas/final-report-v2.0.schema.json planItems[].verdicts[].fixability).
2457
+ _FIXABILITY_VALUES = frozenset({"planner-fixable", "needs-user-input"})
2458
+
2459
+
2460
+ def _validate_disagree_has_fixability(data: dict, failures: list[str]) -> None:
2461
+ """Every `DISAGREE` verdict MUST carry a valid `fixability`
2462
+ (`planner-fixable` / `needs-user-input`). The schema enum only constrains a
2463
+ *present* value; it does not require the field, so a DISAGREE with a missing
2464
+ or mislabelled fixability is schema-valid. That silently degrades
2465
+ `_validate_self_fix_before_clarification`, which counts a non-`planner-fixable`
2466
+ DISAGREE as non-fixable and thus lets a genuinely planner-fixable defect
2467
+ skip the self-fix round and land on the user. This is the enforcement point
2468
+ for the "fixability is DISAGREE-only 필수" MUST in plan-body-verification.md.
2469
+ """
2470
+ ip = data.get("implementationPlanning")
2471
+ if not isinstance(ip, dict):
2472
+ return
2473
+ pbv = ip.get("planBodyVerification")
2474
+ if not isinstance(pbv, dict):
2475
+ return
2476
+ round_count = pbv.get("roundCount")
2477
+ if not isinstance(round_count, int) or round_count < 1:
2478
+ return
2479
+ for item in pbv.get("planItems") or []:
2480
+ if not isinstance(item, dict):
2481
+ continue
2482
+ item_id = item.get("id") or "<unknown>"
2483
+ for verdict in item.get("verdicts") or []:
2484
+ if not isinstance(verdict, dict):
2485
+ continue
2486
+ if str(verdict.get("verdict") or "").upper() != "DISAGREE":
2487
+ continue
2488
+ fixability = verdict.get("fixability")
2489
+ if fixability not in _FIXABILITY_VALUES:
2490
+ worker = verdict.get("worker") or "<worker>"
2491
+ allowed = " / ".join(sorted(_FIXABILITY_VALUES))
2492
+ failures.append(
2493
+ f"final-report data.json: plan item `{item_id}` has a "
2494
+ f"`DISAGREE` verdict from `{worker}` with "
2495
+ f"fixability `{fixability}` — a DISAGREE MUST declare a "
2496
+ f"fixability of {allowed}. A missing/invalid value is "
2497
+ "counted as non-fixable and lets a planner-fixable defect "
2498
+ "skip the self-fix round (plan-body-verification.md "
2499
+ '"fixability (DISAGREE 전용, 필수)").'
2500
+ )
2501
+
2502
+
2503
+ def validate_plan_body_section(
2504
+ data: dict,
2505
+ report_path: Path,
2506
+ failures: list[str],
2507
+ ) -> list[str]:
2508
+ """Run every §5.5.9 plan-body check and return the advisory warnings.
2509
+
2510
+ Grouped into one callable so the round protocol can run the same checks at
2511
+ each round boundary that the full run validation runs at the end. Before
2512
+ this seam existed the only way to reach them was a finished report plus all
2513
+ four manifests, so a lead computing the gate by hand mid-loop had nothing to
2514
+ check itself against until Phase 7 — and a whole self-fix budget could be
2515
+ spent against a mis-scored gate.
2516
+ """
2517
+ pbv = (data.get("implementationPlanning") or {}).get("planBodyVerification") or {}
2518
+ accepted_item_ids = _resolved_noncritical_dissent_ids(data)
2519
+ _validate_plan_body_gate_recompute(data, failures, accepted_item_ids)
2520
+ _validate_gate_blocked_by(data, failures, accepted_item_ids)
2521
+ _validate_participating_analysers(data, failures)
2522
+ _validate_self_fix_grouping(data, failures)
2523
+ _validate_plan_body_verdict_provenance(data, report_path, failures)
2524
+ _validate_aborted_gate_has_clarification(data, failures)
2525
+ _validate_round_recorded_verdicts(data, failures)
2526
+ _validate_verdicts_match_current_subjects(data, failures)
2527
+ _validate_verdict_rounds_outlive_self_fix(data, failures)
2528
+ _validate_unresolved_tie_was_reverified(data, failures)
2529
+ _validate_advisory_plan_body_gating(data, failures)
2530
+ _validate_plan_item_extraction_completeness(data, failures)
2531
+ _validate_plan_item_subject_substance(data, failures)
2532
+ _validate_plan_body_clarification_matching(data, failures, accepted_item_ids)
2533
+ _validate_disagree_has_fixability(data, failures)
2534
+ _validate_self_fix_before_clarification(data, failures)
2535
+ return [*_detect_self_fix_recurrence(pbv), *_detect_uniform_verifier(pbv)]
2536
+
2537
+
2538
+ def _gate_summary_item(
2539
+ item: dict,
2540
+ pbv: dict,
2541
+ accepted_item_ids: set[str],
2542
+ ) -> dict:
2543
+ """One `gate.items[]` row: the gate class plus its state-file counterpart."""
2544
+ classification = _plan_item_gate_class(item, pbv, accepted_item_ids)
2545
+ return {
2546
+ "id": item.get("id"),
2547
+ "classification": classification,
2548
+ "stateClassification": _state_classification(item, classification),
2549
+ "correctnessCritical": _is_correctness_critical(item),
2550
+ "decisionAuthority": _plan_item_decision_authority(item, pbv),
2551
+ "leadDecisionApplied": _lead_decision_applies(item, pbv),
2552
+ # 왜 안 막는지가 기록에 남아야 한다. 이 값이 없으면 범위 밖 강등과
2553
+ # 실제 합의가 산출물에서 같은 모양으로 읽힌다.
2554
+ "stageScope": _stage_scope_bucket(item, pbv),
2555
+ "block": str(item.get("block") or "execution"),
2556
+ }
2557
+
2558
+
2559
+ def plan_body_gate_summary(data: dict) -> dict | None:
2560
+ """The §5.5.9 gate as the round protocol's step 5 needs it — per-item
2561
+ classification, the whole-gate value, and the `gateBlockedBy` causes, all
2562
+ recomputed from `planItems[].verdicts`. Returns ``None`` when the report
2563
+ carries no plan items to judge.
2564
+
2565
+ This is what a lead records instead of tallying the verdicts by hand: the
2566
+ single-vote-blocking kinds, the advisory-only kinds and the P-Var / P-Rb
2567
+ exemptions are one implementation here, not a rule to be re-derived per
2568
+ round from the prompt's prose.
2569
+ """
2570
+ ip = data.get("implementationPlanning")
2571
+ if not isinstance(ip, dict):
2572
+ return None
2573
+ pbv = ip.get("planBodyVerification")
2574
+ if not isinstance(pbv, dict):
2575
+ return None
2576
+ accepted_item_ids = _resolved_noncritical_dissent_ids(data)
2577
+ recomputed = _recompute_plan_body_gate(pbv, accepted_item_ids)
2578
+ if recomputed is None:
2579
+ return None
2580
+ items = [
2581
+ _gate_summary_item(item, pbv, accepted_item_ids)
2582
+ for item in (pbv.get("planItems") or [])
2583
+ if isinstance(item, dict)
2584
+ ]
2585
+ coverage_blockers = _independent_coverage_blockers(ip, pbv)
2586
+ return {
2587
+ "declared": pbv.get("gateResult"),
2588
+ "recomputed": recomputed,
2589
+ "declaredBlockedBy": sorted(
2590
+ str(c) for c in (pbv.get("gateBlockedBy") or []) if isinstance(c, str)
2591
+ ),
2592
+ "blockedBy": sorted(
2593
+ _gate_blocking_causes(pbv, coverage_blockers, accepted_item_ids)
2594
+ ),
2595
+ "coverageBlockers": coverage_blockers,
2596
+ "setAside": _set_aside_register(pbv, accepted_item_ids),
2597
+ "blockingItems": [
2598
+ item["id"]
2599
+ for item in items
2600
+ if item["classification"] == "majority-disagree"
2601
+ ],
2602
+ "items": items,
2603
+ }
2604
+
2605
+
2606
+ _COVERED_BY_ANCHOR_RE = re.compile(r"option|stage|step", re.IGNORECASE)
2607
+
2608
+
2609
+ _COVERED_BY_STAGE_REF_RE = re.compile(r"stage\s*(\d+)", re.IGNORECASE)
2610
+
2611
+
2612
+ _COVERED_BY_VAGUE = {"recommended option", "the recommended option", "recommended"}
2613
+
2614
+
2615
+ _DEVIATION_DECISION_REF_RE = re.compile(r"^(C-\d{3,}|D-\d{4,})$")
2616
+
2617
+
2618
+ _DEVIATION_BLOCKED_DISPOSITION_RE = re.compile(r"^blocked (C-\d{3,})$")
2619
+
2620
+
2621
+ def _deviation_target(
2622
+ ref: str,
2623
+ clarifications: dict,
2624
+ decisions: dict,
2625
+ carried: dict,
2626
+ ) -> dict | None:
2627
+ if ref.startswith("C-"):
2628
+ row = clarifications.get(ref) or carried.get(ref)
2629
+ return row if isinstance(row, dict) else None
2630
+ row = decisions.get(ref)
2631
+ return row if isinstance(row, dict) else None
2632
+
2633
+
2634
+ def _deviation_is_user_confirmed(
2635
+ ref: str,
2636
+ clarifications: dict,
2637
+ carried: dict,
2638
+ ) -> bool:
2639
+ row = clarifications.get(ref)
2640
+ if (
2641
+ isinstance(row, dict)
2642
+ and row.get("status") in {"answered", "resolved"}
2643
+ and str(row.get("userInput") or "").strip()
2644
+ ):
2645
+ return True
2646
+ carried_row = carried.get(ref)
2647
+ if not isinstance(carried_row, dict):
2648
+ return False
2649
+ resolution = carried_row.get("resolutionInput")
2650
+ if isinstance(resolution, dict) and str(resolution.get("userText") or "").strip():
2651
+ return True
2652
+ return bool(str(carried_row.get("userConfirmation") or "").strip())
2653
+
2654
+
2655
+ def _resolved_deviation_refs(
2656
+ row_id: str,
2657
+ refs: object,
2658
+ clarifications: dict,
2659
+ decisions: dict,
2660
+ failures: list[str],
2661
+ carried: dict | None = None,
2662
+ ledger_ids: set[str] | None = None,
2663
+ ) -> list[str]:
2664
+ valid_refs: list[str] = []
2665
+ carried_rows = carried or {}
2666
+ known_ledger = ledger_ids or set()
2667
+ for ref in refs if isinstance(refs, list) else []:
2668
+ if not isinstance(ref, str) or not _DEVIATION_DECISION_REF_RE.fullmatch(ref):
2669
+ failures.append(
2670
+ f"final-report data.json: requirementCoverage `{row_id}` has "
2671
+ f"unsupported decisionRef `{ref}`; expected C-NNN or D-NNNN."
2672
+ )
2673
+ continue
2674
+ if (
2675
+ _deviation_target(ref, clarifications, decisions, carried_rows) is None
2676
+ and ref not in known_ledger
2677
+ ):
2678
+ failures.append(
2679
+ f"final-report data.json: requirementCoverage `{row_id}` "
2680
+ f"decisionRef `{ref}` does not exist in this report."
2681
+ )
2682
+ continue
2683
+ valid_refs.append(ref)
2684
+ return valid_refs
2685
+
2686
+
2687
+ def _validate_deviation_disposition(
2688
+ row_id: str,
2689
+ disposition: object,
2690
+ refs: list[str],
2691
+ clarifications: dict,
2692
+ failures: list[str],
2693
+ carried: dict | None = None,
2694
+ ledger_ids: set[str] | None = None,
2695
+ ) -> None:
2696
+ carried_rows = carried or {}
2697
+ known_ledger = ledger_ids or set()
2698
+ if disposition == "accepted":
2699
+ confirmed = any(
2700
+ ref.startswith("C-")
2701
+ and (
2702
+ ref in known_ledger
2703
+ or _deviation_is_user_confirmed(ref, clarifications, carried_rows)
2704
+ )
2705
+ for ref in refs
2706
+ )
2707
+ if not confirmed:
2708
+ failures.append(
2709
+ f"final-report data.json: requirementCoverage `{row_id}` is "
2710
+ "documented-deviation with approvalDisposition `accepted`, but "
2711
+ "none of its decisionRefs is a user-confirmed clarification "
2712
+ "(`status` answered/resolved with non-empty `userInput`)."
2713
+ )
2714
+ return
2715
+ blocked = (
2716
+ _DEVIATION_BLOCKED_DISPOSITION_RE.fullmatch(disposition)
2717
+ if isinstance(disposition, str)
2718
+ else None
2719
+ )
2720
+ if blocked is None:
2721
+ return
2722
+ clarification_id = blocked.group(1)
2723
+ clarification = clarifications.get(clarification_id) or carried_rows.get(
2724
+ clarification_id
2725
+ )
2726
+ if clarification is None:
2727
+ failures.append(
2728
+ f"final-report data.json: requirementCoverage `{row_id}` "
2729
+ f"approvalDisposition references `{clarification_id}`, which does "
2730
+ "not exist in this report."
2731
+ )
2732
+ elif (
2733
+ clarification.get("status") != "open"
2734
+ or clarification.get("blocks") != "approval"
2735
+ ):
2736
+ failures.append(
2737
+ f"final-report data.json: requirementCoverage `{row_id}` "
2738
+ f"approvalDisposition `{disposition}` must point to an open "
2739
+ "clarification with `blocks: approval`."
2740
+ )
2741
+
2742
+
2743
+ def _validate_requirement_deviations(
2744
+ data: dict,
2745
+ failures: list[str],
2746
+ *,
2747
+ carried: dict | None = None,
2748
+ ) -> None:
2749
+ """Require documented deviations to reference real decisions and approval."""
2750
+ planning = data.get("implementationPlanning")
2751
+ if not isinstance(planning, dict):
2752
+ return
2753
+ clarifications = {
2754
+ row.get("id"): row
2755
+ for row in (data.get("clarificationItems") or [])
2756
+ if isinstance(row, dict) and row.get("id")
2757
+ }
2758
+ decisions = {
2759
+ f"D-{row.get('number')}": row
2760
+ for row in (planning.get("decisionDrafts") or [])
2761
+ if isinstance(row, dict) and row.get("number")
2762
+ }
2763
+ carried_rows = carried or {}
2764
+ ledger_ids = {
2765
+ str(entry.get("clarificationId") or "").strip()
2766
+ for entry in (planning.get("supersessionLedger") or [])
2767
+ if isinstance(entry, dict) and str(entry.get("clarificationId") or "").strip()
2768
+ }
2769
+ for row in planning.get("requirementCoverage") or []:
2770
+ if not isinstance(row, dict) or row.get("status") != "documented-deviation":
2771
+ continue
2772
+ row_id = row.get("id") or "<row>"
2773
+ refs = _resolved_deviation_refs(
2774
+ row_id,
2775
+ row.get("decisionRefs"),
2776
+ clarifications,
2777
+ decisions,
2778
+ failures,
2779
+ carried_rows,
2780
+ ledger_ids,
2781
+ )
2782
+ _validate_deviation_disposition(
2783
+ row_id,
2784
+ row.get("approvalDisposition"),
2785
+ refs,
2786
+ clarifications,
2787
+ failures,
2788
+ carried_rows,
2789
+ ledger_ids,
2790
+ )
2791
+
2792
+
2793
+ def _validate_requirement_coverage_covered_by(data: dict, failures: list[str]) -> None:
2794
+ """H3 (partial) — a `covered` requirement row's `coveredBy` must name a
2795
+ concrete plan element that actually exists, not the spec-forbidden bare
2796
+ "recommended option" nor a phantom stage. Whether the cited step truly
2797
+ *satisfies* the requirement stays a worker DISAGREE(f) judgment; this closes
2798
+ the coarser hole where `coveredBy` points at nothing real.
2799
+ """
2800
+ ip = data.get("implementationPlanning")
2801
+ if not isinstance(ip, dict):
2802
+ return
2803
+ rows = [r for r in (ip.get("requirementCoverage") or []) if isinstance(r, dict)]
2804
+ if not rows:
2805
+ return
2806
+ stage_numbers = {
2807
+ s.get("stage") for s in (ip.get("stages") or []) if isinstance(s, dict)
2808
+ }
2809
+ for row in rows:
2810
+ if row.get("status") != "covered":
2811
+ continue
2812
+ rid = row.get("id") or "<row>"
2813
+ covered = str(row.get("coveredBy") or "").strip()
2814
+ if covered.lower() in _COVERED_BY_VAGUE:
2815
+ failures.append(
2816
+ f"final-report data.json: requirementCoverage `{rid}` is `covered` "
2817
+ f"but coveredBy is just `{covered}`. Name the specific Option "
2818
+ "Candidate and Stage/Step that satisfies it, not 'recommended "
2819
+ "option' (profile Requirement Coverage)."
2820
+ )
2821
+ continue
2822
+ if not _COVERED_BY_ANCHOR_RE.search(covered):
2823
+ failures.append(
2824
+ f"final-report data.json: requirementCoverage `{rid}` coveredBy "
2825
+ f"`{covered}` names no Option / Stage / Step. A `covered` row must "
2826
+ "cite the concrete plan element that satisfies the requirement."
2827
+ )
2828
+ continue
2829
+ phantom = [
2830
+ n
2831
+ for m in _COVERED_BY_STAGE_REF_RE.finditer(covered)
2832
+ if stage_numbers and (n := int(m.group(1))) not in stage_numbers
2833
+ ]
2834
+ if phantom:
2835
+ failures.append(
2836
+ f"final-report data.json: requirementCoverage `{rid}` coveredBy "
2837
+ f"cites Stage {phantom[0]} which does not exist in the Stage Map. "
2838
+ "A coverage row must point at a real stage."
2839
+ )
2840
+
2841
+
2842
+ def _planning_conformance_declarations(
2843
+ stages: object,
2844
+ failures: list[str],
2845
+ ) -> list[dict]:
2846
+ declarations: list[dict] = []
2847
+ for stage in stages if isinstance(stages, list) else []:
2848
+ if (
2849
+ not isinstance(stage, dict)
2850
+ or not str(stage.get("conformanceTests") or "").strip()
2851
+ ):
2852
+ continue
2853
+ parsed = _parse_conformance_tests(stage.get("conformanceTests"))
2854
+ if parsed is None:
2855
+ failures.append(
2856
+ "final-report data.json: stage "
2857
+ f"{stage.get('stage')} has malformed conformanceTests "
2858
+ f"declaration: got {str(stage.get('conformanceTests'))!r}; "
2859
+ "expected `<task_root>/qa/scripts/stage-<N>.<ext> "
2860
+ "(requires=[db|io|http|external,...])`."
2861
+ )
2862
+ continue
2863
+ script, requires = parsed
2864
+ declarations.append(
2865
+ {
2866
+ "stageKey": f"approved-plan-stage-{stage.get('stage')}",
2867
+ "script": script,
2868
+ "requires": sorted(requires),
2869
+ }
2870
+ )
2871
+ return declarations
2872
+
2873
+
2874
+ def _implemented_stages(data_path: Path) -> frozenset[int]:
2875
+ """이 태스크에서 구현이 끝난 stage. 원장을 못 읽으면 빈 집합이다."""
2876
+ from okstra_ctl.consumers import read_stage_consumer_state
2877
+
2878
+ try:
2879
+ state = read_stage_consumer_state(data_path.parent.parent)
2880
+ except (OSError, UnicodeError, ValueError):
2881
+ return frozenset()
2882
+ return frozenset(state.done_stages)
2883
+
2884
+
2885
+ def _is_implemented_stage(stage: object, implemented: frozenset[int]) -> bool:
2886
+ number = stage.get("stage") if isinstance(stage, dict) else None
2887
+ return (
2888
+ isinstance(number, int)
2889
+ and not isinstance(number, bool)
2890
+ and (number in implemented)
2891
+ )
2892
+
2893
+
2894
+ def _validate_planning_conformance_declared(
2895
+ report_path: Path,
2896
+ failures: list[str],
2897
+ surface_patterns: object = None,
2898
+ ) -> None:
2899
+ """계획 단계는 `Conformance tests:` / `Conformance exemption:` 선언 형식을 본다.
2900
+
2901
+ 스크립트 파일과 `runCommand` 는 매칭 implementation stage 가 만든다.
2902
+ 선언만 있고 파일이 없는 것은 계획 게이트 실패가 아니다. 형식이 깨진
2903
+ `conformanceTests` 는 여전히 실패한다.
2904
+
2905
+ 면제 stage 의 `plannedPaths` 가 db/io/http/external 표면을 건드리면 여기서
2906
+ 막는다 — 구현 게이트의 diff-surface 대조(`_validate_conformance_surfaces`)와
2907
+ 같은 패턴이다. 종전에는 그 대조가 구현이 끝난 뒤에만 돌아, 승인된 계획을
2908
+ 고칠 수 없는 자리에서 run 전체가 막혔다(2026-09-09 dev-10784 Stage 2).
2909
+ """
2910
+ data_path = _data_path_for(report_path)
2911
+ if not data_path.is_file():
2912
+ return
2913
+ try:
2914
+ data = load_owned_object(data_path, artifact="planning report")
2915
+ except JsonBoundaryError:
2916
+ return
2917
+ ip = data.get("implementationPlanning")
2918
+ if not isinstance(ip, dict):
2919
+ return
2920
+ # 구현이 끝난 stage 는 이 게이트의 대상이 아니다. 그 본문은 다음 계획 run 에
2921
+ # 그대로 이월되고(ADR-0015), 이월된 본문은 고칠 수 없다 — 규칙이 그 사이에
2922
+ # 넓어졌다면 통과 가능한 값이 없는 요구가 된다(2026-09-24, dev-10860: 이월된
2923
+ # stage 2·3·5 가 오늘의 표면 패턴으로 `requires` 누락 판정).
2924
+ implemented = _implemented_stages(data_path)
2925
+ stages = [
2926
+ stage
2927
+ for stage in ip.get("stages") or ()
2928
+ if not _is_implemented_stage(stage, implemented)
2929
+ ]
2930
+ _planning_conformance_declarations(stages, failures)
2931
+ if ip.get("planningContract") != "selected-direction":
2932
+ from okstra_ctl.implementation_direction import (
2933
+ stage_validation_executability_errors,
2934
+ )
2935
+
2936
+ failures.extend(stage_validation_executability_errors(ip))
2937
+ for conflict in exempt_stage_surface_conflicts(data, surface_patterns):
2938
+ if conflict["stage"] in implemented:
2939
+ continue
2940
+ failures.append(
2941
+ "conformance gate BLOCKING: stage "
2942
+ f"{conflict['stage']} declares `Conformance exemption:` but its "
2943
+ f"planned paths touch surface(s) {conflict['surfaces']}: "
2944
+ f"{', '.join(conflict['paths'])} — an exemption cannot hide a "
2945
+ "db/io/http/external change (prompts/profiles/implementation-planning.md "
2946
+ '"Per-stage conformance declaration"); declare `Conformance tests:` '
2947
+ f"with requires={conflict['surfaces']} for that stage, or move those "
2948
+ "paths out of it. The implementation run's diff-surface check "
2949
+ "blocks the same stage after the work is done, where the approved "
2950
+ "plan can no longer be corrected."
2951
+ )
2952
+ for gap in declared_stage_surface_gaps(data, surface_patterns):
2953
+ if gap["stage"] in implemented:
2954
+ continue
2955
+ failures.append(
2956
+ "conformance gate BLOCKING: stage "
2957
+ f"{gap['stage']} declares `Conformance tests:` with "
2958
+ f"requires={gap['requires']} but its planned paths touch surface(s) "
2959
+ f"{gap['surfaces']}: {', '.join(gap['paths'])} — add "
2960
+ f"{gap['surfaces']} to that stage's `requires`, or move those paths "
2961
+ "out of it. The implementation run's diff-surface check demands "
2962
+ "the wider set after the work is done, where the approved plan can "
2963
+ "no longer be corrected."
2964
+ )
2965
+
2966
+
2967
+ def _is_activity_contract_v1_planning(run_manifest: dict) -> bool:
2968
+ return (
2969
+ run_manifest.get("activityContractVersion") == 1
2970
+ and run_manifest.get("taskType") == "implementation-planning"
2971
+ )
2972
+
2973
+
2974
+ def _validate_approval_context(
2975
+ data: dict,
2976
+ run_manifest: dict,
2977
+ failures: list[str],
2978
+ ) -> None:
2979
+ """활동 계약 v1 계획 run 의 승인 검사를 v3 경로로 넘긴다.
2980
+
2981
+ v2 전용 본문은 삭제했다. 같은 요구를 조립 시점의 `report_assembly.py` /
2982
+ `approval_decisions.py` 가 이미 거부하고, 새 run 은 전부 schemaVersion 3.0
2983
+ 이라 v2 갈래에 도달하는 값이 없었다.
2984
+ """
2985
+ if not _is_activity_contract_v1_planning(run_manifest):
2986
+ return
2987
+ if data.get("schemaVersion") == "3.0":
2988
+ _validate_v3_approval_context(data, failures)
2989
+
2990
+
2991
+ def _validate_v3_approval_context(data: dict, failures: list[str]) -> None:
2992
+ """승인 플래그가 아직 진행을 막는 행과 공존하지 못하게 한다.
2993
+
2994
+ backlinks / dispositions / resolution-link 대조 세 갈래는 뺐다. 셋 다
2995
+ 조립(`report_assembly.py`, `approval_decisions.py`)이 같은 입력으로 만든
2996
+ 값을 같은 식으로 되계산하는 항등식이라 조립을 우회하지 않는 한 걸릴 값이
2997
+ 없다.
2998
+ """
2999
+ approved = (data.get("frontmatter") or {}).get("approved") is True
3000
+ incorporated = incorporated_clarification_ids(data)
3001
+ for row in data.get("clarificationItems") or []:
3002
+ if not isinstance(row, dict) or row.get("blocks") != "approval":
3003
+ continue
3004
+ # `approvalContext` 없는 행을 건너뛰는 것은 이전과 같은 범위다. 여기서
3005
+ # 범위를 넓히면 삭제한 세 갈래가 막던 행이 새 차단으로 되살아난다.
3006
+ if not isinstance(row.get("approvalContext"), dict):
3007
+ continue
3008
+ row_id = str(row.get("id") or "")
3009
+ if approved and row_blocks_progress(
3010
+ str(row.get("status") or ""),
3011
+ clarification_disposition(row),
3012
+ incorporated=row_id in incorporated,
3013
+ ):
3014
+ failures.append(
3015
+ f"final-report data.json: approval is true while clarification "
3016
+ f"`{row.get('id')}` remains `{row.get('status')}`."
3017
+ )
3018
+
3019
+
3020
+ _CHECKLIST_REF_RE = re.compile(r"VC-\d+")
3021
+
3022
+
3023
+ def _detect_missing_dependency_precondition(
3024
+ data: dict, project_root: Path
3025
+ ) -> list[str]:
3026
+ """A stage that runs the toolchain must point at a declared precondition.
3027
+
3028
+ The planning worktree installs no dependencies, so `yarn … test` cannot run
3029
+ there. A plan that says nothing about it produces steps whose commands die
3030
+ on `exit 127`, and the verification round then spends itself on a defect the
3031
+ planner cannot fix by editing the plan.
3032
+
3033
+ The shape checked is the one an observed self-fix loop arrived at after two
3034
+ rounds: one `phase: pre` checklist item declaring the install, referenced by
3035
+ every stage that needs it. Only the reference is machine-checked — whether
3036
+ the cited item genuinely covers dependencies is a semantic judgement left to
3037
+ the §5.5.9 round, the same boundary the scope-provenance gate draws.
3038
+ """
3039
+ planning = data.get("implementationPlanning")
3040
+ if not isinstance(planning, dict):
3041
+ return []
3042
+ tokens = resolve_build_tool_tokens(project_root)
3043
+ if not tokens:
3044
+ return []
3045
+
3046
+ checklist = {
3047
+ str(row.get("id")): str(row.get("phase") or "")
3048
+ for row in (planning.get("validationChecklist") or [])
3049
+ if isinstance(row, dict) and row.get("id")
3050
+ }
3051
+
3052
+ warnings: list[str] = []
3053
+ for stage in planning.get("stages") or []:
3054
+ if not isinstance(stage, dict):
3055
+ continue
3056
+ commands = [
3057
+ str(step.get("command") or "")
3058
+ for step in (stage.get("stepwiseExecution") or [])
3059
+ if isinstance(step, dict)
3060
+ ]
3061
+ if not any(command_invokes_build_tool(c, tokens=tokens) for c in commands):
3062
+ continue
3063
+ refs = _CHECKLIST_REF_RE.findall(str(stage.get("stageValidation") or ""))
3064
+ if not refs:
3065
+ warnings.append(
3066
+ f"Stage {stage.get('stage')} runs the project toolchain but its "
3067
+ "Stage Validation cites no `VC-NNN` precondition. The planning "
3068
+ "worktree has no dependencies installed, so declare the install "
3069
+ "once as a `phase: pre` Validation Checklist item and reference "
3070
+ "it here."
3071
+ )
3072
+ continue
3073
+ if not any(checklist.get(ref) == "pre" for ref in refs):
3074
+ cited = ", ".join(sorted(set(refs)))
3075
+ warnings.append(
3076
+ f"Stage {stage.get('stage')} runs the project toolchain and cites "
3077
+ f"{cited}, but none of those is a `phase: pre` Validation "
3078
+ "Checklist item — a precondition verified after the fact is not a "
3079
+ "precondition."
3080
+ )
3081
+ return warnings
3082
+
3083
+
3084
+ _UNMAPPED_FALLBACK_REASON = "no impacted stages resolved"
3085
+
3086
+
3087
+ def _prior_planning_data(report_path: Path) -> dict | None:
3088
+ """The newest implementation-planning data.json preceding *report_path*."""
3089
+ match = re.search(r"-(\d+)\.md$", report_path.name)
3090
+ if not match:
3091
+ return None
3092
+ current_seq = int(match.group(1))
3093
+ candidates = sorted(
3094
+ (
3095
+ path
3096
+ for path in report_path.parent.glob(
3097
+ "final-report-implementation-planning-*.data.json"
3098
+ )
3099
+ if (m := re.search(r"-(\d+)\.data\.json$", path.name))
3100
+ and int(m.group(1)) < current_seq
3101
+ ),
3102
+ key=lambda p: p.name,
3103
+ )
3104
+ for path in reversed(candidates):
3105
+ try:
3106
+ payload = load_owned_object(path, artifact="prior planning report")
3107
+ except JsonBoundaryError:
3108
+ continue
3109
+ return payload
3110
+ return None
3111
+
3112
+
3113
+ def _detect_unmapped_incremental_fallback(data: dict, report_path: Path) -> list[str]:
3114
+ """A re-run that fell back to full while the prior report could have mapped it.
3115
+
3116
+ Both "the answer restructures the plan" and "no stage could be resolved"
3117
+ return `mode: full`, and only the second is a missed narrowing. The lead
3118
+ declares the first through `--full-reason`, so the reason prefix separates
3119
+ them; this reports the second only when the trace would have succeeded.
3120
+
3121
+ Advisory: a lead that never passed `--full-reason` produces the fallback
3122
+ reason for both cases, so failing here would punish runs written before the
3123
+ flag existed.
3124
+ """
3125
+ planning = data.get("implementationPlanning")
3126
+ if not isinstance(planning, dict):
3127
+ return []
3128
+ decision = planning.get("incrementalDecision")
3129
+ if not isinstance(decision, dict) or decision.get("mode") != "full":
3130
+ return []
3131
+ if _UNMAPPED_FALLBACK_REASON not in str(decision.get("reason") or ""):
3132
+ return []
3133
+
3134
+ answered = _answered_clarification_ids(data)
3135
+ if not answered:
3136
+ return []
3137
+ prior = _prior_planning_data(report_path)
3138
+ if prior is None:
3139
+ return []
3140
+
3141
+ try:
3142
+ from okstra_ctl.incremental_scope import clarification_impacted_stages
3143
+
3144
+ stages = clarification_impacted_stages(prior, set(answered))
3145
+ except (ImportError, ValueError):
3146
+ return []
3147
+ if not stages:
3148
+ return []
3149
+ return [
3150
+ "incrementalDecision fell back to full for lack of a resolved stage, but "
3151
+ f"the prior report maps {', '.join(sorted(answered))} to stage(s) "
3152
+ f"{', '.join(str(s) for s in sorted(stages))}. Pass the answered ids "
3153
+ "through `--answered-clarifications` so the re-run narrows, or declare "
3154
+ "the structural change with `--full-reason` when full is the judgement."
3155
+ ]
3156
+
3157
+
3158
+ _PLAN_BODY_STATE_KEYS = ("schemaVersion", "planItems", "roundHistory")
3159
+
3160
+
3161
+ def _validate_plan_body_state_file(
3162
+ data: dict,
3163
+ report_path: Path,
3164
+ failures: list[str],
3165
+ state_path: Path | None = None,
3166
+ ) -> None:
3167
+ """The per-round state file must exist once a round has run.
3168
+
3169
+ Nothing read this file, so its documented schema was dead contract — yet
3170
+ it is the only record of *superseded* rounds. `planItems[].verdicts` in
3171
+ data.json is overwritten by each self-fix re-verification, so after the
3172
+ loop the report shows the final votes and no trace of what the earlier
3173
+ rounds found. Both defect investigations of this phase depended on the
3174
+ sidecar to recover that history.
3175
+
3176
+ Deliberately does NOT cross-check any gate against data.json: the two are
3177
+ different views by design (per-round history vs. final state), and
3178
+ demanding equality would fail every run whose self-fix loop worked.
3179
+ """
3180
+ ip = data.get("implementationPlanning")
3181
+ if not isinstance(ip, dict):
3182
+ return
3183
+ pbv = ip.get("planBodyVerification")
3184
+ if not isinstance(pbv, dict):
3185
+ return
3186
+ round_count = pbv.get("roundCount")
3187
+ if not isinstance(round_count, int) or round_count < 1:
3188
+ return
3189
+ state_dir = report_path.parent.parent / "state"
3190
+ if state_path is not None:
3191
+ # 호출자가 경로를 넘겼으면 그걸 본다. 리드는 launch 프롬프트의
3192
+ # `Run Paths` 에서 정본 경로를 받으므로, 여기서 이름을 다시 만들면
3193
+ # 그 정본과 어긋날 수 있다 — 실제로 그랬다.
3194
+ written = [state_path] if state_path.is_file() else []
3195
+ else:
3196
+ # 이름을 유도할 근거가 없다. run 은 seq 계열을 둘 갖고(`state` /
3197
+ # `reports`) 리포트 정본은 자기 run 의 state seq 를 담지 않으므로,
3198
+ # 리포트 seq 로 만든 이름은 추측이다. 이 검사가 묻는 것은 "덮어써진
3199
+ # 라운드의 기록이 남았는가" 이지 파일 이름이 아니므로, 이 run 의 상태
3200
+ # 디렉터리에 사이드카가 있는지만 본다. 이름의 정본은 `paths.py` 다.
3201
+ written = sorted(
3202
+ state_dir.glob("plan-body-verification-implementation-planning-*.json")
3203
+ )
3204
+ if not written:
3205
+ failures.append(
3206
+ f"plan-body verification ran ({round_count} round(s)) but no "
3207
+ f"`state/plan-body-verification-*.json` was written. It is the only "
3208
+ "record of superseded rounds — data.json keeps just the final "
3209
+ "verdicts, so without it a self-fixed run leaves no trace of what "
3210
+ 'the earlier rounds found (plan-body-verification.md §"schema"). '
3211
+ "The path is rendered into the launch prompt's `Run Paths` block; "
3212
+ "write it there rather than deriving a name."
3213
+ )
3214
+ return
3215
+ # 여럿이면 가장 최신(seq 가 큰) 것이 이 run 의 것이다.
3216
+ expected = written[-1]
3217
+ try:
3218
+ state = load_owned_object(expected, artifact="plan-body verification state")
3219
+ except JsonBoundaryError as exc:
3220
+ failures.append(f"plan-body verification state file is unreadable: {exc}")
3221
+ return
3222
+ missing = [key for key in _PLAN_BODY_STATE_KEYS if key not in state]
3223
+ for key in missing:
3224
+ failures.append(
3225
+ f"plan-body verification state file `{expected.name}` is "
3226
+ f"missing required key `{key}`."
3227
+ )
3228
+ if not missing:
3229
+ _validate_plan_body_state_rounds(
3230
+ state, pbv, expected.name, round_count, failures
3231
+ )
3232
+
3233
+
3234
+ def _validate_plan_body_state_rounds(
3235
+ state: dict,
3236
+ pbv: dict,
3237
+ name: str,
3238
+ round_count: int,
3239
+ failures: list[str],
3240
+ ) -> None:
3241
+ """Every round that ran must survive in the sidecar, its votes included.
3242
+
3243
+ The round protocol used to write this file once, before the self-fix loop,
3244
+ and never asked for it again — so a run with three re-verifications kept
3245
+ round 1 only, and the superseded rounds this file exists to preserve were
3246
+ exactly the ones it dropped (jobs dev-10269 seq 001: `roundCount` 4 in
3247
+ data.json against `round` 1 here).
3248
+ """
3249
+ history = [e for e in (state.get("roundHistory") or []) if isinstance(e, dict)]
3250
+ # A non-int `round` names no round, so it cannot cover one — and reading it
3251
+ # into a set would abort the whole validation on unhashable lead-authored JSON.
3252
+ recorded = {e["round"] for e in history if isinstance(e.get("round"), int)}
3253
+ if recorded != set(range(1, round_count + 1)):
3254
+ seen = sorted(recorded)
3255
+ failures.append(
3256
+ f"plan-body verification state file `{name}` records `roundHistory[]` "
3257
+ f"rounds {seen} but the report declares `roundCount`={round_count}. "
3258
+ f"One entry per round 1..{round_count} is required: data.json keeps "
3259
+ "only the final verdicts, so a sidecar frozen at an earlier round "
3260
+ "loses every round it superseded (plan-body-verification.md "
3261
+ '§"Round protocol" step 7 "Round completion").'
3262
+ )
3263
+ gateless = [str(e.get("round")) for e in history if not e.get("gateResult")]
3264
+ if gateless:
3265
+ failures.append(
3266
+ f"plan-body verification state file `{name}`: `roundHistory[]` "
3267
+ f"round(s) {', '.join(gateless)} carry no `gateResult`. The per-round "
3268
+ "gate is what tells the reader which round blocked and on what, and "
3269
+ "the sidecar is the only place it survives."
3270
+ )
3271
+ declared = pbv.get("selfFixRoundsApplied")
3272
+ if isinstance(declared, int) and state.get("selfFixRoundsApplied") != declared:
3273
+ failures.append(
3274
+ f"plan-body verification state file `{name}` records "
3275
+ f"`selfFixRoundsApplied`={state.get('selfFixRoundsApplied')!r} but the "
3276
+ f"report declares {declared}. The sidecar is rewritten at each round's "
3277
+ "end, so a stale count means the later rounds were never written to it."
3278
+ )
3279
+ voted = {
3280
+ vote["round"]
3281
+ for item in (state.get("planItems") or [])
3282
+ if isinstance(item, dict)
3283
+ for vote in (item.get("rounds") or [])
3284
+ if isinstance(vote, dict) and isinstance(vote.get("round"), int)
3285
+ }
3286
+ uncited = [
3287
+ n for n in sorted(recorded & set(range(1, round_count + 1))) if n not in voted
3288
+ ]
3289
+ if uncited:
3290
+ failures.append(
3291
+ f"plan-body verification state file `{name}`: round(s) {uncited} "
3292
+ "appear in `roundHistory[]` but no `planItems[].rounds[]` entry "
3293
+ "records a vote cast in them. A re-verification round whose verdicts "
3294
+ "were never written down is precisely the history this file holds."
3295
+ )