okstra 0.206.0 → 0.207.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (259) hide show
  1. package/README.md +3 -3
  2. package/dist/cli-registry.mjs +7 -1
  3. package/dist/cli-registry.mjs.map +1 -1
  4. package/dist/commands/lifecycle/install.mjs +1 -1
  5. package/dist/commands/lifecycle/install.mjs.map +1 -1
  6. package/docs/architecture/storage-model.md +1 -0
  7. package/docs/architecture.md +40 -16
  8. package/docs/cli.md +17 -15
  9. package/docs/contributor-change-matrix.md +3 -2
  10. package/docs/performance-improvement-plan-v2.md +1 -1
  11. package/docs/project-structure-overview.md +43 -20
  12. package/package.json +1 -1
  13. package/runtime/BUILD.json +2 -2
  14. package/runtime/agents/operations/code-review.json +1 -1
  15. package/runtime/bin/lib/okstra/usage.sh +3 -3
  16. package/runtime/bin/okstra-compact-reminder.sh +1 -1
  17. package/runtime/bin/okstra-spawn-followups.py +2 -2
  18. package/runtime/prompts/duties/direction-selection-worker.json +1 -1
  19. package/runtime/prompts/launch.template.md +2 -2
  20. package/runtime/prompts/lead/adapters/cmux.md +4 -3
  21. package/runtime/prompts/lead/context-loader.md +1 -1
  22. package/runtime/prompts/lead/convergence.md +44 -12
  23. package/runtime/prompts/lead/okstra-lead-contract.md +44 -73
  24. package/runtime/prompts/lead/phase-routing.md +64 -0
  25. package/runtime/prompts/lead/report-writer.md +10 -8
  26. package/runtime/prompts/lead/team-contract.md +1 -1
  27. package/runtime/prompts/profiles/_clarification-recommendation.md +4 -4
  28. package/runtime/prompts/profiles/_coding-conventions-preflight.md +1 -1
  29. package/runtime/prompts/profiles/_common-contract.md +2 -2
  30. package/runtime/prompts/profiles/_coverage-critic.md +1 -1
  31. package/runtime/prompts/profiles/forbidden-actions.json +0 -94
  32. package/runtime/prompts/wizard/prompts.ko.json +2 -1
  33. package/runtime/python/okstra_ctl/adapters/hosts/antigravity/relay.md +1 -1
  34. package/runtime/python/okstra_ctl/adapters/hosts/claude-code/relay.md +5 -5
  35. package/runtime/python/okstra_ctl/adapters/hosts/codex/relay.md +1 -1
  36. package/runtime/python/okstra_ctl/adapters/hosts/external/relay.md +3 -2
  37. package/runtime/python/okstra_ctl/adapters/hosts/grok/relay.md +1 -1
  38. package/runtime/python/okstra_ctl/adapters/hosts/kimi/relay.md +1 -1
  39. package/runtime/python/okstra_ctl/adapters/providers/codex/adapter.py +17 -26
  40. package/runtime/python/okstra_ctl/agent/prompt_cli/batch.py +183 -0
  41. package/runtime/python/okstra_ctl/agent/prompt_cli/cli.py +60 -10
  42. package/runtime/python/okstra_ctl/agent/prompt_cli/corrections.py +1 -1
  43. package/runtime/python/okstra_ctl/agent/prompt_cli/jobs.py +21 -4
  44. package/runtime/python/okstra_ctl/agent/prompt_cli/materialize.py +10 -1
  45. package/runtime/python/okstra_ctl/analysis_inputs.py +0 -39
  46. package/runtime/python/okstra_ctl/analysis_scope.py +31 -0
  47. package/runtime/python/okstra_ctl/approval_decisions.py +32 -2
  48. package/runtime/python/okstra_ctl/asset_roots.py +19 -0
  49. package/runtime/python/okstra_ctl/assignment_resolver.py +8 -0
  50. package/runtime/python/okstra_ctl/blocking_checks.py +7 -0
  51. package/runtime/python/okstra_ctl/code_review_target.py +92 -6
  52. package/runtime/python/okstra_ctl/consumers.py +12 -0
  53. package/runtime/python/okstra_ctl/dispatch_checkpoints.py +121 -0
  54. package/runtime/python/okstra_ctl/dispatch_core.py +54 -32
  55. package/runtime/python/okstra_ctl/dispatch_state.py +34 -5
  56. package/runtime/python/okstra_ctl/doctor.py +2 -1
  57. package/runtime/python/okstra_ctl/domain/provider.py +5 -0
  58. package/runtime/python/okstra_ctl/domain/worker_presentation.py +21 -2
  59. package/runtime/python/okstra_ctl/domain/write_policy.py +2 -1
  60. package/runtime/python/okstra_ctl/execution_mutation_audit.py +46 -9
  61. package/runtime/python/okstra_ctl/handoff.py +11 -466
  62. package/runtime/python/okstra_ctl/handoff_error.py +5 -0
  63. package/runtime/python/okstra_ctl/implementation_direction.py +0 -477
  64. package/runtime/python/okstra_ctl/initial_prompt_materialization.py +13 -1
  65. package/runtime/python/okstra_ctl/lead_progress.py +33 -1
  66. package/runtime/python/okstra_ctl/manager_view.py +26 -19
  67. package/runtime/python/okstra_ctl/model_io/lines.py +21 -4
  68. package/runtime/python/okstra_ctl/models.py +4 -1
  69. package/runtime/python/okstra_ctl/operation_invocation.py +11 -2
  70. package/runtime/python/okstra_ctl/option_comparison.py +3 -165
  71. package/runtime/python/okstra_ctl/option_votes.py +3 -191
  72. package/runtime/python/okstra_ctl/paths.py +8 -6
  73. package/runtime/python/okstra_ctl/phases/catalog.py +56 -12
  74. package/runtime/python/okstra_ctl/phases/change_impact_analysis/boundary.json +11 -0
  75. package/runtime/python/okstra_ctl/phases/change_impact_analysis/entry.py +39 -0
  76. package/runtime/python/okstra_ctl/{report_html/view_models/change_impact_analysis.py → phases/change_impact_analysis/report.py} +3 -3
  77. package/runtime/python/okstra_ctl/phases/change_impact_analysis/spec.md +26 -0
  78. package/runtime/python/okstra_ctl/phases/change_impact_analysis/validation.py +23 -0
  79. package/runtime/python/okstra_ctl/phases/error_analysis/__init__.py +1 -0
  80. package/runtime/python/okstra_ctl/phases/error_analysis/boundary.json +9 -0
  81. package/runtime/{prompts/profiles/error-analysis.md → python/okstra_ctl/phases/error_analysis/profile.md} +2 -2
  82. package/runtime/python/okstra_ctl/{report_html/view_models/error_analysis.py → phases/error_analysis/report.py} +9 -8
  83. package/runtime/{templates/reports → python/okstra_ctl/phases/error_analysis/report_assets}/error-analysis-input.template.md +1 -1
  84. package/runtime/python/okstra_ctl/phases/error_analysis/spec.md +118 -0
  85. package/runtime/python/okstra_ctl/phases/error_analysis/validation.py +241 -0
  86. package/runtime/python/okstra_ctl/phases/feature_analysis/__init__.py +1 -0
  87. package/runtime/python/okstra_ctl/phases/feature_analysis/boundary.json +8 -0
  88. package/runtime/python/okstra_ctl/phases/feature_analysis/entry.py +63 -0
  89. package/runtime/python/okstra_ctl/{report_html/view_models/feature_analysis.py → phases/feature_analysis/report.py} +12 -5
  90. package/runtime/python/okstra_ctl/phases/feature_analysis/spec.md +22 -0
  91. package/runtime/python/okstra_ctl/phases/feature_analysis/validation.py +27 -0
  92. package/runtime/python/okstra_ctl/phases/feature_analysis/wizard.py +95 -0
  93. package/runtime/python/okstra_ctl/phases/final_verification/boundary.json +8 -0
  94. package/runtime/python/okstra_ctl/phases/final_verification/profile.md +2 -2
  95. package/runtime/{templates/reports → python/okstra_ctl/phases/final_verification/report_assets}/final-verification-input.template.md +1 -1
  96. package/runtime/python/okstra_ctl/phases/final_verification/spec.md +1 -1
  97. package/runtime/python/okstra_ctl/phases/implementation/__init__.py +1 -0
  98. package/runtime/python/okstra_ctl/phases/implementation/boundary.json +17 -0
  99. package/runtime/python/okstra_ctl/{implementation_stage.py → phases/implementation/entry.py} +22 -10
  100. package/runtime/{prompts/host-orchestration/implementation.md → python/okstra_ctl/phases/implementation/host-rules.md} +1 -1
  101. package/runtime/{prompts/profiles → python/okstra_ctl/phases/implementation/instructions}/_implementation-deliverable.md +1 -1
  102. package/runtime/{prompts/profiles → python/okstra_ctl/phases/implementation/instructions}/_implementation-executor.md +4 -3
  103. package/runtime/{prompts/profiles → python/okstra_ctl/phases/implementation/instructions}/_implementation-verifier.md +18 -7
  104. package/runtime/{prompts/profiles/implementation.md → python/okstra_ctl/phases/implementation/profile.md} +5 -5
  105. package/runtime/python/okstra_ctl/{report_html/view_models/implementation.py → phases/implementation/report.py} +3 -3
  106. package/runtime/{templates/reports → python/okstra_ctl/phases/implementation/report_assets}/implementation-input.template.md +1 -1
  107. package/runtime/python/okstra_ctl/phases/implementation/spec.md +238 -0
  108. package/runtime/python/okstra_ctl/phases/implementation/validation.py +205 -0
  109. package/runtime/python/okstra_ctl/phases/implementation/wizard.py +39 -0
  110. package/runtime/python/okstra_ctl/phases/implementation_option_selection/__init__.py +1 -0
  111. package/runtime/python/okstra_ctl/phases/implementation_option_selection/authoring.py +80 -0
  112. package/runtime/python/okstra_ctl/phases/implementation_option_selection/boundary.json +10 -0
  113. package/runtime/python/okstra_ctl/phases/implementation_option_selection/comparison.py +168 -0
  114. package/runtime/python/okstra_ctl/phases/implementation_option_selection/entry.py +27 -0
  115. package/runtime/{prompts/profiles/implementation-option-selection.md → python/okstra_ctl/phases/implementation_option_selection/profile.md} +3 -3
  116. package/runtime/python/okstra_ctl/{report_html/view_models/implementation_option_selection.py → phases/implementation_option_selection/report.py} +2 -2
  117. package/runtime/python/okstra_ctl/phases/implementation_option_selection/spec.md +83 -0
  118. package/runtime/python/okstra_ctl/{implementation_options.py → phases/implementation_option_selection/validation.py} +3 -3
  119. package/runtime/python/okstra_ctl/phases/implementation_option_selection/votes.py +194 -0
  120. package/runtime/python/okstra_ctl/phases/implementation_planning/__init__.py +1 -0
  121. package/runtime/python/okstra_ctl/phases/implementation_planning/authoring.py +2345 -0
  122. package/runtime/python/okstra_ctl/phases/implementation_planning/boundary.json +12 -0
  123. package/runtime/python/okstra_ctl/phases/implementation_planning/entry.py +161 -0
  124. package/runtime/python/okstra_ctl/phases/implementation_planning/guidance.py +178 -0
  125. package/runtime/{prompts/lead → python/okstra_ctl/phases/implementation_planning/instructions}/plan-body-verification.md +61 -51
  126. package/runtime/python/okstra_ctl/phases/implementation_planning/plan_body.py +3295 -0
  127. package/runtime/{prompts/profiles/implementation-planning.md → python/okstra_ctl/phases/implementation_planning/profile.md} +74 -25
  128. package/runtime/python/okstra_ctl/phases/implementation_planning/report.py +237 -0
  129. package/runtime/{templates/reports → python/okstra_ctl/phases/implementation_planning/report_assets}/implementation-planning-input.template.md +2 -2
  130. package/runtime/python/okstra_ctl/phases/implementation_planning/spec.md +204 -0
  131. package/runtime/python/okstra_ctl/phases/implementation_planning/validation.py +597 -0
  132. package/runtime/python/okstra_ctl/phases/implementation_planning/wizard.py +166 -0
  133. package/runtime/python/okstra_ctl/phases/improvement_discovery/boundary.json +12 -0
  134. package/runtime/python/okstra_ctl/{improvement_lenses.py → phases/improvement_discovery/lenses.py} +1 -6
  135. package/runtime/{prompts/profiles/improvement-discovery.md → python/okstra_ctl/phases/improvement_discovery/profile.md} +5 -5
  136. package/runtime/python/okstra_ctl/{report_html/view_models/improvement_discovery.py → phases/improvement_discovery/report.py} +3 -3
  137. package/runtime/{templates/reports → python/okstra_ctl/phases/improvement_discovery/report_assets}/improvement-discovery-input.template.md +1 -2
  138. package/runtime/python/okstra_ctl/phases/improvement_discovery/spec.md +29 -0
  139. package/runtime/{validators/validate_improvement_report.py → python/okstra_ctl/phases/improvement_discovery/validation.py} +5 -14
  140. package/runtime/python/okstra_ctl/phases/project_analysis/__init__.py +1 -0
  141. package/runtime/python/okstra_ctl/phases/project_analysis/boundary.json +8 -0
  142. package/runtime/python/okstra_ctl/phases/project_analysis/entry.py +11 -0
  143. package/runtime/python/okstra_ctl/{report_html/view_models/project_analysis.py → phases/project_analysis/report.py} +3 -3
  144. package/runtime/python/okstra_ctl/phases/project_analysis/spec.md +33 -0
  145. package/runtime/python/okstra_ctl/phases/project_analysis/validation.py +55 -0
  146. package/runtime/python/okstra_ctl/phases/release_handoff/__init__.py +1 -0
  147. package/runtime/python/okstra_ctl/phases/release_handoff/boundary.json +17 -0
  148. package/runtime/python/okstra_ctl/phases/release_handoff/entry.py +147 -0
  149. package/runtime/python/okstra_ctl/phases/release_handoff/operations.py +446 -0
  150. package/runtime/{prompts/profiles/release-handoff.md → python/okstra_ctl/phases/release_handoff/profile.md} +3 -3
  151. package/runtime/python/okstra_ctl/{report_html/view_models/release_handoff.py → phases/release_handoff/report.py} +3 -3
  152. package/runtime/{templates/reports → python/okstra_ctl/phases/release_handoff/report_assets}/release-handoff-input.template.md +1 -1
  153. package/runtime/python/okstra_ctl/phases/release_handoff/spec.md +233 -0
  154. package/runtime/python/okstra_ctl/phases/release_handoff/wizard.py +84 -0
  155. package/runtime/python/okstra_ctl/phases/requirements_discovery/__init__.py +1 -0
  156. package/runtime/python/okstra_ctl/phases/requirements_discovery/boundary.json +9 -0
  157. package/runtime/{prompts/profiles/requirements-discovery.md → python/okstra_ctl/phases/requirements_discovery/profile.md} +2 -3
  158. package/runtime/python/okstra_ctl/{report_html/view_models/requirements_discovery.py → phases/requirements_discovery/report.py} +3 -3
  159. package/runtime/python/okstra_ctl/phases/requirements_discovery/spec.md +132 -0
  160. package/runtime/{validators/validate_fanout.py → python/okstra_ctl/phases/requirements_discovery/validation.py} +11 -12
  161. package/runtime/python/okstra_ctl/phases/technical_verification/__init__.py +1 -0
  162. package/runtime/python/okstra_ctl/phases/technical_verification/boundary.json +9 -0
  163. package/runtime/python/okstra_ctl/phases/technical_verification/entry.py +100 -0
  164. package/runtime/{prompts/profiles/technical-verification.md → python/okstra_ctl/phases/technical_verification/profile.md} +2 -2
  165. package/runtime/python/okstra_ctl/{report_html/view_models/technical_verification.py → phases/technical_verification/report.py} +2 -2
  166. package/runtime/python/okstra_ctl/phases/technical_verification/spec.md +37 -0
  167. package/runtime/python/okstra_ctl/phases/technical_verification/validation.py +90 -0
  168. package/runtime/python/okstra_ctl/plan_approval.py +70 -0
  169. package/runtime/python/okstra_ctl/plan_items_cli.py +2 -2130
  170. package/runtime/python/okstra_ctl/process_group.py +118 -0
  171. package/runtime/python/okstra_ctl/profile_show.py +3 -3
  172. package/runtime/python/okstra_ctl/render.py +15 -4
  173. package/runtime/python/okstra_ctl/report_assembly.py +28 -92
  174. package/runtime/python/okstra_ctl/report_finalize.py +106 -2
  175. package/runtime/python/okstra_ctl/report_html/context_links.py +1 -1
  176. package/runtime/python/okstra_ctl/report_projections.py +1 -36
  177. package/runtime/python/okstra_ctl/report_routing.py +23 -0
  178. package/runtime/python/okstra_ctl/report_synthesis_packet.py +4 -73
  179. package/runtime/python/okstra_ctl/report_validation_identity.py +38 -0
  180. package/runtime/python/okstra_ctl/report_views.py +1 -1
  181. package/runtime/python/okstra_ctl/run.py +68 -350
  182. package/runtime/python/okstra_ctl/run_artifact_prune.py +200 -0
  183. package/runtime/python/okstra_ctl/stage_map.py +13 -0
  184. package/runtime/python/okstra_ctl/team.py +108 -9
  185. package/runtime/python/okstra_ctl/technical_verification_facts.py +52 -0
  186. package/runtime/python/okstra_ctl/wizard/__init__.py +31 -31
  187. package/runtime/python/okstra_ctl/wizard/api.py +18 -0
  188. package/runtime/python/okstra_ctl/wizard/outcome.py +3 -12
  189. package/runtime/python/okstra_ctl/wizard/registry.py +20 -12
  190. package/runtime/python/okstra_ctl/wizard/steps_analysis.py +0 -97
  191. package/runtime/python/okstra_ctl/wizard/steps_options.py +8 -0
  192. package/runtime/python/okstra_ctl/wizard/steps_plan.py +10 -263
  193. package/runtime/python/okstra_ctl/wizard/steps_roles.py +2 -1
  194. package/runtime/python/okstra_ctl/work_categories.py +1 -1
  195. package/runtime/python/okstra_ctl/worker_dispatch.py +44 -3
  196. package/runtime/python/okstra_ctl/worker_prompt_contract.py +36 -0
  197. package/runtime/python/okstra_ctl/worker_prompt_policy.py +19 -0
  198. package/runtime/python/okstra_ctl/worker_runner.py +21 -3
  199. package/runtime/python/okstra_ctl/workflow.py +26 -143
  200. package/runtime/python/okstra_ctl/write_policy.py +57 -7
  201. package/runtime/python/okstra_project/dirs.py +14 -0
  202. package/runtime/python/okstra_project/resolver.py +2 -1
  203. package/runtime/schemas/execution-manifest-v2.schema.json +2 -1
  204. package/runtime/skills/okstra-brief-gen/SKILL.md +3 -3
  205. package/runtime/skills/okstra-code-review/SKILL.md +70 -32
  206. package/runtime/skills/okstra-code-review/references/review-calibration.md +26 -6
  207. package/runtime/skills/okstra-run/SKILL.md +3 -3
  208. package/runtime/templates/manager/view.template.html +18 -1
  209. package/runtime/templates/reports/quick-input.template.md +1 -1
  210. package/runtime/templates/reports/task-brief.template.md +1 -1
  211. package/runtime/validators/validate-brief.py +2 -2
  212. package/runtime/validators/validate-run.py +299 -3940
  213. package/runtime/validators/validate_analysis_report.py +14 -126
  214. package/runtime/python/okstra_ctl/report_html/view_models/implementation_planning.py +0 -147
  215. package/runtime/python/okstra_ctl/technical_verification.py +0 -195
  216. /package/runtime/{prompts/profiles/change-impact-analysis.json → python/okstra_ctl/phases/change_impact_analysis/profile.json} +0 -0
  217. /package/runtime/{prompts/profiles/change-impact-analysis.md → python/okstra_ctl/phases/change_impact_analysis/profile.md} +0 -0
  218. /package/runtime/{templates/reports → python/okstra_ctl/phases/change_impact_analysis/report_assets}/change-impact-analysis-input.template.md +0 -0
  219. /package/runtime/{templates/reports/html/tasks → python/okstra_ctl/phases/change_impact_analysis/report_assets}/change-impact-analysis.template.html +0 -0
  220. /package/runtime/{templates/reports/md/tasks → python/okstra_ctl/phases/change_impact_analysis/report_assets}/change-impact-analysis.template.md +0 -0
  221. /package/runtime/{prompts/profiles/error-analysis.json → python/okstra_ctl/phases/error_analysis/profile.json} +0 -0
  222. /package/runtime/{templates/reports/html/tasks → python/okstra_ctl/phases/error_analysis/report_assets}/error-analysis.template.html +0 -0
  223. /package/runtime/{templates/reports/md/tasks → python/okstra_ctl/phases/error_analysis/report_assets}/error-analysis.template.md +0 -0
  224. /package/runtime/{prompts/profiles/feature-analysis.json → python/okstra_ctl/phases/feature_analysis/profile.json} +0 -0
  225. /package/runtime/{prompts/profiles/feature-analysis.md → python/okstra_ctl/phases/feature_analysis/profile.md} +0 -0
  226. /package/runtime/{templates/reports → python/okstra_ctl/phases/feature_analysis/report_assets}/feature-analysis-input.template.md +0 -0
  227. /package/runtime/{templates/reports/html/tasks → python/okstra_ctl/phases/feature_analysis/report_assets}/feature-analysis.template.html +0 -0
  228. /package/runtime/{templates/reports/md/tasks → python/okstra_ctl/phases/feature_analysis/report_assets}/feature-analysis.template.md +0 -0
  229. /package/runtime/{prompts/profiles → python/okstra_ctl/phases/implementation/instructions}/_implementation-diff-review.md +0 -0
  230. /package/runtime/{prompts/profiles → python/okstra_ctl/phases/implementation/instructions}/_implementation-self-check.md +0 -0
  231. /package/runtime/{prompts/profiles/implementation.json → python/okstra_ctl/phases/implementation/profile.json} +0 -0
  232. /package/runtime/{templates/reports/html/tasks → python/okstra_ctl/phases/implementation/report_assets}/implementation.template.html +0 -0
  233. /package/runtime/{templates/reports/md/tasks → python/okstra_ctl/phases/implementation/report_assets}/implementation.template.md +0 -0
  234. /package/runtime/{prompts/profiles/implementation-option-selection.json → python/okstra_ctl/phases/implementation_option_selection/profile.json} +0 -0
  235. /package/runtime/{templates/reports/html/tasks → python/okstra_ctl/phases/implementation_option_selection/report_assets}/implementation-option-selection.template.html +0 -0
  236. /package/runtime/{templates/reports/md/tasks → python/okstra_ctl/phases/implementation_option_selection/report_assets}/implementation-option-selection.template.md +0 -0
  237. /package/runtime/{prompts/host-orchestration/implementation-planning.md → python/okstra_ctl/phases/implementation_planning/host-rules.md} +0 -0
  238. /package/runtime/{prompts/profiles/implementation-planning.json → python/okstra_ctl/phases/implementation_planning/profile.json} +0 -0
  239. /package/runtime/{templates/reports/html/tasks → python/okstra_ctl/phases/implementation_planning/report_assets}/implementation-planning.template.html +0 -0
  240. /package/runtime/{templates/reports/md/tasks → python/okstra_ctl/phases/implementation_planning/report_assets}/implementation-planning.template.md +0 -0
  241. /package/runtime/{prompts/profiles/improvement-discovery.json → python/okstra_ctl/phases/improvement_discovery/profile.json} +0 -0
  242. /package/runtime/{templates/reports/html/tasks → python/okstra_ctl/phases/improvement_discovery/report_assets}/improvement-discovery.template.html +0 -0
  243. /package/runtime/{templates/reports/md/tasks → python/okstra_ctl/phases/improvement_discovery/report_assets}/improvement-discovery.template.md +0 -0
  244. /package/runtime/{prompts/profiles/project-analysis.json → python/okstra_ctl/phases/project_analysis/profile.json} +0 -0
  245. /package/runtime/{prompts/profiles/project-analysis.md → python/okstra_ctl/phases/project_analysis/profile.md} +0 -0
  246. /package/runtime/{templates/reports → python/okstra_ctl/phases/project_analysis/report_assets}/project-analysis-input.template.md +0 -0
  247. /package/runtime/{templates/reports/html/tasks → python/okstra_ctl/phases/project_analysis/report_assets}/project-analysis.template.html +0 -0
  248. /package/runtime/{templates/reports/md/tasks → python/okstra_ctl/phases/project_analysis/report_assets}/project-analysis.template.md +0 -0
  249. /package/runtime/{prompts/profiles/release-handoff.json → python/okstra_ctl/phases/release_handoff/profile.json} +0 -0
  250. /package/runtime/{templates/reports/html/tasks → python/okstra_ctl/phases/release_handoff/report_assets}/release-handoff.template.html +0 -0
  251. /package/runtime/{templates/reports/md/tasks → python/okstra_ctl/phases/release_handoff/report_assets}/release-handoff.template.md +0 -0
  252. /package/runtime/python/okstra_ctl/{fanout.py → phases/requirements_discovery/fanout.py} +0 -0
  253. /package/runtime/{prompts/profiles/requirements-discovery.json → python/okstra_ctl/phases/requirements_discovery/profile.json} +0 -0
  254. /package/runtime/{templates/reports → python/okstra_ctl/phases/requirements_discovery/report_assets}/fan-out-unit.template.md +0 -0
  255. /package/runtime/{templates/reports/html/tasks → python/okstra_ctl/phases/requirements_discovery/report_assets}/requirements-discovery.template.html +0 -0
  256. /package/runtime/{templates/reports/md/tasks → python/okstra_ctl/phases/requirements_discovery/report_assets}/requirements-discovery.template.md +0 -0
  257. /package/runtime/{prompts/profiles/technical-verification.json → python/okstra_ctl/phases/technical_verification/profile.json} +0 -0
  258. /package/runtime/{templates/reports/html/tasks → python/okstra_ctl/phases/technical_verification/report_assets}/technical-verification.template.html +0 -0
  259. /package/runtime/{templates/reports/md/tasks → python/okstra_ctl/phases/technical_verification/report_assets}/technical-verification.template.md +0 -0
@@ -36,15 +36,150 @@ except ImportError: # pragma: no cover — runtime guarantees this import
36
36
  load_schema_for_data = None # type: ignore[assignment]
37
37
  schema_validate = None # type: ignore[assignment]
38
38
 
39
+ from okstra_ctl.phases.implementation.validation import (
40
+ _validate_verifier_reran_independently,
41
+ _validate_verifier_discrepancy_is_not_passed,
42
+ _validate_verifier_fail_blocks_verdict,
43
+ _validate_verifier_command_log_is_read_only as validate_verifier_command_log_is_read_only,
44
+ _validate_verifier_discrepancy_names_checklist_phase as validate_verifier_discrepancy_names_checklist_phase,
45
+ _warn_out_of_plan_edits_not_in_diff as warn_out_of_plan_edits_not_in_diff,
46
+ )
47
+
48
+ from okstra_ctl.phases.implementation_planning.validation import validate_selected_direction_plan as validate_selected_direction_plan
49
+ from okstra_ctl.phases.implementation_planning.guidance import (
50
+ _validate_implementation_planning_cross_project as _validate_implementation_planning_cross_project,
51
+ _validate_implementation_planning_decision_drafts as _validate_implementation_planning_decision_drafts,
52
+ _next_step_texts as _next_step_texts,
53
+ _has_unresolved_approval_blocker as _has_unresolved_approval_blocker,
54
+ _planning_gate_blocks_approval as _planning_gate_blocks_approval,
55
+ _validate_rerun_guidance as _validate_rerun_guidance,
56
+ _validate_approval_guidance as _validate_approval_guidance,
57
+ )
58
+ from okstra_ctl.plan_approval import plan_is_approved as _report_already_approved
59
+ from okstra_ctl.report_validation_identity import (
60
+ _report_run_seq as _report_run_seq,
61
+ _report_task_type as _report_task_type,
62
+ )
63
+ from okstra_ctl.phases.implementation_planning.plan_body import (
64
+ _CHECKLIST_REF_RE as _CHECKLIST_REF_RE,
65
+ _PLAN_BODY_STATE_KEYS as _PLAN_BODY_STATE_KEYS,
66
+ _UNMAPPED_FALLBACK_REASON as _UNMAPPED_FALLBACK_REASON,
67
+ _detect_missing_dependency_precondition as _detect_missing_dependency_precondition,
68
+ _detect_unmapped_incremental_fallback as _detect_unmapped_incremental_fallback,
69
+ _implemented_stages as _implemented_stages,
70
+ _is_activity_contract_v1_planning as _is_activity_contract_v1_planning,
71
+ _is_implemented_stage as _is_implemented_stage,
72
+ _planning_conformance_declarations as _planning_conformance_declarations,
73
+ _prior_planning_data as _prior_planning_data,
74
+ _validate_approval_context as _validate_approval_context,
75
+ _validate_plan_body_state_file as _validate_plan_body_state_file,
76
+ _validate_plan_body_state_rounds as _validate_plan_body_state_rounds,
77
+ _validate_planning_conformance_declared as _validate_planning_conformance_declared,
78
+ _validate_v3_approval_context as _validate_v3_approval_context,
79
+ _ADVISORY_ONLY_KINDS as _ADVISORY_ONLY_KINDS,
80
+ _ANSWERED_CLARIFICATION_STATUSES as _ANSWERED_CLARIFICATION_STATUSES,
81
+ _APPROVAL_CLARIFICATION_ID_RE as _APPROVAL_CLARIFICATION_ID_RE,
82
+ _BARE_PLAN_ITEM_ID_RE as _BARE_PLAN_ITEM_ID_RE,
83
+ _COVERED_BY_ANCHOR_RE as _COVERED_BY_ANCHOR_RE,
84
+ _COVERED_BY_STAGE_REF_RE as _COVERED_BY_STAGE_REF_RE,
85
+ _COVERED_BY_VAGUE as _COVERED_BY_VAGUE,
86
+ _DESIGN_PREP_CONTRACT as _DESIGN_PREP_CONTRACT,
87
+ _DESIGN_PREP_REQUEST_STATUSES as _DESIGN_PREP_REQUEST_STATUSES,
88
+ _DESIGN_PREP_TERMINAL_STATUSES as _DESIGN_PREP_TERMINAL_STATUSES,
89
+ _DEVIATION_BLOCKED_DISPOSITION_RE as _DEVIATION_BLOCKED_DISPOSITION_RE,
90
+ _DEVIATION_DECISION_REF_RE as _DEVIATION_DECISION_REF_RE,
91
+ _FIXABILITY_VALUES as _FIXABILITY_VALUES,
92
+ _MIN_SUBJECT_LEN as _MIN_SUBJECT_LEN,
93
+ _NEAR_MISS_SAMPLE as _NEAR_MISS_SAMPLE,
94
+ _PLAN_GATE_RANK as _PLAN_GATE_RANK,
95
+ _SELF_FIX_EXHAUSTED_REASONS as _SELF_FIX_EXHAUSTED_REASONS,
96
+ _SELF_FIX_NOTE_ROUND_RE as _SELF_FIX_NOTE_ROUND_RE,
97
+ _SINGLE_VOTE_BLOCKING_KINDS as _SINGLE_VOTE_BLOCKING_KINDS,
98
+ _UNIFORM_VERIFIER_MIN_ITEMS as _UNIFORM_VERIFIER_MIN_ITEMS,
99
+ _answered_clarification_ids as _answered_clarification_ids,
100
+ _blocks_approval as _blocks_approval,
101
+ _cited_clarification_id as _cited_clarification_id,
102
+ _clarification_ids_on_activity as _clarification_ids_on_activity,
103
+ _classify_plan_item_gate as _classify_plan_item_gate,
104
+ _critic_gate_class as _critic_gate_class,
105
+ _critic_non_error_verdicts as _critic_non_error_verdicts,
106
+ _design_prep_rows as _design_prep_rows,
107
+ _detect_self_fix_recurrence as _detect_self_fix_recurrence,
108
+ _detect_uniform_verifier as _detect_uniform_verifier,
109
+ _deviation_is_user_confirmed as _deviation_is_user_confirmed,
110
+ _deviation_target as _deviation_target,
111
+ _disagree_breakage_kinds as _disagree_breakage_kinds,
112
+ _gate_blocking_causes as _gate_blocking_causes,
113
+ _gate_summary_item as _gate_summary_item,
114
+ _has_clarification_backtrace as _has_clarification_backtrace,
115
+ _has_planner_fixable_majority as _has_planner_fixable_majority,
116
+ _independent_coverage_blockers as _independent_coverage_blockers,
117
+ _is_correctness_critical as _is_correctness_critical,
118
+ _is_dissent_downgraded as _is_dissent_downgraded,
119
+ _is_even_analyser_split as _is_even_analyser_split,
120
+ _is_unsettled_tie as _is_unsettled_tie,
121
+ _is_variation_point_item as _is_variation_point_item,
122
+ _items_resolved_in_round as _items_resolved_in_round,
123
+ _lead_decision_applies as _lead_decision_applies,
124
+ _manifest_seq_for_report as _manifest_seq_for_report,
125
+ _plan_body_promoted_clarification_ids as _plan_body_promoted_clarification_ids,
126
+ _plan_item_clarification_ids as _plan_item_clarification_ids,
127
+ _plan_item_decision_authority as _plan_item_decision_authority,
128
+ _plan_item_gate_class as _plan_item_gate_class,
129
+ _plan_item_ids_for_clarification as _plan_item_ids_for_clarification,
130
+ _plan_items_routed_to_a_user_decision as _plan_items_routed_to_a_user_decision,
131
+ _plan_verify_dispatched_results as _plan_verify_dispatched_results,
132
+ _plan_verify_result_workers as _plan_verify_result_workers,
133
+ _plan_verify_seq_aliases as _plan_verify_seq_aliases,
134
+ _plan_verify_seq_near_misses as _plan_verify_seq_near_misses,
135
+ _recompute_plan_body_gate as _recompute_plan_body_gate,
136
+ _resolved_deviation_refs as _resolved_deviation_refs,
137
+ _resolved_noncritical_dissent_ids as _resolved_noncritical_dissent_ids,
138
+ _self_fix_budget_exhausted as _self_fix_budget_exhausted,
139
+ _set_aside_reason as _set_aside_reason,
140
+ _set_aside_register as _set_aside_register,
141
+ _single_vote_block_survives as _single_vote_block_survives,
142
+ _single_vote_dissents as _single_vote_dissents,
143
+ _stage_scope_bucket as _stage_scope_bucket,
144
+ _state_classification as _state_classification,
145
+ _unbacked_remedy_clause as _unbacked_remedy_clause,
146
+ _user_accepted_plan_item_ids as _user_accepted_plan_item_ids,
147
+ _validate_aborted_gate_has_clarification as _validate_aborted_gate_has_clarification,
148
+ _validate_advisory_plan_body_gating as _validate_advisory_plan_body_gating,
149
+ _validate_approval_clarification_backtrace as _validate_approval_clarification_backtrace,
150
+ _validate_design_prep_contract as _validate_design_prep_contract,
151
+ _validate_design_prep_requests as _validate_design_prep_requests,
152
+ _validate_design_prep_states as _validate_design_prep_states,
153
+ _validate_deviation_disposition as _validate_deviation_disposition,
154
+ _validate_disagree_has_fixability as _validate_disagree_has_fixability,
155
+ _validate_gate_blocked_by as _validate_gate_blocked_by,
156
+ _validate_participating_analysers as _validate_participating_analysers,
157
+ _validate_plan_body_clarification_matching as _validate_plan_body_clarification_matching,
158
+ _validate_plan_body_gate_recompute as _validate_plan_body_gate_recompute,
159
+ _validate_plan_body_verdict_provenance as _validate_plan_body_verdict_provenance,
160
+ _validate_plan_item_extraction_completeness as _validate_plan_item_extraction_completeness,
161
+ _validate_plan_item_subject_substance as _validate_plan_item_subject_substance,
162
+ _validate_requirement_coverage_covered_by as _validate_requirement_coverage_covered_by,
163
+ _validate_requirement_deviations as _validate_requirement_deviations,
164
+ _validate_round_recorded_verdicts as _validate_round_recorded_verdicts,
165
+ _validate_self_fix_before_clarification as _validate_self_fix_before_clarification,
166
+ _validate_self_fix_grouping as _validate_self_fix_grouping,
167
+ _validate_supersession_ledger as _validate_supersession_ledger,
168
+ _validate_unresolved_tie_was_reverified as _validate_unresolved_tie_was_reverified,
169
+ _validate_variation_point_analysis as _validate_variation_point_analysis,
170
+ _validate_verdict_rounds_outlive_self_fix as _validate_verdict_rounds_outlive_self_fix,
171
+ _validate_verdicts_match_current_subjects as _validate_verdicts_match_current_subjects,
172
+ plan_body_gate_summary as plan_body_gate_summary,
173
+ validate_plan_body_section as validate_plan_body_section,
174
+ )
175
+ from okstra_ctl.phases.error_analysis.validation import _validate_error_analysis_consistency
39
176
  from okstra_project import project_json_path # noqa: E402
40
177
  from okstra_project.dirs import tasks_root as _okstra_tasks_root # noqa: E402
41
178
  from okstra_project.resolver import resolve_architecture # noqa: E402
42
179
 
43
- from okstra_ctl.conformance import ( # noqa: E402
180
+ from okstra_ctl.conformance import (
44
181
  conformance_result_file,
45
182
  detect_surfaces,
46
- declared_stage_surface_gaps,
47
- exempt_stage_surface_conflicts,
48
183
  evaluate_conformance,
49
184
  manifest_required_surfaces,
50
185
  missing_declared_scripts,
@@ -71,10 +206,6 @@ from okstra_ctl.release_gate import ( # noqa: E402
71
206
  )
72
207
  from okstra_ctl.domain.host import HostNotRegistered # noqa: E402
73
208
  from okstra_ctl.registry.host_registry import default_host_registry # noqa: E402
74
- from okstra_ctl.build_tools import ( # noqa: E402
75
- command_invokes_build_tool,
76
- resolve_build_tool_tokens,
77
- )
78
209
  from okstra_ctl.mutation_probe import ( # noqa: E402
79
210
  INTEGRITY_INSPECTION,
80
211
  classify_reason,
@@ -84,36 +215,13 @@ from okstra_ctl.validation_contract import ( # noqa: E402
84
215
  CURRENT_VALIDATION_CONTRACT_VERSION,
85
216
  )
86
217
  from okstra_ctl.blocking_checks import partition as partition_blocking # noqa: E402
87
- from okstra_ctl.stage_citations import enumerated_stage_numbers # noqa: E402
88
- from okstra_ctl.plan_items import ( # noqa: E402
89
- CRITIC_WORKER_ID,
90
- advisory_plan_body_gating,
91
- requires_plan_repair,
92
- analyser_key as _analyser_key,
93
- is_critic_worker,
94
- lead_decision_basis,
95
- self_fix_rounds,
96
- stage_scope_bucket as _item_stage_scope_bucket,
97
- voting_analyser_keys,
98
- )
99
- from okstra_ctl.incremental_scope import ( # noqa: E402
100
- coverage_row_blocked_on,
101
- stages_for_clarification,
102
- )
103
218
  from okstra_ctl import next_phase # noqa: E402
104
219
  from okstra_ctl.phases.final_verification.validation import ( # noqa: E402
105
220
  validate_final_verification_content,
106
221
  validate_verification_target_match,
107
222
  )
108
- from okstra_ctl.clarification_items import ( # noqa: E402
109
- APPROVAL_BLOCKS,
110
- clarification_disposition,
111
- incorporated_clarification_ids,
112
- progress_blocking_ids,
113
- row_blocks_progress,
114
- )
115
223
  from okstra_ctl.workflow import ( # noqa: E402
116
- ERROR_ANALYSIS_ROUTING_DIRECTIONS,
224
+ ERROR_ANALYSIS_ROUTING_DIRECTIONS as ERROR_ANALYSIS_ROUTING_DIRECTIONS,
117
225
  PHASE_SEQUENCE,
118
226
  REQUIREMENTS_DISCOVERY_ROUTING_TARGETS,
119
227
  )
@@ -122,33 +230,22 @@ from okstra_ctl.final_report_paths import ( # noqa: E402
122
230
  translation_sidecar_path,
123
231
  )
124
232
  from okstra_token_usage.report import _match_worker_index # noqa: E402
125
- from okstra_ctl.technical_verification import validate_technical_verification_report
126
- from okstra_ctl.implementation_options import ( # noqa: E402
233
+ from okstra_ctl.phases.requirements_discovery.validation import (
234
+ validate_requirements_discovery_fanout as _validate_requirements_discovery_fanout,
235
+ )
236
+ from okstra_ctl.phases.technical_verification.validation import validate_technical_verification_report
237
+ from okstra_ctl.report_routing import technical_verification_routing_errors
238
+ from okstra_ctl.phases.implementation_option_selection.validation import ( # noqa: E402
127
239
  validate_blocked_answer_channel,
128
240
  validate_implementation_option_selection,
129
241
  )
130
- from okstra_ctl.implementation_direction import ( # noqa: E402
131
- validate_selected_direction_plan,
242
+ from okstra_ctl.phases.implementation_planning.validation import (
243
+ _validate_requirement_provenance, _validate_stage_has_requirement,
132
244
  )
133
245
  from okstra_ctl.scope_provenance import ( # noqa: E402
134
- brief_citation_problem,
135
246
  brief_end_state_id_sequence,
136
247
  brief_end_state_ids,
137
248
  brief_headings,
138
- parse_source,
139
- resolve_chain,
140
- )
141
- from okstra_ctl.design_prep import ( # noqa: E402
142
- DesignPrepError,
143
- _planning_seq as _design_prep_planning_seq,
144
- _render_request as _render_design_prep_request,
145
- _report_language as _design_prep_report_language,
146
- _request_identity as _design_prep_request_identity,
147
- )
148
- from okstra_ctl.design_surfaces import DesignSurfaceError # noqa: E402
149
- from okstra_ctl.plan_items import ( # noqa: E402
150
- expected_plan_item_ids,
151
- extract_plan_items,
152
249
  )
153
250
  from okstra_ctl.worker_prompt_contract import ( # noqa: E402
154
251
  PromptRecord,
@@ -719,8 +816,6 @@ def _session_accounting(team_state: dict) -> str:
719
816
  return "claude-jsonl"
720
817
 
721
818
 
722
-
723
-
724
819
  def load_json(path: Path) -> dict:
725
820
  try:
726
821
  return json.loads(path.read_text())
@@ -735,13 +830,6 @@ def write_json(path: Path, payload: dict) -> None:
735
830
  path.write_text(json.dumps(payload, indent=2, ensure_ascii=False) + "\n")
736
831
 
737
832
 
738
- def _report_already_approved(report_data: Mapping[str, Any] | None) -> bool:
739
- if not isinstance(report_data, Mapping):
740
- return False
741
- frontmatter = report_data.get("frontmatter")
742
- return isinstance(frontmatter, Mapping) and frontmatter.get("approved") is True
743
-
744
-
745
833
  def _derive_awaiting_approval(
746
834
  *,
747
835
  existing: bool,
@@ -2189,35 +2277,6 @@ def _declared_conformance_errors(
2189
2277
  return errors
2190
2278
 
2191
2279
 
2192
- def _planning_conformance_declarations(
2193
- stages: object,
2194
- failures: list[str],
2195
- ) -> list[dict]:
2196
- declarations: list[dict] = []
2197
- for stage in stages if isinstance(stages, list) else []:
2198
- if not isinstance(stage, dict) or not str(stage.get("conformanceTests") or "").strip():
2199
- continue
2200
- parsed = _parse_conformance_tests(stage.get("conformanceTests"))
2201
- if parsed is None:
2202
- failures.append(
2203
- "final-report data.json: stage "
2204
- f"{stage.get('stage')} has malformed conformanceTests "
2205
- f"declaration: got {str(stage.get('conformanceTests'))!r}; "
2206
- "expected `<task_root>/qa/scripts/stage-<N>.<ext> "
2207
- "(requires=[db|io|http|external,...])`."
2208
- )
2209
- continue
2210
- script, requires = parsed
2211
- declarations.append(
2212
- {
2213
- "stageKey": f"approved-plan-stage-{stage.get('stage')}",
2214
- "script": script,
2215
- "requires": sorted(requires),
2216
- }
2217
- )
2218
- return declarations
2219
-
2220
-
2221
2280
  def _project_surface_patterns(project_root: Path) -> object:
2222
2281
  """project.json `qaEnv.surfacePatterns` — 계획·구현 두 게이트가 같은 표를 쓴다."""
2223
2282
  path = project_json_path(project_root)
@@ -2229,97 +2288,6 @@ def _project_surface_patterns(project_root: Path) -> object:
2229
2288
  return None
2230
2289
 
2231
2290
 
2232
- def _implemented_stages(data_path: Path) -> frozenset[int]:
2233
- """이 태스크에서 구현이 끝난 stage. 원장을 못 읽으면 빈 집합이다."""
2234
- from okstra_ctl.consumers import read_stage_consumer_state
2235
-
2236
- try:
2237
- state = read_stage_consumer_state(data_path.parent.parent)
2238
- except (OSError, UnicodeError, ValueError):
2239
- return frozenset()
2240
- return frozenset(state.done_stages)
2241
-
2242
-
2243
- def _is_implemented_stage(stage: object, implemented: frozenset[int]) -> bool:
2244
- number = stage.get("stage") if isinstance(stage, dict) else None
2245
- return isinstance(number, int) and not isinstance(number, bool) and (
2246
- number in implemented
2247
- )
2248
-
2249
-
2250
- def _validate_planning_conformance_declared(
2251
- report_path: Path,
2252
- failures: list[str],
2253
- surface_patterns: object = None,
2254
- ) -> None:
2255
- """계획 단계는 `Conformance tests:` / `Conformance exemption:` 선언 형식을 본다.
2256
-
2257
- 스크립트 파일과 `runCommand` 는 매칭 implementation stage 가 만든다.
2258
- 선언만 있고 파일이 없는 것은 계획 게이트 실패가 아니다. 형식이 깨진
2259
- `conformanceTests` 는 여전히 실패한다.
2260
-
2261
- 면제 stage 의 `plannedPaths` 가 db/io/http/external 표면을 건드리면 여기서
2262
- 막는다 — 구현 게이트의 diff-surface 대조(`_validate_conformance_surfaces`)와
2263
- 같은 패턴이다. 종전에는 그 대조가 구현이 끝난 뒤에만 돌아, 승인된 계획을
2264
- 고칠 수 없는 자리에서 run 전체가 막혔다(2026-09-09 dev-10784 Stage 2).
2265
- """
2266
- data_path = _data_path_for(report_path)
2267
- if not data_path.is_file():
2268
- return
2269
- try:
2270
- data = json.loads(data_path.read_text(encoding="utf-8"))
2271
- except (OSError, json.JSONDecodeError):
2272
- return
2273
- ip = data.get("implementationPlanning")
2274
- if not isinstance(ip, dict):
2275
- return
2276
- # 구현이 끝난 stage 는 이 게이트의 대상이 아니다. 그 본문은 다음 계획 run 에
2277
- # 그대로 이월되고(ADR-0015), 이월된 본문은 고칠 수 없다 — 규칙이 그 사이에
2278
- # 넓어졌다면 통과 가능한 값이 없는 요구가 된다(2026-09-24, dev-10860: 이월된
2279
- # stage 2·3·5 가 오늘의 표면 패턴으로 `requires` 누락 판정).
2280
- implemented = _implemented_stages(data_path)
2281
- stages = [
2282
- stage
2283
- for stage in ip.get("stages") or ()
2284
- if not _is_implemented_stage(stage, implemented)
2285
- ]
2286
- _planning_conformance_declarations(stages, failures)
2287
- if ip.get("planningContract") != "selected-direction":
2288
- from okstra_ctl.implementation_direction import (
2289
- stage_validation_executability_errors,
2290
- )
2291
-
2292
- failures.extend(stage_validation_executability_errors(ip))
2293
- for conflict in exempt_stage_surface_conflicts(data, surface_patterns):
2294
- if conflict["stage"] in implemented:
2295
- continue
2296
- failures.append(
2297
- "conformance gate BLOCKING: stage "
2298
- f"{conflict['stage']} declares `Conformance exemption:` but its "
2299
- f"planned paths touch surface(s) {conflict['surfaces']}: "
2300
- f"{', '.join(conflict['paths'])} — an exemption cannot hide a "
2301
- "db/io/http/external change (prompts/profiles/implementation-planning.md "
2302
- "\"Per-stage conformance declaration\"); declare `Conformance tests:` "
2303
- f"with requires={conflict['surfaces']} for that stage, or move those "
2304
- "paths out of it. The implementation run's diff-surface check "
2305
- "blocks the same stage after the work is done, where the approved "
2306
- "plan can no longer be corrected."
2307
- )
2308
- for gap in declared_stage_surface_gaps(data, surface_patterns):
2309
- if gap["stage"] in implemented:
2310
- continue
2311
- failures.append(
2312
- "conformance gate BLOCKING: stage "
2313
- f"{gap['stage']} declares `Conformance tests:` with "
2314
- f"requires={gap['requires']} but its planned paths touch surface(s) "
2315
- f"{gap['surfaces']}: {', '.join(gap['paths'])} — add "
2316
- f"{gap['surfaces']} to that stage's `requires`, or move those paths "
2317
- "out of it. The implementation run's diff-surface check demands "
2318
- "the wider set after the work is done, where the approved plan can "
2319
- "no longer be corrected."
2320
- )
2321
-
2322
-
2323
2291
  def _validate_conformance_surfaces(
2324
2292
  report_data: Mapping[str, Any],
2325
2293
  scoped_manifest: dict,
@@ -2978,39 +2946,6 @@ def validate_report(
2978
2946
  failures.append(f"final report is missing: {report_path}")
2979
2947
 
2980
2948
 
2981
- _REPORT_BASENAME_SEQ_RE = re.compile(r"-(?P<seq>\d{3})(?:\.data)?\.(?:md|json)$")
2982
- _REPORT_BASENAME_TASK_TYPE_RE = re.compile(
2983
- r"^final-report-(?P<task_type>[a-z][a-z-]*?)-\d{3}(?:\.data)?\.(?:md|json)$"
2984
- )
2985
-
2986
-
2987
- def _report_run_seq(report_path: Path) -> str | None:
2988
- """This run's seq, read off `final-report-<task-type>-<seq>.md`. ``None``
2989
- when the name does not carry one, so callers fall back to not filtering
2990
- rather than silently checking nothing."""
2991
- match = _REPORT_BASENAME_SEQ_RE.search(report_path.name)
2992
- return match.group("seq") if match else None
2993
-
2994
-
2995
- def _report_task_type(report_path: Path) -> str:
2996
- """This run's task type, read off `final-report-<task-type>-<seq>`.
2997
-
2998
- The report's own `header.taskType` is the first source, but it is not
2999
- always reachable. `report_narrative._allowed_top_level()` has no `header` —
3000
- it is not a writer-owned block — so a gate scored from a narrative, which is
3001
- the only input a report-contract-3.0 run has before assembly, carries no
3002
- task type at all. Globbing with an empty one matched nothing and reported
3003
- every verdict as unbacked under a `runs//worker-results/` path.
3004
-
3005
- The filename carries it in every caller: the full-run path passes the report
3006
- itself, and `_report_path_for_state` builds the same canonical name from the
3007
- state file. Returns `""` when the name does not carry one, so a caller can
3008
- tell "not resolvable" from a real task type.
3009
- """
3010
- match = _REPORT_BASENAME_TASK_TYPE_RE.match(report_path.name)
3011
- return match.group("task_type") if match else ""
3012
-
3013
-
3014
2949
  def validate_worker_results_audit(
3015
2950
  report_path: Path,
3016
2951
  task_type: str,
@@ -3085,212 +3020,6 @@ PLAN_VERIFY_GATE_VALUES = (
3085
3020
  _FRONTMATTER_BLOCK_RE = re.compile(r"\A\ufeff?\s*---\n(.*?)\n---\n", re.DOTALL)
3086
3021
 
3087
3022
 
3088
- def _upstream_by_candidate(candidates: list[Any]) -> dict[str, list[str]]:
3089
- upstream: dict[str, list[str]] = {}
3090
- for candidate in candidates:
3091
- if not isinstance(candidate, Mapping):
3092
- continue
3093
- candidate_id = candidate.get("id")
3094
- declared = candidate.get("downstreamOf")
3095
- if isinstance(candidate_id, str) and isinstance(declared, list):
3096
- upstream[candidate_id] = [row for row in declared if isinstance(row, str)]
3097
- return upstream
3098
-
3099
-
3100
- def _chain_cycle(upstream: dict[str, list[str]]) -> list[str]:
3101
- """The first cycle reachable through `downstreamOf`, as the ids that form it.
3102
-
3103
- A cycle is a diagnosis that says each step is caused by the next, so it
3104
- names no first cause. It also hangs the figure's layering, which relaxes
3105
- until depths settle.
3106
- """
3107
- settled: set[str] = set()
3108
- for start in sorted(upstream):
3109
- stack = [start]
3110
- on_path: list[str] = []
3111
- while stack:
3112
- current = stack.pop()
3113
- if current in on_path:
3114
- return on_path[on_path.index(current):] + [current]
3115
- if current in settled or current not in upstream:
3116
- continue
3117
- on_path.append(current)
3118
- stack.extend(upstream[current])
3119
- settled.update(on_path)
3120
- return []
3121
-
3122
-
3123
- def _validate_cause_chain(
3124
- candidates: list[Any], candidate_ids: set[str], failures: list[str]
3125
- ) -> None:
3126
- """`downstreamOf` must name a sibling candidate, and never itself."""
3127
- upstream = _upstream_by_candidate(candidates)
3128
- for candidate_id in sorted(upstream):
3129
- unknown = sorted(set(upstream[candidate_id]) - candidate_ids)
3130
- if unknown:
3131
- failures.append(
3132
- f"final-report data.json: {candidate_id}.downstreamOf names "
3133
- "unknown cause candidate(s): " + ", ".join(unknown) + "."
3134
- )
3135
- if candidate_id in upstream[candidate_id]:
3136
- failures.append(
3137
- f"final-report data.json: {candidate_id}.downstreamOf names itself."
3138
- )
3139
- cycle = _chain_cycle(
3140
- {key: [row for row in value if row in candidate_ids] for key, value in upstream.items()}
3141
- )
3142
- if cycle:
3143
- failures.append(
3144
- "final-report data.json: cause candidates form a downstreamOf cycle: "
3145
- + " -> ".join(cycle)
3146
- + "."
3147
- )
3148
-
3149
-
3150
- def _validate_error_analysis_consistency(
3151
- data: Mapping[str, Any], failures: list[str]
3152
- ) -> None:
3153
- error_analysis_value = data.get("errorAnalysis")
3154
- error_analysis = (
3155
- error_analysis_value if isinstance(error_analysis_value, Mapping) else {}
3156
- )
3157
- reproduction_value = error_analysis.get("reproduction")
3158
- reproduction = (
3159
- reproduction_value if isinstance(reproduction_value, Mapping) else {}
3160
- )
3161
- reproduction_status = reproduction.get("status")
3162
- blocked_reason = reproduction.get("blockedReason")
3163
- if reproduction_status == "blocked-before-repro":
3164
- if not isinstance(blocked_reason, str) or not blocked_reason.strip():
3165
- failures.append(
3166
- "final-report data.json: blocked-before-repro requires a non-empty "
3167
- "errorAnalysis.reproduction.blockedReason."
3168
- )
3169
- elif blocked_reason != "":
3170
- failures.append(
3171
- "final-report data.json: errorAnalysis.reproduction.blockedReason "
3172
- "must be exactly empty unless status is blocked-before-repro."
3173
- )
3174
-
3175
- candidates_value = error_analysis.get("causeCandidates")
3176
- candidates = candidates_value if isinstance(candidates_value, list) else []
3177
- candidate_ids: list[str] = []
3178
- for candidate in candidates:
3179
- if not isinstance(candidate, Mapping):
3180
- continue
3181
- candidate_id = candidate.get("id")
3182
- if isinstance(candidate_id, str):
3183
- candidate_ids.append(candidate_id)
3184
- duplicate_ids = sorted(
3185
- candidate_id
3186
- for candidate_id in set(candidate_ids)
3187
- if candidate_ids.count(candidate_id) > 1
3188
- )
3189
- if duplicate_ids:
3190
- failures.append(
3191
- "final-report data.json: duplicate cause candidate id(s): "
3192
- + ", ".join(duplicate_ids)
3193
- + "."
3194
- )
3195
-
3196
- _validate_cause_chain(candidates, set(candidate_ids), failures)
3197
-
3198
- routing_value = error_analysis.get("routing")
3199
- routing = routing_value if isinstance(routing_value, Mapping) else {}
3200
- target = routing.get("nextTaskType")
3201
- leading_cause_id = routing.get("leadingCauseId")
3202
- candidate_id_set = set(candidate_ids)
3203
- if isinstance(target, str) and target not in ERROR_ANALYSIS_ROUTING_DIRECTIONS:
3204
- failures.append(
3205
- "final-report data.json: errorAnalysis.routing has unsupported "
3206
- f"routing target `{target}`."
3207
- )
3208
- if target == "implementation-option-selection":
3209
- if not candidates:
3210
- failures.append(
3211
- "final-report data.json: implementation-option-selection routing requires "
3212
- "at least one cause candidate."
3213
- )
3214
- if (
3215
- not isinstance(leading_cause_id, str)
3216
- or leading_cause_id not in candidate_id_set
3217
- ):
3218
- failures.append(
3219
- "final-report data.json: implementation-option-selection routing "
3220
- "leadingCauseId must reference a cause candidate."
3221
- )
3222
- elif target == "error-analysis" and (
3223
- not isinstance(leading_cause_id, str)
3224
- or (leading_cause_id != "" and leading_cause_id not in candidate_id_set)
3225
- ):
3226
- failures.append(
3227
- "final-report data.json: error-analysis routing leadingCauseId must be "
3228
- "empty or reference a cause candidate."
3229
- )
3230
-
3231
- expected_direction = ERROR_ANALYSIS_ROUTING_DIRECTIONS.get(target)
3232
- verdict_card_value = data.get("verdictCard")
3233
- verdict_card = (
3234
- verdict_card_value if isinstance(verdict_card_value, Mapping) else {}
3235
- )
3236
- final_verdict_value = data.get("finalVerdict")
3237
- final_verdict = (
3238
- final_verdict_value if isinstance(final_verdict_value, Mapping) else {}
3239
- )
3240
- if expected_direction:
3241
- for field_name, verdict in (
3242
- ("verdictCard", verdict_card),
3243
- ("finalVerdict", final_verdict),
3244
- ):
3245
- if verdict.get("direction") != expected_direction:
3246
- failures.append(
3247
- f"final-report data.json: {field_name}.direction must be "
3248
- f"`{expected_direction}` for `{target}` routing."
3249
- )
3250
-
3251
- follow_up_tasks_value = data.get("followUpTasks")
3252
- follow_up_tasks = (
3253
- follow_up_tasks_value if isinstance(follow_up_tasks_value, list) else []
3254
- )
3255
- continuations = [
3256
- row
3257
- for row in follow_up_tasks
3258
- if isinstance(row, Mapping) and row.get("origin") == "phase-continuation"
3259
- ]
3260
- if len(continuations) != 1:
3261
- failures.append(
3262
- "final-report data.json: followUpTasks must contain exactly one "
3263
- "phase-continuation row."
3264
- )
3265
- else:
3266
- continuation = continuations[0]
3267
- frontmatter_value = data.get("frontmatter")
3268
- frontmatter = (
3269
- frontmatter_value if isinstance(frontmatter_value, Mapping) else {}
3270
- )
3271
- task_id = frontmatter.get("taskId")
3272
- if continuation.get("suggestedTaskType") != target:
3273
- failures.append(
3274
- "final-report data.json: phase-continuation suggestedTaskType "
3275
- "must match the routing target."
3276
- )
3277
- # `newTaskId` 의 형식과 `priority` 값은 여기서 보지 않는다. 이 행은
3278
- # `okstra-spawn-followups.py` 의 `NON_SPAWNING_ORIGINS` 에 들어 있어
3279
- # 아무것도 스폰하지 않는 표식이고, 그래서 두 필드에는 소비자가 없다.
3280
- # 게다가 그 스크립트의 priority 기본값은 `P1` 인데 여기서는 `P0` 를
3281
- # 요구해 두 값이 정면으로 어긋났고, 두 규칙 중 어느 쪽도 report-writer
3282
- # 가 읽는 스키마·프로필·템플릿 어디에도 적혀 있지 않았다 — 작성자가
3283
- # 알 수 없는 규칙을 소비자 없는 필드에 걸어 두고 있었다.
3284
- if continuation.get("autoSpawn") != "no":
3285
- failures.append(
3286
- "final-report data.json: phase-continuation autoSpawn must be no."
3287
- )
3288
-
3289
- # 산문(nextStep / recommendedNextSteps.text / commands)이 라우팅 문자열을
3290
- # 포함하는지 강제하던 블록은 삭제했다. 라우팅 대상은 위의 enum 검사와
3291
- # `leadingCauseId` 정합이 판정하고, 표기 강제는 소비자가 없다.
3292
-
3293
-
3294
3023
  def _load_final_report_data(report_path: Path) -> dict:
3295
3024
  """Best-effort parse of the final-report data.json sibling. Returns {} when
3296
3025
  absent or unparseable — those conditions are already surfaced as failures by
@@ -3402,6 +3131,7 @@ def validate_final_report_data(
3402
3131
  data, report_path, project_root, failures
3403
3132
  )
3404
3133
  failures.extend(validate_technical_verification_report(data, report_path, project_root or report_path.parent))
3134
+ failures.extend(technical_verification_routing_errors(data))
3405
3135
  if task_type == "implementation-option-selection":
3406
3136
  selection = data.get("implementationOptionSelection") or {}
3407
3137
  validation_root = project_root or report_path.parent
@@ -3645,1159 +3375,101 @@ def _unbridged_worker_finding_refs(data: dict) -> list[str]:
3645
3375
  ]
3646
3376
 
3647
3377
 
3648
- def _validate_implementation_planning_cross_project(data: dict, failures: list[str]) -> None:
3649
- """타 프로젝트 의존을 DM 행(`kind == 'cross-project'`)으로 선언했다면
3650
- `crossProjectDependencies` 에 `direction == 'upstream-precondition'` 행이
3651
- 반드시 있어야 한다 — 실제 선행 필수 의존이 soft `recommendedNextSteps`
3652
- 추천으로 새어나가지 못하게 강제한다. 각 XP 행의 필드 비-빈은 스키마가
3653
- 보장하고, 이 검사는 cross-project DM 신호가 있을 때 XP 행이 *존재*하는지를 본다.
3654
- """
3655
- ip = data.get("implementationPlanning")
3656
- if not isinstance(ip, dict):
3657
- return
3658
- dm_rows = ip.get("dependencyMigrationRisk") or []
3659
- has_cross_project_dm = any(
3660
- isinstance(r, dict) and r.get("kind") == "cross-project" for r in dm_rows
3661
- )
3662
- if not has_cross_project_dm:
3663
- return
3664
- xp_rows = ip.get("crossProjectDependencies") or []
3665
- has_upstream = any(
3666
- isinstance(r, dict) and r.get("direction") == "upstream-precondition"
3667
- for r in xp_rows
3668
- )
3669
- if not has_upstream:
3670
- failures.append(
3671
- "final-report data.json: a dependencyMigrationRisk row has "
3672
- "kind='cross-project' but crossProjectDependencies carries no "
3673
- "direction='upstream-precondition' entry. Record the cross-project "
3674
- "dependency as a mandatory precondition (concrete requiredWork / "
3675
- "verificationSignal / howToStart) — not a soft Recommended Next Step."
3676
- )
3677
-
3678
3378
 
3679
- def _validate_implementation_planning_decision_drafts(data: dict, failures: list[str]) -> None:
3680
- """`decisionDrafts` 가 비어있지 않으면 어느 stage 의 stepwiseExecution step 이
3681
- `.okstra/decisions/` 파일을 생성하는 materialization step 을 포함해야 한다.
3682
- 프로파일 Decision-record evaluation 의 "approved plan stepwise MUST include
3683
- Create .okstra/decisions/<NNNN>-<slug>.md" 를 실제 강제한다. draft 존재 자체가
3684
- trigger 이므로 self-contained 하다.
3685
- """
3686
- ip = data.get("implementationPlanning")
3687
- if not isinstance(ip, dict):
3688
- return
3689
- if not (ip.get("decisionDrafts") or []):
3690
- return
3691
- has_materialization = any(
3692
- ".okstra/decisions/" in (step.get("files") or "")
3693
- or ".okstra/decisions/" in (step.get("action") or "")
3694
- for stage in (ip.get("stages") or [])
3695
- if isinstance(stage, dict)
3696
- for step in (stage.get("stepwiseExecution") or [])
3697
- if isinstance(step, dict)
3698
- )
3699
- if not has_materialization:
3700
- failures.append(
3701
- "final-report data.json: implementationPlanning.decisionDrafts is "
3702
- "non-empty but no stage's stepwiseExecution creates a "
3703
- "`.okstra/decisions/<NNNN>-<slug>.md` file. The approved plan must "
3704
- "materialize each decision draft via a stepwise step (profile "
3705
- "Decision-record evaluation)."
3706
- )
3707
3379
 
3380
+ _CLARIFICATION_OPTION_SCHEMA_VERSIONS = frozenset({"2.0", "3.0"})
3381
+ # The four profiles that read `_clarification-recommendation.md`. This gate is
3382
+ # called phase-agnostically, so the task-type filter here is the only thing
3383
+ # scoping it.
3384
+ _CLARIFICATION_OPTION_TASK_TYPES = frozenset({
3385
+ "error-analysis",
3386
+ "implementation-planning",
3387
+ "improvement-discovery",
3388
+ "requirements-discovery",
3389
+ })
3390
+ # `in-repo` and `cross-repo` answer the same question, so exactly one may hold.
3391
+ _REACH_TOKENS = frozenset({"in-repo", "cross-repo"})
3392
+ _LEGACY_EXPECTED_FORM_RE = re.compile(r"\b(?:Recommended|Alternatives):")
3708
3393
 
3709
- # Plan-body gate outcomes ranked by how favorable each is to approval.
3710
- # A higher rank claims a healthier verification result. The recompute check
3711
- # below fails only when the *declared* gate outranks what the recorded
3712
- # per-worker verdicts support — i.e. the lead claimed a better outcome than
3713
- # the votes justify. A lead writing a conservatively *worse* gate is allowed,
3714
- # so genuine edge cases in this recompute never manufacture false failures.
3715
- _PLAN_GATE_RANK = {
3716
- "aborted-non-result": 0,
3717
- "blocked-by-disagreement": 0,
3718
- "passed-with-dissent": 1,
3719
- "passed": 2,
3720
- }
3721
-
3722
- # Breakage kinds where a single DISAGREE blocks the gate on its own (no majority
3723
- # needed), because the defect is concrete, safety-critical, and adversarially
3724
- # verifiable: `a` = cited path/symbol mismatch. `b`/`c`/`e` still need a
3725
- # majority — `b` in particular is prone to planning-vs-implementation
3726
- # environment false positives.
3727
- _SINGLE_VOTE_BLOCKING_KINDS = {"a"}
3728
-
3729
- # Rollback ordering (`d`) is executed by a human, not by okstra's workers or
3730
- # verifiers, so a rollback-ordering dissent is recorded but never gates
3731
- # approval: it is dropped from the blocking-disagree tally entirely, so an item
3732
- # whose only DISAGREEs are advisory-only can never rise above `has-dissent`.
3733
- _ADVISORY_ONLY_KINDS = {"d"}
3734
-
3735
- # Stop reasons that justify promoting a still-broken planner-fixable item to the
3736
- # user: the self-fix budget ran out, or a round produced no net resolution so
3737
- # further rounds would repeat themselves.
3738
- #
3739
- # `cause-group-recurrence` is the legacy spelling of that same exhaustion
3740
- # (plan-body-verification.md "Loop termination"). A pre-activity-contract report
3741
- # carrying it is a report whose loop stopped because the cause kept recurring —
3742
- # refusing it here left such a run with no exit at all: the loop may not run
3743
- # again, and the surviving item may not be promoted either.
3744
- _SELF_FIX_EXHAUSTED_REASONS = frozenset(
3745
- {"max-rounds-reached", "no-progress", "cause-group-recurrence"}
3746
- )
3747
3394
 
3748
3395
 
3749
- def _is_variation_point_item(item: dict) -> bool:
3750
- """Whether this is a `P-Var-*` variation-point item, which is majority-gated
3751
- (`prompts/lead/plan-body-verification.md` "`P-Var-<N>` … is majority-gated").
3752
- Whether a behavior has two implementations, and whether the plan extracted the
3753
- right interface for it, is a design judgement — it lacks the concrete certainty
3754
- of kind `a`, where a verifier points at two spelled-out references that
3755
- contradict each other. So kind `a` carries no extra weight on a P-Var item: it
3756
- neither single-vote-blocks nor counts as correctness-critical, exactly like the
3757
- `b` / `c` / `e` kinds the prompt routes P-Var defects to. Only a
3758
- `majority-disagree` gates it — that part is unchanged.
3759
- """
3760
- return str(item.get("id") or "").upper().startswith("P-VAR")
3761
3396
 
3762
3397
 
3763
- def _single_vote_dissents(item: dict, kinds: set[str]) -> list[dict]:
3764
- """이 항목에서 1표 차단을 주장하는 DISAGREE 행들."""
3765
- return [
3766
- row for row in (item.get("verdicts") or [])
3767
- if isinstance(row, dict)
3768
- and str(row.get("verdict") or "").strip().upper() == "DISAGREE"
3769
- and str(row.get("breakageKind") or "").strip().lower() in kinds
3770
- ]
3771
3398
 
3772
3399
 
3773
- def _single_vote_block_survives(item: dict, kinds: set[str]) -> bool:
3774
- """1표 차단이 성립하는지.
3775
3400
 
3776
- 1표 차단에는 근거가 있다 — 명시된 두 인용이 서로 모순이라는 것은 한 명이
3777
- 실측으로 확정할 수 있는 사실이고, 사실을 다수결로 기각하면 안 된다. 문제는
3778
- 1표라는 것이 아니라 **1표에 재현 요구가 없었다**는 것이다. "이 경로는 존재하지
3779
- 않는다" 라고 쓰기만 하면 그대로 차단이 됐다.
3780
3401
 
3781
- 이제 주장이 스스로 `fact` 를 선언하고 okstra 가 그것을 재현했을 때만 1표로
3782
- 막는다. 선언했는데 재현되지 않았거나 `judgement` 였다면 정족수로 내려간다.
3783
3402
 
3784
- 아무 행도 `claimKind` 를 선언하지 않았으면 종전대로 막는다. 그 필드를 실을 수
3785
- 없던 시절의 판정을 뒤에서 뒤집지 않기 위해서다 — 도입은 완화 방향으로만
3786
- 작동하고, 선언한 주장만 재현을 요구받는다.
3787
- """
3788
- dissents = _single_vote_dissents(item, kinds)
3789
- declared = [row for row in dissents if row.get("claimKind")]
3790
- if not declared:
3791
- return bool(dissents)
3792
- return any(
3793
- str(row.get("claimKind") or "") == "fact"
3794
- and str(row.get("reproductionResult") or "") == "reproduced"
3795
- for row in declared
3796
- )
3797
3403
 
3798
3404
 
3799
- def _critic_non_error_verdicts(item: dict) -> list[dict]:
3800
- """현재 기록된 비판 검토자의 최신 유효 판정."""
3801
- rows = [row for row in item.get("verdicts", []) if isinstance(row, dict)]
3802
- critic = [row for row in rows
3803
- if is_critic_worker(row.get("worker", ""))
3804
- and str(row.get("verdict", "")).upper() in {"AGREE", "SUPPLEMENT", "DISAGREE"}]
3805
- latest = max((row.get("round", 1) for row in critic), default=0)
3806
- return [row for row in critic if row.get("round", 1) == latest]
3807
3405
 
3808
3406
 
3809
- def _critic_gate_class(item: dict) -> str | None:
3810
- """비판 검토자의 교정 권한은 분석자의 표수나 동수 여부에 의존하지 않는다."""
3811
- critic = _critic_non_error_verdicts(item)
3812
- if not critic:
3813
- return None
3814
- critic_dissent = [row for row in critic if str(row.get("verdict", "")).upper() == "DISAGREE"]
3815
- if critic_dissent:
3816
- if str(item.get("id", "")).upper().startswith("P-RB"):
3817
- return "has-dissent"
3818
- return "majority-disagree" if any(
3819
- str(row.get("breakageKind", "")).lower() not in _ADVISORY_ONLY_KINDS
3820
- for row in critic_dissent
3821
- ) else "has-dissent"
3822
- dissent = any(str(row.get("verdict", "")).upper() == "DISAGREE" for row in item.get("verdicts", []))
3823
- return "has-dissent" if dissent else "full-consensus"
3824
-
3825
-
3826
- def _classify_plan_item_gate(item: dict) -> str:
3827
- """Recompute one plan item's gate class from its per-worker verdicts,
3828
- per `prompts/lead/plan-body-verification.md` "Round protocol". Returns one of
3829
- ``majority-disagree`` / ``needs-reverify`` / ``has-dissent`` /
3830
- ``full-consensus`` / ``all-non-result``. Blocking-kind minority dissent
3831
- (``dissent-isolated`` / ``partial-consensus`` on ``b``/``c``/``e``) is
3832
- ``majority-disagree`` so the user gate sees it. ``has-dissent`` remains
3833
- advisory-only, rollback items, and a single-vote kind that lost its
3834
- reproduction. An analyser 1-1 is ``needs-reverify`` until ``critic-worker``
3835
- settles it.
3836
- """
3837
- corrected = _critic_gate_class(item)
3838
- if corrected is not None:
3839
- return corrected
3840
- tokens = [
3841
- (
3842
- str(v.get("verdict") or "").strip().upper(),
3843
- str(v.get("breakageKind") or "").strip().lower(),
3844
- )
3845
- for v in (item.get("verdicts") or [])
3846
- if isinstance(v, dict)
3847
- and not is_critic_worker(str(v.get("worker") or ""))
3848
- ]
3849
- non_error = [(vd, bk) for (vd, bk) in tokens if vd and vd != "VERIFICATION-ERROR"]
3850
- if not non_error:
3851
- return "all-non-result"
3852
- disagree = [(vd, bk) for (vd, bk) in non_error if vd == "DISAGREE"]
3853
- agree = [(vd, bk) for (vd, bk) in non_error if vd in ("AGREE", "SUPPLEMENT")]
3854
- if not disagree:
3855
- return "full-consensus"
3856
- # Rollback is a human-run operation, so rollback dissent never blocks the
3857
- # gate — closed from two angles so a verifier cannot re-block it by relabelling:
3858
- # (1) a whole rollback plan item (`P-Rb-*`) is advisory regardless of
3859
- # breakage kind — otherwise a `DISAGREE(b)` "rollback command is
3860
- # ambiguous" would sail past the kind-`d` exemption and block;
3861
- # (2) a rollback-ordering dissent (`d`) is advisory on ANY item, since a
3862
- # rollback-order defect raised against a non-rollback item is still a
3863
- # human-run concern.
3864
- # Both are recorded as dissent and fold into `has-dissent`, never blocking.
3865
- if str(item.get("id") or "").upper().startswith("P-RB"):
3866
- return "has-dissent"
3867
- blocking_disagree = [(vd, bk) for (vd, bk) in disagree if bk not in _ADVISORY_ONLY_KINDS]
3868
- if not blocking_disagree:
3869
- return "has-dissent"
3870
- blocking_kinds = {bk for (_vd, bk) in blocking_disagree if bk}
3871
- # Single-vote-blocking kinds: one confirmed DISAGREE on a concrete,
3872
- # safety-critical, adversarially-verifiable defect is enough to block, even
3873
- # in a two-worker roster — a lone correct dissent must not be outvoted here.
3874
- # `a` for any item except `P-Var-*` (majority-gated, see
3875
- # `_is_variation_point_item`); `f` only for P-Req items (requirement coverage).
3876
- is_req = str(item.get("id") or "").upper().startswith("P-REQ")
3877
- single_vote_kinds = set(_SINGLE_VOTE_BLOCKING_KINDS) | ({"f"} if is_req else set())
3878
- if (
3879
- not _is_variation_point_item(item)
3880
- and blocking_kinds & single_vote_kinds
3881
- and _single_vote_block_survives(item, single_vote_kinds)
3882
- ):
3883
- # "One confirmed DISAGREE" presupposes the item was actually
3884
- # cross-verified. When the peer returned a non-result nothing confirmed
3885
- # the dissent, so blocking here would reproduce the same
3886
- # worker-failure-makes-the-gate-stricter paradox the majority branch
3887
- # below guards against. Route it to a re-verify round instead.
3888
- if len(non_error) < 2:
3889
- return "needs-reverify"
3890
- return "majority-disagree"
3891
- # Otherwise a genuine majority is required — and a majority needs at least
3892
- # two participating votes, so a lone surviving DISAGREE (its peer returned a
3893
- # non-result) does NOT block. That fixes the paradox where a worker failure
3894
- # made the gate stricter than a healthy roster would.
3895
- if len(non_error) >= 2 and len(blocking_disagree) > len(agree):
3896
- return "majority-disagree"
3897
- if len(blocking_disagree) == len(agree) and len(non_error) >= 2:
3898
- return "needs-reverify"
3899
- if (
3900
- len(non_error) >= 2
3901
- and blocking_disagree
3902
- and (
3903
- not (blocking_kinds & single_vote_kinds)
3904
- or _is_variation_point_item(item)
3905
- )
3906
- ):
3907
- # 판단 종류의 소수 반대는 표로 기각하지 않는다. 양쪽이 표를 냈으면
3908
- # 사용자가 고른다. 재현에 실패한 1표 종류 `a`/`f` 는 위에서 이미
3909
- # 근거를 잃었으므로 이 분기에 안 들어온다.
3910
- return "majority-disagree"
3911
- return "has-dissent"
3912
3407
 
3913
3408
 
3914
- def _is_even_analyser_split(item: dict) -> bool:
3915
- tokens = [
3916
- str(row.get("verdict") or "").strip().upper()
3917
- for row in (item.get("verdicts") or [])
3918
- if isinstance(row, dict)
3919
- and not is_critic_worker(str(row.get("worker") or ""))
3920
- and str(row.get("verdict") or "").strip().upper()
3921
- not in ("", "VERIFICATION-ERROR")
3922
- ]
3923
- if len(tokens) < 2:
3924
- return False
3925
- disagree = sum(1 for token in tokens if token == "DISAGREE")
3926
- agree = sum(1 for token in tokens if token in {"AGREE", "SUPPLEMENT"})
3927
- return disagree == agree and disagree > 0
3928
3409
 
3929
3410
 
3930
- def _is_unsettled_tie(item: dict) -> bool:
3931
- """분석자는 갈렸고 critic 표가 아직 없는 동수 항목."""
3932
- return _is_even_analyser_split(item) and not _critic_non_error_verdicts(item)
3933
3411
 
3934
3412
 
3935
- def _disagree_breakage_kinds(item: dict) -> set[str]:
3936
- return {
3937
- str(v.get("breakageKind") or "").strip().lower()
3938
- for v in (item.get("verdicts") or [])
3939
- if isinstance(v, dict)
3940
- and str(v.get("verdict") or "").strip().upper() == "DISAGREE"
3941
- and str(v.get("breakageKind") or "").strip()
3942
- }
3943
3413
 
3944
3414
 
3945
- def _has_planner_fixable_majority(item: dict) -> bool:
3946
- disagrees = [
3947
- v
3948
- for v in (item.get("verdicts") or [])
3949
- if isinstance(v, dict) and str(v.get("verdict") or "").upper() == "DISAGREE"
3950
- ]
3951
- fixable = [v for v in disagrees if v.get("fixability") == "planner-fixable"]
3952
- return bool(disagrees) and len(fixable) * 2 > len(disagrees)
3953
-
3954
-
3955
- def _is_correctness_critical(item: dict) -> bool:
3956
- """Whether this item's defect would make `implementation` produce wrong or
3957
- unsafe code — the single-vote-blocking kind `a` (cited path/symbol mismatch)
3958
- on any item but `P-Var-*`, or `f` (requirement-coverage mismatch) on a
3959
- `P-Req-*` item.
3960
- Kinds `b`/`c`/`e` are plan-prose defects: they degrade the document, not the
3961
- resulting code. Rollback ordering (`d`) is advisory — a human runs the
3962
- rollback — so it never counts as correctness-critical. A `P-Var-*` item is
3963
- majority-gated end to end, so a kind-`a` dissent on one is no more critical
3964
- than the `b`/`e` its defect should have been raised under; otherwise the same
3965
- mis-tag that no longer single-vote-blocks would still veto the downgrade.
3966
- """
3967
- if _is_variation_point_item(item):
3968
- return False
3969
- kinds = _disagree_breakage_kinds(item)
3970
- is_req = str(item.get("id") or "").upper().startswith("P-REQ")
3971
- return bool(kinds & _SINGLE_VOTE_BLOCKING_KINDS) or (is_req and "f" in kinds)
3972
3415
 
3973
3416
 
3974
- def _self_fix_budget_exhausted(pbv: dict) -> bool:
3975
- rounds_applied = pbv.get("selfFixRoundsApplied")
3976
- return (
3977
- isinstance(rounds_applied, int)
3978
- and rounds_applied >= 1
3979
- and pbv.get("selfFixStopReason") in _SELF_FIX_EXHAUSTED_REASONS
3980
- )
3981
3417
 
3982
3418
 
3983
- def _state_classification(item: dict, gate_class: str) -> str:
3984
- """This item's `planItems[].rounds[].classification` for the state file.
3985
3419
 
3986
- Blocking-kind `dissent-isolated` / `partial-consensus` is already
3987
- `majority-disagree` at the gate. `has-dissent` that remains is advisory
3988
- or a single-vote kind that lost reproduction; the state file then splits
3989
- that remainder into `dissent-isolated` vs `partial-consensus`.
3990
3420
 
3991
- *gate_class* is passed in rather than recomputed so that the caller's
3992
- effective classification — which may have been downgraded by
3993
- `_is_dissent_downgraded` — is the one this translates.
3994
3421
 
3995
- `contested` never appears: it is only meaningful at `maxRounds > 1`, and at
3996
- the default `maxRounds=1` the round protocol folds any otherwise-unresolved
3997
- item into `partial-consensus`.
3998
- """
3999
- if gate_class == "all-non-result":
4000
- # No non-error vote at all is the `needs-reverify` shape taken to its
4001
- # limit — "fewer than 2 participating votes" covers zero.
4002
- return "needs-reverify"
4003
- if gate_class != "has-dissent":
4004
- return gate_class
4005
- dissenting = sum(
4006
- 1
4007
- for vote in (item.get("verdicts") or [])
4008
- if isinstance(vote, dict)
4009
- and str(vote.get("verdict") or "").strip().upper() == "DISAGREE"
4010
- )
4011
- return "dissent-isolated" if dissenting == 1 else "partial-consensus"
4012
3422
 
4013
3423
 
4014
- def _clarification_ids_on_activity(activity: dict) -> set[str]:
4015
- refs: set[str] = set()
4016
- for key in ("clarificationRefs", "evidenceRefs"):
4017
- for value in activity.get(key) or []:
4018
- if isinstance(value, str) and _APPROVAL_CLARIFICATION_ID_RE.fullmatch(value):
4019
- refs.add(value)
4020
- return refs
4021
3424
 
3425
+ def _project_root_from_report(report_path: Path) -> Path:
3426
+ """Walk out of `<project>/.okstra/tasks/.../reports/` to the project root."""
3427
+ for parent in report_path.parents:
3428
+ if parent.name == ".okstra":
3429
+ return parent.parent
3430
+ return report_path.parent
4022
3431
 
4023
- def _plan_item_ids_for_clarification(
4024
- row: dict, context: dict, data: dict,
4025
- ) -> list[str]:
4026
- """이 C 행이 가리키는 계획 항목.
4027
3432
 
4028
- 계약 3.0 `approvalContext` 는 `planItemIds` 를 갖지 않는다. 활동
4029
- `evidenceRefs` / `clarificationRefs` 와 `planItems[].clarificationRefs` 가
4030
- 역추적이다. 이 C 만 인용한 활동을 묶음 활동보다 앞세운다.
4031
- """
4032
- linked = [
4033
- item_id
4034
- for item_id in (context.get("planItemIds") or [])
4035
- if isinstance(item_id, str) and item_id
4036
- ]
4037
- if linked:
4038
- return linked
4039
- row_id = str(row.get("id") or "")
4040
- if not row_id:
4041
- return []
4042
- singleton: list[str] = []
4043
- bulk: list[str] = []
4044
- for activity in data.get("agentActivity") or []:
4045
- if not isinstance(activity, dict):
4046
- continue
4047
- refs = _clarification_ids_on_activity(activity)
4048
- if row_id not in refs:
4049
- continue
4050
- ids = [
4051
- item_id
4052
- for item_id in (activity.get("planItemIds") or [])
4053
- if isinstance(item_id, str) and item_id
4054
- ]
4055
- if refs == {row_id}:
4056
- singleton.extend(ids)
4057
- else:
4058
- bulk.extend(ids)
4059
- if singleton or bulk:
4060
- return singleton or bulk
4061
- items = (
4062
- ((data.get("implementationPlanning") or {}).get("planBodyVerification")
4063
- or {}).get("planItems") or []
4064
- )
4065
- return [
4066
- str(item.get("id") or "")
4067
- for item in items
4068
- if isinstance(item, dict)
4069
- and row_id in {
4070
- ref for ref in (item.get("clarificationRefs") or [])
4071
- if isinstance(ref, str)
4072
- }
4073
- and item.get("id")
4074
- ]
4075
3433
 
4076
3434
 
4077
- def _user_accepted_plan_item_ids(data: dict) -> set[str]:
4078
- """사용자가 진행 처분을 고른 승인 행이 가리키는 계획 항목.
4079
3435
 
4080
- DISAGREE 표는 그대로 남는다. 게이트만 `has-dissent` 로 내린다.
4081
- """
4082
- accepted: set[str] = set()
4083
- for row in data.get("clarificationItems") or []:
4084
- if not isinstance(row, dict) or row.get("blocks") != "approval":
4085
- continue
4086
- if row_blocks_progress(
4087
- str(row.get("status") or ""), clarification_disposition(row)
4088
- ):
4089
- continue
4090
- context = row.get("approvalContext")
4091
- if not isinstance(context, dict):
4092
- context = {}
4093
- accepted.update(_plan_item_ids_for_clarification(row, context, data))
4094
- return accepted
4095
3436
 
4096
3437
 
4097
- def _resolved_noncritical_dissent_ids(data: dict) -> set[str]:
4098
- """호환 별칭. 새 코드는 `_user_accepted_plan_item_ids` 를 쓴다."""
4099
- return _user_accepted_plan_item_ids(data)
4100
3438
 
4101
3439
 
4102
- def _plan_item_decision_authority(item: dict, pbv: dict) -> str | None:
4103
- """자동 수정 이후의 설계 판단만 리드가 결정하며 사실·사용자 권한은 남긴다."""
4104
- classification = _classify_plan_item_gate(item)
4105
- votes = [row for row in item.get("verdicts", []) if isinstance(row, dict)]
4106
- non_result = any(row.get("verdict") not in {"AGREE", "SUPPLEMENT", "DISAGREE"} for row in votes)
4107
- if classification not in {"majority-disagree", "needs-reverify", "all-non-result"} and not non_result:
4108
- return None
4109
- if _stage_scope_bucket(item, pbv) != "in-scope" or item.get("block") == "record":
4110
- return None
4111
- disagrees = [row for row in votes if row.get("verdict") == "DISAGREE"]
4112
- verified = item.get("contentHash")
4113
- if (
4114
- not self_fix_rounds(pbv) or pbv.get("gating") is False
4115
- or not verified or item.get("verifiedContentHash") != verified
4116
- or _is_correctness_critical(item) or not disagrees
4117
- or len(voting_analyser_keys([item])) < 2
4118
- or non_result
4119
- or any(row.get("claimKind") not in {None, "judgement"}
4120
- or row.get("fixability") != "planner-fixable"
4121
- or row.get("breakageKind") not in {"b", "c", "e"} for row in disagrees)
4122
- ):
4123
- return "user"
4124
- return "lead"
4125
3440
 
4126
3441
 
4127
- def _lead_decision_applies(item: dict, pbv: dict) -> bool:
4128
- decision = item.get("leadDecision")
4129
- return (
4130
- isinstance(decision, dict)
4131
- and bool(str(decision.get("decision") or "").strip())
4132
- and decision.get("basisHash") == lead_decision_basis(item)
4133
- and _plan_item_decision_authority(item, pbv) == "lead"
4134
- )
4135
3442
 
4136
3443
 
4137
- def _is_dissent_downgraded(
4138
- item: dict,
4139
- pbv: dict,
4140
- accepted_item_ids: set[str],
4141
- ) -> bool:
4142
- """유효한 리드 결정 또는 사용자 진행 처분은 반대 표를 보존하며 차단을 해소한다."""
4143
- return _lead_decision_applies(item, pbv) or (
4144
- _classify_plan_item_gate(item) == "majority-disagree"
4145
- and str(item.get("id") or "") in accepted_item_ids
4146
- )
4147
3444
 
4148
3445
 
4149
- def _stage_scope_bucket(item: dict, pbv: dict) -> str:
4150
- """Whether this item has standing to block the stage about to start.
4151
3446
 
4152
- The plan covers every stage; implementation runs one at a time. Judging all
4153
- of them at once means a defect in a stage nobody has reached, or in one
4154
- already frozen, stops the next stage from starting — and a frozen stage's
4155
- item cannot be fixed at all, because the Stage Ledger forbids editing its
4156
- commands. Measured on one run, 9 of 13 blockers were that shape, 6 of them
4157
- frozen.
4158
3447
 
4159
- Returns `in-scope` (may block), `observed` (only frozen stages), or
4160
- `deferred` (only stages not yet startable). Anything unresolvable is
4161
- `in-scope`: an absent ledger is no basis to narrow. Plan-wide items
4162
- (`P-Opt-*`, `P-Var-*`, `P-Dep-*`, `P-Dir-1`) with no `stageScope` stay
4163
- in-scope. An unscoped `P-Val-*` / `P-Req-*` / `P-Rb-*` stays in-scope only
4164
- until a stage is `done`; after that it is `deferred` so a re-plan does not
4165
- re-score the whole checklist.
4166
3448
 
4167
- 디스패치 큐와 같은 함수를 쓴다. 검증기가 다른 통을 내면 워커가 안 본
4168
- 항목이 승인을 막거나, 본 항목이 게이트에서 빠진다.
4169
- """
4170
- ledger = pbv.get("stageLedger")
4171
- return _item_stage_scope_bucket(
4172
- item, ledger if isinstance(ledger, dict) else None,
4173
- )
4174
3449
 
3450
+ # 선언 `gateBlockedBy` 집합과 재계산 집합을 대조하던 갈래는 삭제했다.
3451
+ # 생산자(`okstra plan-items complete-round`)가 이 검증기의 계산 함수를
3452
+ # 그대로 import 해 필드를 쓰므로 값이 갈릴 자리가 없고, 그 필드를 읽어
3453
+ # 실행을 구동하는 소비자도 없다.
4175
3454
 
4176
- def _set_aside_reason(item: dict, pbv: dict, accepted_item_ids: set[str]) -> str | None:
4177
- """Why this item stopped blocking, or ``None`` if it never did.
4178
3455
 
4179
- A gate that passes while defects were set aside has to say which ones and on
4180
- what grounds. Without that the two halves of the acceptance condition — the
4181
- next stage can start, and the known risks are written down — collapse into
4182
- the first, and a defect deferred for a good reason is indistinguishable in
4183
- the record from one nobody found.
4184
- """
4185
- raw = (
4186
- "has-dissent"
4187
- if _is_dissent_downgraded(item, pbv, accepted_item_ids)
4188
- else _classify_plan_item_gate(item)
4189
- )
4190
- if raw != "majority-disagree":
4191
- return None
4192
- bucket = _stage_scope_bucket(item, pbv)
4193
- if bucket != "in-scope":
4194
- return bucket
4195
- return "record" if str(item.get("block") or "") == "record" else None
4196
-
4197
-
4198
- def _set_aside_register(pbv: dict, accepted_item_ids: set[str]) -> list[dict]:
4199
- """Every set-aside item, in id order, as the gate records them."""
4200
- register = [
4201
- {"id": str(item.get("id") or ""), "reason": reason}
4202
- for item in (pbv.get("planItems") or [])
4203
- if isinstance(item, dict)
4204
- for reason in [_set_aside_reason(item, pbv, accepted_item_ids)]
4205
- if reason is not None
4206
- ]
4207
- return sorted(register, key=lambda row: row["id"])
3456
+ _CLARIFICATION_OPTION_SCHEMA_VERSIONS = frozenset({"2.0", "3.0"})
3457
+ # The four profiles that read `_clarification-recommendation.md`. This gate is
3458
+ # called phase-agnostically, so the task-type filter here is the only thing
3459
+ # scoping it.
3460
+ _CLARIFICATION_OPTION_TASK_TYPES = frozenset({
3461
+ "error-analysis",
3462
+ "implementation-planning",
3463
+ "improvement-discovery",
3464
+ "requirements-discovery",
3465
+ })
3466
+ # `in-repo` and `cross-repo` answer the same question, so exactly one may hold.
3467
+ _REACH_TOKENS = frozenset({"in-repo", "cross-repo"})
3468
+ _LEGACY_EXPECTED_FORM_RE = re.compile(r"\b(?:Recommended|Alternatives):")
4208
3469
 
4209
3470
 
4210
- def _plan_item_gate_class(
4211
- item: dict, pbv: dict, accepted_item_ids: set[str],
4212
- ) -> str:
4213
- """The gate class for one item, after stage scope is applied.
4214
-
4215
- An out-of-scope blocker is not dropped — it lands on `has-dissent`, so the
4216
- gate still reads `passed-with-dissent` rather than `passed` and the record
4217
- says something is outstanding. Silently scoring it `passed` would hide the
4218
- defect instead of deferring it.
4219
- """
4220
- classification = (
4221
- "has-dissent"
4222
- if _is_dissent_downgraded(item, pbv, accepted_item_ids)
4223
- else _classify_plan_item_gate(item)
4224
- )
4225
- if classification != "majority-disagree":
4226
- return classification
4227
- if _stage_scope_bucket(item, pbv) != "in-scope":
4228
- return "has-dissent"
4229
- if str(item.get("block") or "") == "record":
4230
- # 자기 기록의 부정확은 기록되고 다음 run 의 입력이 되지, 구현 착수를 막지
4231
- # 않는다. 요구사항이 실제로 안 만들어지는 경우는 이 경로가 아니라
4232
- # `_independent_coverage_blockers` 의 `coverage-gap` 이 계속 막는다.
4233
- return "has-dissent"
4234
- return classification
4235
-
4236
-
4237
- def _recompute_plan_body_gate(
4238
- pbv: dict,
4239
- accepted_item_ids: set[str] | None = None,
4240
- ) -> str | None:
4241
- """Recompute the whole §5.5.9 gate value from ``planItems[].verdicts``.
4242
- Returns a value in ``PLAN_VERIFY_GATE_VALUES`` or ``None`` when there are
4243
- no plan items to judge (disabled / empty round)."""
4244
- accepted = accepted_item_ids or set()
4245
- classes = [
4246
- _plan_item_gate_class(it, pbv, accepted)
4247
- for it in (pbv.get("planItems") or [])
4248
- if isinstance(it, dict)
4249
- and (
4250
- _stage_scope_bucket(it, pbv) == "in-scope"
4251
- or it.get("verdicts")
4252
- )
4253
- ]
4254
- if not classes:
4255
- return None
4256
- if all(c == "all-non-result" for c in classes):
4257
- return "aborted-non-result"
4258
- if pbv.get("gating") is False and not requires_plan_repair(pbv):
4259
- if any(c in ("majority-disagree", "has-dissent", "needs-reverify", "all-non-result") for c in classes):
4260
- return "passed-with-dissent"
4261
- return "passed"
4262
- if any(c == "majority-disagree" for c in classes):
4263
- return "blocked-by-disagreement"
4264
- if any(c in ("has-dissent", "needs-reverify", "all-non-result") for c in classes):
4265
- # `all-non-result` belongs here for the same reason `needs-reverify`
4266
- # does — it IS that shape with zero participating votes instead of one
4267
- # (`_state_classification` maps it there, and the contract's step 5
4268
- # lists `needs-reverify` under `passed-with-dissent`). Left out, an
4269
- # item no verifier could judge scored `passed`: the all-error case
4270
- # already reads `needs-reverify` in the state file while the gate it
4271
- # feeds says every item reached consensus.
4272
- return "passed-with-dissent"
4273
- return "passed"
4274
-
4275
-
4276
- def _validate_plan_body_gate_recompute(
4277
- data: dict,
4278
- failures: list[str],
4279
- accepted_item_ids: set[str] | None = None,
4280
- ) -> None:
4281
- """H1 — the declared `Gate result` must not claim a healthier outcome than
4282
- the recorded per-worker verdicts support. Closes the forgery hole where a
4283
- lead writes `gateResult: passed` while workers actually voted DISAGREE:
4284
- the verdicts live in `planItems[].verdicts`, so the gate is recomputable
4285
- and no longer depends on the lead's honesty alone.
4286
- """
4287
- ip = data.get("implementationPlanning")
4288
- if not isinstance(ip, dict):
4289
- return
4290
- pbv = ip.get("planBodyVerification")
4291
- if not isinstance(pbv, dict):
4292
- return
4293
- declared = str(pbv.get("gateResult") or "").strip().lower()
4294
- accepted = (
4295
- _resolved_noncritical_dissent_ids(data)
4296
- if accepted_item_ids is None
4297
- else accepted_item_ids
4298
- )
4299
- recomputed = _recompute_plan_body_gate(pbv, accepted)
4300
- if recomputed is None or declared not in _PLAN_GATE_RANK:
4301
- return
4302
- if _PLAN_GATE_RANK[declared] > _PLAN_GATE_RANK[recomputed]:
4303
- failures.append(
4304
- "final-report data.json: implementationPlanning.planBodyVerification "
4305
- f"`gateResult` is `{declared}` but the recorded planItems[].verdicts "
4306
- f"only support `{recomputed}` (a majority DISAGREE, a DISAGREE(f) on "
4307
- "a P-Req item, or all-non-result dispatches were recorded). The gate "
4308
- "value must honestly aggregate the worker votes — do not upgrade it "
4309
- "to unblock the run (plan-body-verification.md Round protocol)."
4310
- )
4311
-
4312
-
4313
- def _cited_clarification_id(row: dict) -> str | None:
4314
- """The `C-NNN` a coverage row's `status` / `approvalDisposition` cites."""
4315
- for field in ("status", "approvalDisposition"):
4316
- value = str(row.get(field) or "").strip()
4317
- if value.startswith("blocked "):
4318
- return value.split(" ", 1)[1].strip()
4319
- return None
4320
-
4321
-
4322
- def _blocks_approval(row: dict) -> bool:
4323
- """Whether one Requirement Coverage row blocks approval on its face, per
4324
- `prompts/profiles/implementation-planning.md` §"Requirement Coverage": a
4325
- `gap`, a plain `blocked C-NNN`, or a deviation whose approval disposition
4326
- is blocked.
4327
- """
4328
- status = str(row.get("status") or "").strip()
4329
- if status == "gap" or status.startswith("blocked C-"):
4330
- return True
4331
- disposition = str(row.get("approvalDisposition") or "").strip()
4332
- return status == "documented-deviation" and disposition.startswith("blocked C-")
4333
-
4334
-
4335
- def _plan_item_clarification_ids(item: object) -> set[str]:
4336
- """이 plan item 이 가리키는 `C-NNN` 들.
4337
-
4338
- 계약 v3 에서 리포트 정본의 이 링크는 복수형 `clarificationRefs[]` 다 —
4339
- `report_assembly` 가 활동 원장의 `clarificationRefs[]` + `planItemIds[]` 에서
4340
- 유도해 쓰고, v3.0 스키마의 `planItems[]` 는 `additionalProperties: false` 아래
4341
- 그 이름만 허용한다. 단수형 `clarificationId` 는 lead 가 쓰는 plan-body 상태
4342
- 파일과 v2 리포트에 남아 있으므로 읽을 때는 둘 다 받는다
4343
- (`incremental_scope` 가 이미 그렇게 한다).
4344
- """
4345
- if not isinstance(item, dict):
4346
- return set()
4347
- ids = {
4348
- str(ref).strip()
4349
- for ref in (item.get("clarificationRefs") or [])
4350
- if str(ref).strip()
4351
- }
4352
- single = item.get("clarificationId")
4353
- if isinstance(single, str) and single.strip():
4354
- ids.add(single.strip())
4355
- return ids
4356
-
4357
-
4358
- def _plan_body_promoted_clarification_ids(pbv: dict) -> set[str]:
4359
- """`C-NNN` ids this run's own plan-body round created by promoting a
4360
- majority-disagree item (step 8). Used to break the Requirement Coverage
4361
- ↔ Clarification cycle: a coverage row citing one of these echoes a blocker
4362
- the gate already counted, rather than contributing an independent one.
4363
- """
4364
- return {
4365
- clarification_id
4366
- for item in (pbv.get("planItems") or [])
4367
- for clarification_id in _plan_item_clarification_ids(item)
4368
- }
4369
-
4370
-
4371
- def _independent_coverage_blockers(ip: dict, pbv: dict) -> list[str]:
4372
- """Coverage rows that block the gate on their own — excluding rows whose
4373
- blocker is a `C-NNN` this same run's plan-body round promoted."""
4374
- promoted = _plan_body_promoted_clarification_ids(pbv)
4375
- return [
4376
- str(row.get("id") or "<unknown>")
4377
- for row in (ip.get("requirementCoverage") or [])
4378
- if isinstance(row, dict)
4379
- and _blocks_approval(row)
4380
- and _cited_clarification_id(row) not in promoted
4381
- ]
4382
-
4383
-
4384
- def _gate_blocking_causes(
4385
- pbv: dict,
4386
- coverage_blockers: list[str],
4387
- accepted_item_ids: set[str] | None = None,
4388
- ) -> set[str]:
4389
- """Which inputs actually block approval, as `gateBlockedBy` enum values."""
4390
- causes = set()
4391
- recomputed = _recompute_plan_body_gate(pbv, accepted_item_ids)
4392
- if pbv.get("gating") is False and not requires_plan_repair(pbv):
4393
- if recomputed == "aborted-non-result":
4394
- causes.add("non-result")
4395
- return causes
4396
- if recomputed == "blocked-by-disagreement":
4397
- causes.add("majority-disagree")
4398
- elif recomputed == "aborted-non-result":
4399
- causes.add("non-result")
4400
- if coverage_blockers:
4401
- causes.add("coverage-gap")
4402
- return causes
4403
-
4404
-
4405
- def _is_activity_contract_v1_planning(run_manifest: dict) -> bool:
4406
- return (
4407
- run_manifest.get("activityContractVersion") == 1
4408
- and run_manifest.get("taskType") == "implementation-planning"
4409
- )
4410
-
4411
-
4412
- _APPROVAL_CLARIFICATION_ID_RE = re.compile(r"^C-\d{3,}$")
4413
-
4414
-
4415
- def _validate_approval_context(
4416
- data: dict,
4417
- run_manifest: dict,
4418
- failures: list[str],
4419
- ) -> None:
4420
- """활동 계약 v1 계획 run 의 승인 검사를 v3 경로로 넘긴다.
4421
-
4422
- v2 전용 본문은 삭제했다. 같은 요구를 조립 시점의 `report_assembly.py` /
4423
- `approval_decisions.py` 가 이미 거부하고, 새 run 은 전부 schemaVersion 3.0
4424
- 이라 v2 갈래에 도달하는 값이 없었다.
4425
- """
4426
- if not _is_activity_contract_v1_planning(run_manifest):
4427
- return
4428
- if data.get("schemaVersion") == "3.0":
4429
- _validate_v3_approval_context(data, failures)
4430
-
4431
-
4432
- def _validate_v3_approval_context(data: dict, failures: list[str]) -> None:
4433
- """승인 플래그가 아직 진행을 막는 행과 공존하지 못하게 한다.
4434
-
4435
- backlinks / dispositions / resolution-link 대조 세 갈래는 뺐다. 셋 다
4436
- 조립(`report_assembly.py`, `approval_decisions.py`)이 같은 입력으로 만든
4437
- 값을 같은 식으로 되계산하는 항등식이라 조립을 우회하지 않는 한 걸릴 값이
4438
- 없다.
4439
- """
4440
- approved = (data.get("frontmatter") or {}).get("approved") is True
4441
- incorporated = incorporated_clarification_ids(data)
4442
- for row in data.get("clarificationItems") or []:
4443
- if not isinstance(row, dict) or row.get("blocks") != "approval":
4444
- continue
4445
- # `approvalContext` 없는 행을 건너뛰는 것은 이전과 같은 범위다. 여기서
4446
- # 범위를 넓히면 삭제한 세 갈래가 막던 행이 새 차단으로 되살아난다.
4447
- if not isinstance(row.get("approvalContext"), dict):
4448
- continue
4449
- row_id = str(row.get("id") or "")
4450
- if approved and row_blocks_progress(
4451
- str(row.get("status") or ""),
4452
- clarification_disposition(row),
4453
- incorporated=row_id in incorporated,
4454
- ):
4455
- failures.append(
4456
- f"final-report data.json: approval is true while clarification "
4457
- f"`{row.get('id')}` remains `{row.get('status')}`."
4458
- )
4459
-
4460
-
4461
- _CHECKLIST_REF_RE = re.compile(r"VC-\d+")
4462
-
4463
-
4464
- def _project_root_from_report(report_path: Path) -> Path:
4465
- """Walk out of `<project>/.okstra/tasks/.../reports/` to the project root."""
4466
- for parent in report_path.parents:
4467
- if parent.name == ".okstra":
4468
- return parent.parent
4469
- return report_path.parent
4470
-
4471
-
4472
- def _detect_missing_dependency_precondition(
4473
- data: dict, project_root: Path
4474
- ) -> list[str]:
4475
- """A stage that runs the toolchain must point at a declared precondition.
4476
-
4477
- The planning worktree installs no dependencies, so `yarn … test` cannot run
4478
- there. A plan that says nothing about it produces steps whose commands die
4479
- on `exit 127`, and the verification round then spends itself on a defect the
4480
- planner cannot fix by editing the plan.
4481
-
4482
- The shape checked is the one an observed self-fix loop arrived at after two
4483
- rounds: one `phase: pre` checklist item declaring the install, referenced by
4484
- every stage that needs it. Only the reference is machine-checked — whether
4485
- the cited item genuinely covers dependencies is a semantic judgement left to
4486
- the §5.5.9 round, the same boundary the scope-provenance gate draws.
4487
- """
4488
- planning = data.get("implementationPlanning")
4489
- if not isinstance(planning, dict):
4490
- return []
4491
- tokens = resolve_build_tool_tokens(project_root)
4492
- if not tokens:
4493
- return []
4494
-
4495
- checklist = {
4496
- str(row.get("id")): str(row.get("phase") or "")
4497
- for row in (planning.get("validationChecklist") or [])
4498
- if isinstance(row, dict) and row.get("id")
4499
- }
4500
-
4501
- warnings: list[str] = []
4502
- for stage in planning.get("stages") or []:
4503
- if not isinstance(stage, dict):
4504
- continue
4505
- commands = [
4506
- str(step.get("command") or "")
4507
- for step in (stage.get("stepwiseExecution") or [])
4508
- if isinstance(step, dict)
4509
- ]
4510
- if not any(command_invokes_build_tool(c, tokens=tokens) for c in commands):
4511
- continue
4512
- refs = _CHECKLIST_REF_RE.findall(str(stage.get("stageValidation") or ""))
4513
- if not refs:
4514
- warnings.append(
4515
- f"Stage {stage.get('stage')} runs the project toolchain but its "
4516
- "Stage Validation cites no `VC-NNN` precondition. The planning "
4517
- "worktree has no dependencies installed, so declare the install "
4518
- "once as a `phase: pre` Validation Checklist item and reference "
4519
- "it here."
4520
- )
4521
- continue
4522
- if not any(checklist.get(ref) == "pre" for ref in refs):
4523
- cited = ", ".join(sorted(set(refs)))
4524
- warnings.append(
4525
- f"Stage {stage.get('stage')} runs the project toolchain and cites "
4526
- f"{cited}, but none of those is a `phase: pre` Validation "
4527
- "Checklist item — a precondition verified after the fact is not a "
4528
- "precondition."
4529
- )
4530
- return warnings
4531
-
4532
-
4533
- _UNMAPPED_FALLBACK_REASON = "no impacted stages resolved"
4534
-
4535
-
4536
- def _prior_planning_data(report_path: Path) -> dict | None:
4537
- """The newest implementation-planning data.json preceding *report_path*."""
4538
- match = re.search(r"-(\d+)\.md$", report_path.name)
4539
- if not match:
4540
- return None
4541
- current_seq = int(match.group(1))
4542
- candidates = sorted(
4543
- (
4544
- path
4545
- for path in report_path.parent.glob(
4546
- "final-report-implementation-planning-*.data.json"
4547
- )
4548
- if (m := re.search(r"-(\d+)\.data\.json$", path.name))
4549
- and int(m.group(1)) < current_seq
4550
- ),
4551
- key=lambda p: p.name,
4552
- )
4553
- for path in reversed(candidates):
4554
- try:
4555
- payload = json.loads(path.read_text(encoding="utf-8"))
4556
- except (OSError, json.JSONDecodeError):
4557
- continue
4558
- if isinstance(payload, dict):
4559
- return payload
4560
- return None
4561
-
4562
-
4563
- def _detect_unmapped_incremental_fallback(
4564
- data: dict, report_path: Path
4565
- ) -> list[str]:
4566
- """A re-run that fell back to full while the prior report could have mapped it.
4567
-
4568
- Both "the answer restructures the plan" and "no stage could be resolved"
4569
- return `mode: full`, and only the second is a missed narrowing. The lead
4570
- declares the first through `--full-reason`, so the reason prefix separates
4571
- them; this reports the second only when the trace would have succeeded.
4572
-
4573
- Advisory: a lead that never passed `--full-reason` produces the fallback
4574
- reason for both cases, so failing here would punish runs written before the
4575
- flag existed.
4576
- """
4577
- planning = data.get("implementationPlanning")
4578
- if not isinstance(planning, dict):
4579
- return []
4580
- decision = planning.get("incrementalDecision")
4581
- if not isinstance(decision, dict) or decision.get("mode") != "full":
4582
- return []
4583
- if _UNMAPPED_FALLBACK_REASON not in str(decision.get("reason") or ""):
4584
- return []
4585
-
4586
- answered = _answered_clarification_ids(data)
4587
- if not answered:
4588
- return []
4589
- prior = _prior_planning_data(report_path)
4590
- if prior is None:
4591
- return []
4592
-
4593
- try:
4594
- from okstra_ctl.incremental_scope import clarification_impacted_stages
4595
-
4596
- stages = clarification_impacted_stages(prior, set(answered))
4597
- except (ImportError, ValueError):
4598
- return []
4599
- if not stages:
4600
- return []
4601
- return [
4602
- "incrementalDecision fell back to full for lack of a resolved stage, but "
4603
- f"the prior report maps {', '.join(sorted(answered))} to stage(s) "
4604
- f"{', '.join(str(s) for s in sorted(stages))}. Pass the answered ids "
4605
- "through `--answered-clarifications` so the re-run narrows, or declare "
4606
- "the structural change with `--full-reason` when full is the judgement."
4607
- ]
4608
-
4609
-
4610
- _SELF_FIX_NOTE_ROUND_RE = re.compile(r"self-fixed in round\s*(\d+)", re.IGNORECASE)
4611
-
4612
-
4613
- def _items_resolved_in_round(plan_items: object, round_number: int) -> set[str]:
4614
- resolved: set[str] = set()
4615
- for item in plan_items if isinstance(plan_items, list) else []:
4616
- if not isinstance(item, dict):
4617
- continue
4618
- match = _SELF_FIX_NOTE_ROUND_RE.search(str(item.get("selfFixNote") or ""))
4619
- if match and int(match.group(1)) == round_number:
4620
- resolved.add(str(item.get("id")))
4621
- return resolved
4622
-
4623
-
4624
- def _detect_self_fix_recurrence(pbv: dict) -> list[str]:
4625
- """Rounds that re-target ground the previous round already worked, unresolved.
4626
-
4627
- `no-progress` is judged at the round's end from what it resolved. Repeating
4628
- the previous round's *unresolved* remainder is the same conclusion reached
4629
- one dispatch earlier — the observed shape was two rounds spent on one
4630
- identical seven-item set. This names that shape so the loop can exit on it
4631
- rather than paying for the round that proves it.
4632
-
4633
- Advisory only. Narrowing onto what the last round genuinely left open is
4634
- legitimate progress, and the rule separating that from re-digging the same
4635
- hole is not settled (design D-1), so this reports rather than fails.
4636
- """
4637
- groups = pbv.get("selfFixGroups") if isinstance(pbv, dict) else None
4638
- if not isinstance(groups, list):
4639
- return []
4640
- by_round: dict[int, set[str]] = {}
4641
- for group in groups:
4642
- if not isinstance(group, dict) or not isinstance(group.get("round"), int):
4643
- continue
4644
- ids = {str(i) for i in group.get("itemIds") or []}
4645
- by_round.setdefault(group["round"], set()).update(ids)
4646
-
4647
- warnings: list[str] = []
4648
- plan_items = pbv.get("planItems")
4649
- for round_number in sorted(by_round)[1:]:
4650
- previous = by_round.get(round_number - 1)
4651
- if not previous:
4652
- continue
4653
- unresolved = previous - _items_resolved_in_round(plan_items, round_number - 1)
4654
- current = by_round[round_number]
4655
- if current and current <= unresolved:
4656
- warnings.append(
4657
- f"self-fix round {round_number} re-targets only items round "
4658
- f"{round_number - 1} left unresolved ({', '.join(sorted(current))}) "
4659
- "— the previous round's correction did not move this cause. "
4660
- "Consider exiting with `no-progress` instead of spending the "
4661
- "remaining budget on the same ground."
4662
- )
4663
- return warnings
4664
-
4665
-
4666
- def _validate_participating_analysers(data: dict, failures: list[str]) -> None:
4667
- """The gate's own arithmetic base, checked against the votes it ran on.
4668
-
4669
- A majority over two votes and a majority over three are different claims,
4670
- and a shrunken roster loosens the gate silently: with two analysers,
4671
- 1-AGREE/1-DISAGREE is a tie, so it never reaches `majority-disagree`. The
4672
- field only reports; the arithmetic is unchanged. It is recomputable from
4673
- the recorded verdicts, so a figure the table denies is a defect.
4674
-
4675
- 재계산은 `okstra_ctl.plan_items.voting_analyser_keys` 하나뿐이고, 기록하는
4676
- 쪽(`okstra plan-items complete-round`)도 같은 함수를 부른다. 두 곳이 각자
4677
- 세던 동안 생산자는 이번 라운드 큐만, 이쪽은 전 항목·전 라운드를 세서
4678
- critic 이 동수만 가른 라운드에서 값이 갈렸다.
4679
- """
4680
- pbv = ((data.get("implementationPlanning") or {}).get("planBodyVerification") or {})
4681
- declared = pbv.get("participatingAnalysers")
4682
- if not isinstance(declared, dict):
4683
- return
4684
-
4685
- rostered = declared.get("rostered")
4686
- voting = declared.get("voting")
4687
- if not isinstance(rostered, int) or not isinstance(voting, int):
4688
- failures.append(
4689
- "final-report data.json: planBodyVerification.participatingAnalysers "
4690
- "needs integer `rostered` and `voting`."
4691
- )
4692
- return
4693
- if voting > rostered:
4694
- failures.append(
4695
- "final-report data.json: planBodyVerification.participatingAnalysers "
4696
- f"claims {voting} voting of {rostered} rostered — more workers voted "
4697
- "than were on the roster."
4698
- )
4699
- return
4700
-
4701
- observed = voting_analyser_keys(pbv.get("planItems") or [])
4702
- if observed and voting != len(observed):
4703
- failures.append(
4704
- "final-report data.json: planBodyVerification.participatingAnalysers "
4705
- f"declares {voting} voting analyser(s) but the recorded verdicts carry "
4706
- f"{len(observed)} ({', '.join(sorted(observed))}). A worker whose "
4707
- "dispatch returned no result is excluded from the gate arithmetic and "
4708
- "must not be counted here either."
4709
- )
4710
-
4711
-
4712
- def _validate_gate_blocked_by(
4713
- data: dict,
4714
- failures: list[str],
4715
- accepted_item_ids: set[str] | None = None,
4716
- ) -> None:
4717
- """선언된 `gateResult` 가 실제로 남아 있는 차단 원인과 맞는지 본다.
4718
-
4719
- 승인을 막는 입력은 둘이다 — `majority-disagree` 플랜 항목, 그리고
4720
- Requirement Coverage 의 `gap` / `blocked C-NNN` 행. 두 갈래를 남긴다:
4721
- (a) 막는 원인이 있는데 `passed` 계열을 선언한 경우, (b) 막는 원인이 하나도
4722
- 없는데 차단 값을 그대로 둔 경우. 둘 다 실측 사고에서 나왔다 — (b) 는 gate
4723
- 토큰이 1라운드 값에 멈춰 프로젝트 전체에서 run 을 못 열게 만들었다.
4724
- """
4725
- ip = data.get("implementationPlanning")
4726
- if not isinstance(ip, dict):
4727
- return
4728
- pbv = ip.get("planBodyVerification")
4729
- if not isinstance(pbv, dict):
4730
- return
4731
- round_count = pbv.get("roundCount")
4732
- if not isinstance(round_count, int) or round_count < 1:
4733
- return
4734
-
4735
- declared_gate = str(pbv.get("gateResult") or "").strip().lower()
4736
- coverage_blockers = _independent_coverage_blockers(ip, pbv)
4737
- accepted = (
4738
- _resolved_noncritical_dissent_ids(data)
4739
- if accepted_item_ids is None
4740
- else accepted_item_ids
4741
- )
4742
- actual_causes = _gate_blocking_causes(pbv, coverage_blockers, accepted)
4743
-
4744
- if actual_causes and declared_gate in ("passed", "passed-with-dissent"):
4745
- failures.append(
4746
- "final-report data.json: implementationPlanning.planBodyVerification "
4747
- f"`gateResult` is `{declared_gate}` but "
4748
- f"{sorted(actual_causes)} blocks approval "
4749
- f"(coverage rows: {coverage_blockers or 'none'}). A Requirement "
4750
- "Coverage `gap` / `blocked C-NNN` row blocks the gate independently "
4751
- "of the worker verdicts (implementation-planning.md "
4752
- '§"Requirement Coverage").'
4753
- )
4754
- return
4755
-
4756
- if not actual_causes and _PLAN_GATE_RANK.get(declared_gate) == 0:
4757
- # A blocking value with nothing left blocking it. The two checks around
4758
- # this one both walk from a recorded cause outward, so a gate that
4759
- # simply stopped being updated fell between them: a self-fix loop
4760
- # resolved every majority-disagree item, `gateBlockedBy` emptied
4761
- # correctly, and the gate token stayed at its round-1 value. The plan
4762
- # was approvable and nothing said so — run-prep refused the approval,
4763
- # and the refusal propagated far enough to take the run wizard down
4764
- # with it, so no run could be started in that project at all. Rescoring
4765
- # with `okstra plan-verify` and recording what it returns is the fix;
4766
- # the round is not complete until that call agrees with the report.
4767
- failures.append(
4768
- "final-report data.json: implementationPlanning.planBodyVerification "
4769
- f"`gateResult` is `{declared_gate}` but nothing blocks approval — "
4770
- "no plan item is `majority-disagree`, no dispatch was a non-result, "
4771
- "and no Requirement Coverage row blocks independently. A gate that "
4772
- "withholds approval with no recorded cause is almost always a value "
4773
- "left behind by an earlier round: rescore with `okstra plan-verify` "
4774
- "and record its `gate.recomputed` "
4775
- '(plan-body-verification.md §"Round protocol" step 5).'
4776
- )
4777
-
4778
- # 선언 `gateBlockedBy` 집합과 재계산 집합을 대조하던 갈래는 삭제했다.
4779
- # 생산자(`okstra plan-items complete-round`)가 이 검증기의 계산 함수를
4780
- # 그대로 import 해 필드를 쓰므로 값이 갈릴 자리가 없고, 그 필드를 읽어
4781
- # 실행을 구동하는 소비자도 없다.
4782
-
4783
-
4784
- _CLARIFICATION_OPTION_SCHEMA_VERSIONS = frozenset({"2.0", "3.0"})
4785
- # The four profiles that read `_clarification-recommendation.md`. This gate is
4786
- # called phase-agnostically, so the task-type filter here is the only thing
4787
- # scoping it.
4788
- _CLARIFICATION_OPTION_TASK_TYPES = frozenset({
4789
- "error-analysis",
4790
- "implementation-planning",
4791
- "improvement-discovery",
4792
- "requirements-discovery",
4793
- })
4794
- # `in-repo` and `cross-repo` answer the same question, so exactly one may hold.
4795
- _REACH_TOKENS = frozenset({"in-repo", "cross-repo"})
4796
- _LEGACY_EXPECTED_FORM_RE = re.compile(r"\b(?:Recommended|Alternatives):")
4797
-
4798
-
4799
- def _validate_clarification_options(data: dict, failures: list[str]) -> None:
4800
- """A `decision` row must carry its choices as data, not as prose.
3471
+ def _validate_clarification_options(data: dict, failures: list[str]) -> None:
3472
+ """A `decision` row must carry its choices as data, not as prose.
4801
3473
 
4802
3474
  The choices used to live inside the `expectedForm` string, where two
4803
3475
  separate parsers split them differently and neither was checked — the board
@@ -4944,365 +3616,29 @@ def _validate_open_approval_blocker_provenance(
4944
3616
  )
4945
3617
 
4946
3618
 
4947
- def _has_clarification_backtrace(
4948
- row_id: str, plan_items: object, coverage: object
4949
- ) -> bool:
4950
- """Whether the plan records anything this clarification blocks.
4951
3619
 
4952
- Two link shapes, both authored by the same run: the `P-*` plan item that
4953
- carries the `clarificationId`, and the requirement-coverage row blocked on
4954
- the id. `incremental-scope` resolves impacted stages from exactly these
4955
- two, and the coverage side goes through its predicate so the gate and the
4956
- resolver cannot disagree about what counts as a link.
4957
- """
4958
- if isinstance(plan_items, list) and any(
4959
- row_id in _plan_item_clarification_ids(item) for item in plan_items
4960
- ):
4961
- return True
4962
- return isinstance(coverage, list) and any(
4963
- coverage_row_blocked_on(row, row_id) for row in coverage
4964
- )
4965
3620
 
4966
3621
 
4967
- def _validate_approval_clarification_backtrace(
4968
- data: dict, failures: list[str]
4969
- ) -> None:
4970
- """An approval blocker must record what it blocks.
4971
-
4972
- `_validate_plan_body_clarification_matching` already walks the other
4973
- direction — a majority-disagree plan item must cite a `blocks: approval`
4974
- row. Nothing walked this way, so a row could withhold approval while
4975
- recording no blast radius at all. The cost lands on the re-run:
4976
- `incremental-scope` resolves impacted stages from these links and will not
4977
- silently narrow past an id that traces to no stage, so this report fails
4978
- rather than forcing a full re-run.
4979
-
4980
- The link must also *resolve to a stage*, which is the thing the re-run
4981
- actually reads. Checking only that a link exists let a row satisfy this
4982
- gate while the next re-run still could not place the answer: `P-Req-*`
4983
- and `P-Val-*` ids are numbered by position in their own array, so they
4984
- carry no stage, and a blocked coverage row whose `coveredBy` is prose
4985
- cites none either.
4986
- """
4987
- if (data.get("header") or {}).get("taskType") != "implementation-planning":
4988
- return
4989
- planning = data.get("implementationPlanning")
4990
- if not isinstance(planning, dict):
4991
- return
4992
- coverage = planning.get("requirementCoverage")
4993
- verification = planning.get("planBodyVerification")
4994
- plan_items = (
4995
- verification.get("planItems") if isinstance(verification, dict) else None
4996
- )
4997
- for row in data.get("clarificationItems") or []:
4998
- if not isinstance(row, dict) or row.get("blocks") != "approval":
4999
- continue
5000
- if str(row.get("status") or "") in {"answered", "resolved"}:
5001
- continue
5002
- row_id = str(row.get("id") or "<unknown>")
5003
- if not _has_clarification_backtrace(row_id, plan_items, coverage):
5004
- failures.append(
5005
- f"final-report data.json: clarification `{row_id}` blocks approval "
5006
- "but has no back-trace into the plan — no plan item carries it as "
5007
- "`clarificationId`, and no requirement-coverage row is `blocked "
5008
- f"{row_id}` in its `status` or `approvalDisposition`. An item that "
5009
- "withholds approval without recording what it affects cannot "
5010
- "place the next re-run's scope; this report fails rather than "
5011
- "forcing a full re-run."
5012
- )
5013
- continue
5014
- if stages_for_clarification(data, row_id):
5015
- continue
5016
- failures.append(
5017
- f"final-report data.json: clarification `{row_id}` blocks approval "
5018
- "and is linked, but the link resolves to no stage. `incremental-"
5019
- "scope` reads the stage from a `P-Step-<stage>.<step>` / `P-Prep-"
5020
- "S<stage>-<kind>` plan-item id, from `stageScope` / `stageRefs` on "
5021
- "the linked plan item or coverage row, or from a `Stage N` citation "
5022
- f"in the blocked coverage row's `coveredBy`. A `P-Req-*` / `P-Val-*` "
5023
- "id carries no stage number, so a row linked only that way must "
5024
- "carry `stageRefs` or cite the stage in `coveredBy`. A blocker "
5025
- "whose blast radius resolves to no stage cannot auto-narrow the "
5026
- "next re-run; this report fails rather than forcing a full re-run."
5027
- )
5028
3622
 
5029
3623
 
5030
- _RERUN_FLAG = "--answered-clarifications"
5031
- _USER_RESPONSE_HINT = re.compile(r"okstra-user-response", re.IGNORECASE)
5032
- _APPROVE_HINT = re.compile(r"--approve|\bapprov", re.IGNORECASE)
5033
3624
 
5034
3625
 
5035
- def _next_step_texts(steps: object) -> list[str]:
5036
- """Every reader-visible string in `recommendedNextSteps`, prose and command."""
5037
- texts: list[str] = []
5038
- for step in steps if isinstance(steps, list) else []:
5039
- if not isinstance(step, dict):
5040
- continue
5041
- texts.append(str(step.get("text") or ""))
5042
- for command in step.get("commands") or []:
5043
- if isinstance(command, dict):
5044
- texts.append(str(command.get("claudeCode") or ""))
5045
- texts.append(str(command.get("terminal") or ""))
5046
- return texts
5047
-
5048
-
5049
- def _has_unresolved_approval_blocker(data: dict) -> bool:
5050
- return bool(
5051
- progress_blocking_ids(
5052
- data.get("clarificationItems"),
5053
- APPROVAL_BLOCKS,
5054
- report_data=data,
5055
- )
5056
- )
5057
3626
 
5058
3627
 
5059
- def _planning_gate_blocks_approval(data: dict) -> bool:
5060
- planning = data.get("implementationPlanning")
5061
- if not isinstance(planning, dict):
5062
- return False
5063
- verification = planning.get("planBodyVerification")
5064
- if not isinstance(verification, dict):
5065
- return False
5066
- gate = str(verification.get("gateResult") or "").strip().lower()
5067
- if gate == "aborted-non-result":
5068
- return True
5069
- if gate != "blocked-by-disagreement":
5070
- return False
5071
- return _has_unresolved_approval_blocker(data) or not any(
5072
- isinstance(row, dict) and row.get("blocks") == "approval"
5073
- for row in data.get("clarificationItems") or []
5074
- )
5075
3628
 
5076
3629
 
5077
- def _validate_rerun_guidance(data: dict, failures: list[str]) -> None:
5078
- """A report that withholds approval must say how to come back from it.
5079
3630
 
5080
- The way forward — answer the blockers, then re-run carrying those ids —
5081
- lived only in the lead prompt, which is read *after* the next run has
5082
- already started. The person who has to act reads the report instead, and it
5083
- told them nothing about the next command. Requiring the flag by name is a
5084
- low bar deliberately: it does not check that the rest of the step is right,
5085
- only that the report stops leaving the reader to work the mechanics out.
5086
- """
5087
- if (data.get("header") or {}).get("taskType") != "implementation-planning":
5088
- return
5089
- if not _has_unresolved_approval_blocker(data):
5090
- return
5091
- texts = _next_step_texts(data.get("recommendedNextSteps"))
5092
- if any(_RERUN_FLAG in text for text in texts) and any(
5093
- _USER_RESPONSE_HINT.search(text) for text in texts
5094
- ):
5095
- return
5096
- failures.append(
5097
- "final-report data.json: this plan withholds approval on an unresolved "
5098
- "`blocks: approval` clarification, but no `recommendedNextSteps` entry "
5099
- "tells the reader the command to run now — name `/okstra-user-response` "
5100
- f"and the `{_RERUN_FLAG}` re-run in a step's `text` or one of its "
5101
- "`commands`. `okstra recap assemble` prints the exact ids and flag "
5102
- "value once the answers are recorded."
5103
- )
5104
3631
 
5105
3632
 
5106
- def _validate_approval_guidance(data: dict, failures: list[str]) -> None:
5107
- """승인 가능한 plan-ready 는 사용자에게 승인하라고 말해야 한다.
5108
3633
 
5109
- 포인터가 implementation/ready 여도 승인은 사용자만 뒤집는다. 다음 단계
5110
- 안내가 계획 재실행이면 승인 칸을 건너뛰고 같은 단계를 다시 돈다.
5111
- """
5112
- if (data.get("header") or {}).get("taskType") != "implementation-planning":
5113
- return
5114
- planning = data.get("implementationPlanning")
5115
- if not isinstance(planning, dict) or planning.get("outcome") != "plan-ready":
5116
- return
5117
- if _has_unresolved_approval_blocker(data) or _planning_gate_blocks_approval(data):
5118
- return
5119
- if _report_already_approved(data):
5120
- return
5121
- if any(_APPROVE_HINT.search(text) for text in _next_step_texts(
5122
- data.get("recommendedNextSteps")
5123
- )):
5124
- return
5125
- failures.append(
5126
- "final-report data.json: this plan is ready for the user to approve, "
5127
- "but no `recommendedNextSteps` entry tells the reader to approve — "
5128
- "name `--approve` or the in-session wizard in a step's `text` or "
5129
- "one of its `commands`. Do not recommend another "
5130
- "implementation-planning run."
5131
- )
5132
3634
 
5133
3635
 
5134
- def _validate_self_fix_grouping(data: dict, failures: list[str]) -> None:
5135
- """A self-fix round must be instructed by cause, not as a flat item list.
5136
3636
 
5137
- Blocked items are usually several derivatives of one defect. Instructed
5138
- item-by-item, each patch corrects its own section and leaves the sibling
5139
- sections still asserting the old value, so the next round re-finds the same
5140
- family and the budget drains without converging. Recording the grouping
5141
- makes the lead commit to a diagnosis and makes a one-group-per-item
5142
- non-diagnosis visible in the artifact rather than invisible in a prompt.
5143
3637
 
5144
- Recording rounds here also ties `selfFixRoundsApplied` to work that exists
5145
- in the data: it was a free-floating self-reported integer, yet
5146
- `_validate_self_fix_before_clarification` gates promotion on its value.
5147
- """
5148
- ip = data.get("implementationPlanning")
5149
- if not isinstance(ip, dict):
5150
- return
5151
- pbv = ip.get("planBodyVerification")
5152
- if not isinstance(pbv, dict):
5153
- return
5154
- rounds_applied = pbv.get("selfFixRoundsApplied")
5155
- if not isinstance(rounds_applied, int) or rounds_applied < 1:
5156
- return
5157
-
5158
- groups = [g for g in (pbv.get("selfFixGroups") or []) if isinstance(g, dict)]
5159
- if not groups:
5160
- failures.append(
5161
- "final-report data.json: planBodyVerification declares "
5162
- f"`selfFixRoundsApplied`={rounds_applied} but records no "
5163
- "`selfFixGroups`. Each round's targets MUST be grouped by common "
5164
- "cause before being handed to report-writer — a flat item list "
5165
- "makes every patch leave its siblings' contradictions standing "
5166
- '(plan-body-verification.md §"Round protocol" step 7).'
5167
- )
5168
- return
5169
-
5170
- rounds = [g.get("round") for g in groups if isinstance(g.get("round"), int)]
5171
- if len(set(rounds)) > 1:
5172
- failures.append(
5173
- "final-report data.json: automatic self-fix is limited to one rewrite; "
5174
- "resolve remaining items through lead decisions or user confirmation."
5175
- )
5176
- if rounds and max(rounds) != rounds_applied:
5177
- failures.append(
5178
- "final-report data.json: planBodyVerification "
5179
- f"`selfFixRoundsApplied`={rounds_applied} does not match the highest "
5180
- f"round recorded in `selfFixGroups` ({max(rounds)}). The round count "
5181
- "must be derivable from recorded work, not asserted independently of "
5182
- "it — promotion eligibility is gated on this number."
5183
- )
5184
-
5185
- known_ids = {
5186
- str(item.get("id")).strip()
5187
- for item in (pbv.get("planItems") or [])
5188
- if isinstance(item, dict) and str(item.get("id") or "").strip()
5189
- }
5190
- grouped_ids = [
5191
- str(item_id).strip()
5192
- for group in groups
5193
- for item_id in (group.get("itemIds") or [])
5194
- if str(item_id or "").strip()
5195
- ]
5196
- unknown = sorted({i for i in grouped_ids if i not in known_ids})
5197
- if unknown:
5198
- failures.append(
5199
- "final-report data.json: planBodyVerification.selfFixGroups targets "
5200
- f"plan item(s) {unknown} that do not exist in `planItems`."
5201
- )
5202
-
5203
- fixed_ids = {
5204
- str(item.get("id")).strip()
5205
- for item in (pbv.get("planItems") or [])
5206
- if isinstance(item, dict)
5207
- and str(item.get("selfFixNote") or "").strip()
5208
- and str(item.get("id") or "").strip()
5209
- }
5210
- ungrouped = sorted(fixed_ids - set(grouped_ids))
5211
- if ungrouped:
5212
- failures.append(
5213
- "final-report data.json: plan item(s) "
5214
- f"{ungrouped} carry a `selfFixNote` but appear in no "
5215
- "`selfFixGroups` entry. Every item a round corrected must be "
5216
- "attributable to the cause group it was instructed under."
5217
- )
5218
-
5219
-
5220
- _ANSWERED_CLARIFICATION_STATUSES = frozenset({"answered", "resolved"})
5221
3638
 
5222
3639
 
5223
- def _answered_clarification_ids(data: dict) -> list[str]:
5224
- """Clarifications this run incorporated an answer for — the rows whose
5225
- answers can invalidate statements the previous run wrote."""
5226
- return [
5227
- str(row.get("id")).strip()
5228
- for row in (data.get("clarificationItems") or [])
5229
- if isinstance(row, dict)
5230
- and str(row.get("status") or "").strip() in _ANSWERED_CLARIFICATION_STATUSES
5231
- and str(row.get("userInput") or "").strip()
5232
- and str(row.get("id") or "").strip()
5233
- ]
5234
3640
 
5235
3641
 
5236
- def _validate_supersession_ledger(
5237
- data: dict,
5238
- failures: list[str],
5239
- *,
5240
- carried: dict | None = None,
5241
- new_plan: bool = False,
5242
- ) -> None:
5243
- """Incorporating an answer means retiring what it invalidates, not only
5244
- adding what it decides.
5245
-
5246
- `new_plan` marks a plan built from a selected direction. Prepare seeds
5247
- that run's ledger with every answer the option-selection record carried
5248
- (2026-09-05), and a first plan has no earlier statement those answers
5249
- could retire — an entry per carried row would be `no-dependent-statement`
5250
- by construction. Those ids are exempt; answers the plan itself raised and
5251
- settled still need their entry.
5252
-
5253
- A re-run reconciles each `C-*` row's `Status` and writes the new decision
5254
- into the plan, but nothing required it to remove the sentences the answer
5255
- made false. The result is one plan carrying two opposite instructions for
5256
- the same symbol — the implementer then has to guess which one is live, and
5257
- the §5.5.9 round correctly blocks on it. This check makes the writer state,
5258
- per answered clarification, what it retired or why nothing was contingent
5259
- on that answer. The claim's *truth* is what the §5.5.9 adversarial round
5260
- tests; this only forces the claim to exist and be attributable.
5261
- """
5262
- ip = data.get("implementationPlanning")
5263
- if not isinstance(ip, dict):
5264
- return
5265
- answered = set(_answered_clarification_ids(data))
5266
- if new_plan:
5267
- answered.difference_update((carried or {}).keys())
5268
- else:
5269
- answered.update((carried or {}).keys())
5270
- if not answered:
5271
- return
5272
- ledger = [e for e in (ip.get("supersessionLedger") or []) if isinstance(e, dict)]
5273
- covered = {
5274
- str(entry.get("clarificationId") or "").strip()
5275
- for entry in ledger
5276
- if str(entry.get("clarificationId") or "").strip()
5277
- }
5278
- missing = [cid for cid in answered if cid not in covered]
5279
- if missing:
5280
- failures.append(
5281
- "final-report data.json: implementationPlanning.supersessionLedger has "
5282
- f"no entry for answered clarification(s) {sorted(missing)}. Every "
5283
- "answer this run incorporated MUST record what it superseded "
5284
- "(`disposition: superseded` with the retired statement and the "
5285
- "sections revised) or state that no plan statement was contingent "
5286
- "on it (`disposition: no-dependent-statement` with a rationale). "
5287
- "Adding the new decision while leaving the contradicting sentence "
5288
- "in place is what puts two opposite instructions in one plan "
5289
- '(_common-contract.md §"clarification response carry-in").'
5290
- )
5291
- stale = covered - answered
5292
- carry_in = data.get("clarificationCarryIn")
5293
- # 이월 원장은 이전 런에서 받은 답을 이번 런이 반영한 기록이다.
5294
- # 이번 런 clarificationItems 에 userInput 이 없다고 stale 로 보면
5295
- # C-024 같은 이월 행이 "this run did not answer" 가 된다.
5296
- if stale and isinstance(carry_in, dict) and str(carry_in.get("sourceFile") or "").strip():
5297
- stale = set()
5298
- if stale:
5299
- failures.append(
5300
- "final-report data.json: implementationPlanning.supersessionLedger "
5301
- f"cites {sorted(stale)}, which this run did not answer. A ledger "
5302
- "entry must correspond 1:1 to a clarification whose answer this "
5303
- "run incorporated."
5304
- )
5305
-
5306
3642
 
5307
3643
  def _carry_in_source_for_run(run_manifest_path: Path) -> str:
5308
3644
  """The carry-in source path THIS run was launched with, read off the
@@ -5352,7 +3688,6 @@ def _clarification_text_for_run(
5352
3688
  return ""
5353
3689
 
5354
3690
 
5355
- _PASSING_VERDICT_TOKENS = frozenset({"accepted", "conditional-accept"})
5356
3691
 
5357
3692
 
5358
3693
  def _consumers_rows(report_path: Path) -> list[dict] | None:
@@ -5380,210 +3715,15 @@ def _consumers_rows(report_path: Path) -> list[dict] | None:
5380
3715
  return rows
5381
3716
 
5382
3717
 
5383
- _PLAN_BODY_STATE_KEYS = ("schemaVersion", "planItems", "roundHistory")
5384
-
5385
3718
 
5386
- def _validate_plan_body_state_file(
5387
- data: dict,
5388
- report_path: Path,
5389
- failures: list[str],
5390
- state_path: Path | None = None,
5391
- ) -> None:
5392
- """The per-round state file must exist once a round has run.
5393
-
5394
- Nothing read this file, so its documented schema was dead contract — yet
5395
- it is the only record of *superseded* rounds. `planItems[].verdicts` in
5396
- data.json is overwritten by each self-fix re-verification, so after the
5397
- loop the report shows the final votes and no trace of what the earlier
5398
- rounds found. Both defect investigations of this phase depended on the
5399
- sidecar to recover that history.
5400
-
5401
- Deliberately does NOT cross-check any gate against data.json: the two are
5402
- different views by design (per-round history vs. final state), and
5403
- demanding equality would fail every run whose self-fix loop worked.
5404
- """
5405
- ip = data.get("implementationPlanning")
5406
- if not isinstance(ip, dict):
5407
- return
5408
- pbv = ip.get("planBodyVerification")
5409
- if not isinstance(pbv, dict):
5410
- return
5411
- round_count = pbv.get("roundCount")
5412
- if not isinstance(round_count, int) or round_count < 1:
5413
- return
5414
- state_dir = report_path.parent.parent / "state"
5415
- if state_path is not None:
5416
- # 호출자가 경로를 넘겼으면 그걸 본다. 리드는 launch 프롬프트의
5417
- # `Run Paths` 에서 정본 경로를 받으므로, 여기서 이름을 다시 만들면
5418
- # 그 정본과 어긋날 수 있다 — 실제로 그랬다.
5419
- written = [state_path] if state_path.is_file() else []
5420
- else:
5421
- # 이름을 유도할 근거가 없다. run 은 seq 계열을 둘 갖고(`state` /
5422
- # `reports`) 리포트 정본은 자기 run 의 state seq 를 담지 않으므로,
5423
- # 리포트 seq 로 만든 이름은 추측이다. 이 검사가 묻는 것은 "덮어써진
5424
- # 라운드의 기록이 남았는가" 이지 파일 이름이 아니므로, 이 run 의 상태
5425
- # 디렉터리에 사이드카가 있는지만 본다. 이름의 정본은 `paths.py` 다.
5426
- written = sorted(
5427
- state_dir.glob("plan-body-verification-implementation-planning-*.json")
5428
- )
5429
- if not written:
5430
- failures.append(
5431
- f"plan-body verification ran ({round_count} round(s)) but no "
5432
- f"`state/plan-body-verification-*.json` was written. It is the only "
5433
- "record of superseded rounds — data.json keeps just the final "
5434
- "verdicts, so without it a self-fixed run leaves no trace of what "
5435
- 'the earlier rounds found (plan-body-verification.md §"schema"). '
5436
- "The path is rendered into the launch prompt's `Run Paths` block; "
5437
- "write it there rather than deriving a name."
5438
- )
5439
- return
5440
- # 여럿이면 가장 최신(seq 가 큰) 것이 이 run 의 것이다.
5441
- expected = written[-1]
5442
- try:
5443
- state = json.loads(expected.read_text(encoding="utf-8"))
5444
- except (OSError, json.JSONDecodeError) as exc:
5445
- failures.append(f"plan-body verification state file is unreadable: {exc}")
5446
- return
5447
- missing = [key for key in _PLAN_BODY_STATE_KEYS if key not in state]
5448
- for key in missing:
5449
- failures.append(
5450
- f"plan-body verification state file `{expected.name}` is "
5451
- f"missing required key `{key}`."
5452
- )
5453
- if not missing:
5454
- _validate_plan_body_state_rounds(
5455
- state, pbv, expected.name, round_count, failures
5456
- )
5457
3719
 
5458
3720
 
5459
- def _validate_plan_body_state_rounds(
5460
- state: dict,
5461
- pbv: dict,
5462
- name: str,
5463
- round_count: int,
5464
- failures: list[str],
5465
- ) -> None:
5466
- """Every round that ran must survive in the sidecar, its votes included.
5467
3721
 
5468
- The round protocol used to write this file once, before the self-fix loop,
5469
- and never asked for it again — so a run with three re-verifications kept
5470
- round 1 only, and the superseded rounds this file exists to preserve were
5471
- exactly the ones it dropped (jobs dev-10269 seq 001: `roundCount` 4 in
5472
- data.json against `round` 1 here).
5473
- """
5474
- history = [e for e in (state.get("roundHistory") or []) if isinstance(e, dict)]
5475
- # A non-int `round` names no round, so it cannot cover one — and reading it
5476
- # into a set would abort the whole validation on unhashable lead-authored JSON.
5477
- recorded = {e["round"] for e in history if isinstance(e.get("round"), int)}
5478
- if recorded != set(range(1, round_count + 1)):
5479
- seen = sorted(recorded)
5480
- failures.append(
5481
- f"plan-body verification state file `{name}` records `roundHistory[]` "
5482
- f"rounds {seen} but the report declares `roundCount`={round_count}. "
5483
- f"One entry per round 1..{round_count} is required: data.json keeps "
5484
- "only the final verdicts, so a sidecar frozen at an earlier round "
5485
- "loses every round it superseded (plan-body-verification.md "
5486
- '§"Round protocol" step 7 "Round completion").'
5487
- )
5488
- gateless = [str(e.get("round")) for e in history if not e.get("gateResult")]
5489
- if gateless:
5490
- failures.append(
5491
- f"plan-body verification state file `{name}`: `roundHistory[]` "
5492
- f"round(s) {', '.join(gateless)} carry no `gateResult`. The per-round "
5493
- "gate is what tells the reader which round blocked and on what, and "
5494
- "the sidecar is the only place it survives."
5495
- )
5496
- declared = pbv.get("selfFixRoundsApplied")
5497
- if isinstance(declared, int) and state.get("selfFixRoundsApplied") != declared:
5498
- failures.append(
5499
- f"plan-body verification state file `{name}` records "
5500
- f"`selfFixRoundsApplied`={state.get('selfFixRoundsApplied')!r} but the "
5501
- f"report declares {declared}. The sidecar is rewritten at each round's "
5502
- "end, so a stale count means the later rounds were never written to it."
5503
- )
5504
- voted = {
5505
- vote["round"]
5506
- for item in (state.get("planItems") or [])
5507
- if isinstance(item, dict)
5508
- for vote in (item.get("rounds") or [])
5509
- if isinstance(vote, dict) and isinstance(vote.get("round"), int)
5510
- }
5511
- uncited = [n for n in sorted(recorded & set(range(1, round_count + 1)))
5512
- if n not in voted]
5513
- if uncited:
5514
- failures.append(
5515
- f"plan-body verification state file `{name}`: round(s) {uncited} "
5516
- "appear in `roundHistory[]` but no `planItems[].rounds[]` entry "
5517
- "records a vote cast in them. A re-verification round whose verdicts "
5518
- "were never written down is precisely the history this file holds."
5519
- )
5520
3722
 
5521
3723
 
5522
3724
  def _warn_out_of_plan_edits_not_in_diff(data: dict, warnings: list[str]) -> None:
5523
- """소스 차이 목록은 별도 QA 산출물의 변경 여부를 증명하지 못한다."""
5524
- implementation = data.get("implementation")
5525
- if not isinstance(implementation, dict):
5526
- return
5527
- rows = implementation.get("outOfPlanEdits")
5528
- if not isinstance(rows, list) or not rows:
5529
- return
5530
- changed = set(_diff_summary_files(data))
5531
- if not changed:
5532
- return
5533
- for row in rows:
5534
- if not isinstance(row, dict):
5535
- continue
5536
- target = row.get("file")
5537
- if isinstance(target, str) and target and target not in changed:
5538
- warnings.append(
5539
- f"out-of-plan-edit: {row.get('id') or 'OOP-???'} 가 `{target}` 을 "
5540
- "계획 밖 편집으로 신고했지만 diffSummary 에 그 파일이 없다"
5541
- )
5542
-
5543
-
5544
- def _validate_verifier_reran_independently(data: dict, failures: list[str]) -> None:
5545
- """`independentValidationRerun` 칸이 비어 있지 않아야 한다.
5546
-
5547
- 스키마는 필드 존재만 강제하고 값은 보지 않는다. 빈 칸은 재현 없이
5548
- 통과시킨 것과 구별되지 않는다.
5549
-
5550
- executor 인용 표현을 잡던 정규식 갈래는 삭제했다. 표현을 세는 검사라
5551
- 같은 재현을 어떻게 서술했느냐로 통과가 갈렸다.
5552
- """
5553
- for who, row in _verifier_rows(data):
5554
- rerun = row.get("independentValidationRerun")
5555
- if not isinstance(rerun, str) or not rerun.strip():
5556
- failures.append(
5557
- f"verifier-rerun: {who} 가 independentValidationRerun 을 비워 뒀다 — "
5558
- "재현 없이 통과시킨 것과 구별되지 않는다"
5559
- )
5560
-
5561
-
5562
- def _validate_verifier_discrepancy_is_not_passed(
5563
- data: dict, failures: list[str]
5564
- ) -> None:
5565
- """재현 결과가 executor 보고와 갈렸는데 PASS 로 넘기지 못하게 한다.
3725
+ warn_out_of_plan_edits_not_in_diff(data, warnings, _diff_summary_files(data))
5566
3726
 
5567
- 규칙(§"Discrepancy rule")은 Tier 1/2 와 blocking io-only Tier 3 의 divergence 에
5568
- `FAIL` 을 요구하고, Tier 3 외부 자문 divergence 만 제외한다. 리포트 구조에는
5569
- tier 필드가 없어 그 둘을 여기서 가릴 수 없다. 그래서 `PASS` 만 막는다 —
5570
- 자문 divergence 는 `CONCERNS` 로 기록할 자리가 이미 있고, `PASS` 는 "갈렸는데
5571
- 아무 일도 없었다" 는 뜻이라 어느 tier 로도 정당화되지 않는다.
5572
- """
5573
- for who, row in _verifier_rows(data):
5574
- discrepancy = row.get("discrepancy")
5575
- if not isinstance(discrepancy, str) or not discrepancy.strip():
5576
- continue
5577
- if row.get("verdict") == "PASS":
5578
- failures.append(
5579
- f"verifier-discrepancy: {who} 가 divergence 를 기록하고도 PASS 를 냈다 "
5580
- f"— FAIL(또는 자문 divergence 면 CONCERNS)이어야 한다: "
5581
- f"{discrepancy.strip()[:120]}"
5582
- )
5583
-
5584
-
5585
- _CHECKLIST_ID_RE = re.compile(r"\bVC-\d{3,}\b")
5586
- _CHECKLIST_PHASE_RE = re.compile(r"\bphase\W{0,3}(pre|mid|post)\b", re.IGNORECASE)
5587
3727
 
5588
3728
 
5589
3729
  def _approved_plan_record(
@@ -5630,91 +3770,50 @@ def _approved_plan_record(
5630
3770
 
5631
3771
 
5632
3772
  def _validate_verifier_discrepancy_names_checklist_phase(
5633
- data: dict,
5634
- report_path: Path,
5635
- project_root: Path | None,
5636
- failures: list[str],
3773
+ data: dict, report_path: Path, project_root: Path | None, failures: list[str],
5637
3774
  ) -> None:
5638
- """계획 `validationChecklist` 행을 근거로 적은 divergence 는 그 행의 `phase` 를 인용한다.
3775
+ validate_verifier_discrepancy_names_checklist_phase(
3776
+ data, _approved_plan_record(data, report_path, project_root), failures,
3777
+ )
5639
3778
 
5640
- 2026-09-05 실측(fontsninja-v3-site dev-10626 stage-1): codex 검증자가 `VC-003` 의
5641
- `git diff --name-only` 가 커밋 뒤 빈 출력이라며 FAIL 을 냈다. 그 행은 계획 레코드에
5642
- `phase: mid` — 편집과 커밋 사이의 체크포인트 — 로 선언돼 있어, 커밋 뒤의 빈 출력은
5643
- 계획의 단계 순서 그 자체였다. 수렴에서 제기자 본인이 반대 읽기에 AGREE 했지만 FAIL
5644
- 행은 남아 stage 가 `failed` 로 갔고, 리드도 그 주장을 열어 보지 않고 라우팅에 옮겼다.
5645
- `pre`/`mid`/`post` 는 행이 언제 성립하는지를 정하므로, 행을 인용하는 문장이 그 값을
5646
- 함께 적어야 한다 — 읽지 않은 행을 근거로 쓰는 문장은 그러면 쓸 수 없다.
5647
3779
 
5648
- 행에 `phase` 가 없거나 계획 레코드를 못 찾으면 판정하지 않는다.
5649
- """
5650
- plan = _approved_plan_record(data, report_path, project_root)
5651
- if plan is None:
5652
- return
5653
- planning = plan.get("implementationPlanning")
5654
- rows = planning.get("validationChecklist") if isinstance(planning, dict) else None
5655
- phases = {
5656
- str(row["id"]): str(row["phase"]).strip().lower()
5657
- for row in (rows if isinstance(rows, list) else [])
5658
- if isinstance(row, dict)
5659
- and isinstance(row.get("id"), str)
5660
- and isinstance(row.get("phase"), str)
5661
- }
5662
- if not phases:
5663
- return
5664
- for who, row in _verifier_rows(data):
5665
- discrepancy = row.get("discrepancy")
5666
- if not isinstance(discrepancy, str) or not discrepancy.strip():
5667
- continue
5668
- cited = sorted(set(_CHECKLIST_ID_RE.findall(discrepancy)) & set(phases))
5669
- if not cited:
5670
- continue
5671
- named = {
5672
- match.group(1).lower()
5673
- for match in _CHECKLIST_PHASE_RE.finditer(discrepancy)
5674
- }
5675
- missing = [
5676
- f"{row_id} (phase: {phases[row_id]})"
5677
- for row_id in cited
5678
- if phases[row_id] not in named
5679
- ]
5680
- if missing:
5681
- failures.append(
5682
- f"verifier-discrepancy: {who} 가 계획 체크리스트 행을 근거로 divergence 를 "
5683
- f"적었지만 그 행의 phase 를 인용하지 않았다 — {', '.join(missing)}. "
5684
- "`pre`/`mid`/`post` 는 행이 언제 성립하는지를 정하므로 인용 문장에 "
5685
- "`VC-NNN (phase: <값>)` 으로 적는다 (`_implementation-verifier.md` § Tier 1)."
5686
- )
5687
3780
 
5688
3781
 
5689
- def _verifier_rows(data: dict):
5690
- """(표시 이름, verifierResults 행) 쌍."""
5691
- implementation = data.get("implementation")
5692
- if not isinstance(implementation, dict):
5693
- return
5694
- for row in implementation.get("verifierResults") or []:
5695
- if not isinstance(row, dict):
5696
- continue
5697
- yield str(row.get("verifier") or row.get("role") or "verifier"), row
5698
3782
 
5699
3783
 
5700
- def _validate_verifier_command_log_is_read_only(
5701
- data: dict,
5702
- failures: list[str],
5703
- ) -> None:
5704
- """검증자의 Read-only command log 에 변조 모드가 없어야 한다.
5705
3784
 
5706
- 규칙은 prompts/profiles/_implementation-verifier.md 가 "런타임 AND 검증자가
5707
- 거부해야 한다"고 BLOCKING 으로 선언해 왔지만 런타임 검사는 없었다. 스키마는
5708
- 로그의 **존재**만 강제하고 내용은 아무도 읽지 않았다 — 검증자가
5709
- `eslint --fix` 로 자기가 검증할 소스를 고쳐 놓아도 통과한다.
5710
3785
 
5711
- 로그는 리포트에 그대로 복사되므로 여기서 읽는 것이 정본이다.
5712
- """
3786
+
3787
+ _LEAD_AUTHORED = "Okstra lead"
3788
+ _REPORT_AUTHORING_HEADING_RE = re.compile(r"^## REPORT AUTHORING\s*$", re.MULTILINE)
3789
+ _REPORT_AUTHORING_APPROVED = "approved"
3790
+ # `report-writer.md` "Lead-authored fallback": the attempt must have reached one
3791
+ # of these with a concrete reason. `completed` means the worker produced the
3792
+ # report, so the lead had nothing to fall back from.
3793
+ _DISPATCH_FAILURE_STATUSES = {"error", "timeout", "not-run"}
3794
+
3795
+
3796
+
3797
+
3798
+
3799
+
3800
+
3801
+
3802
+
3803
+
3804
+
3805
+
3806
+
3807
+
3808
+
3809
+
3810
+
3811
+
3812
+ def _validate_verifier_command_log_is_read_only(data: dict, failures: list[str]) -> None:
5713
3813
  implementation = data.get("implementation")
5714
3814
  if not isinstance(implementation, dict):
5715
3815
  return
5716
- rows = [r for r in (implementation.get("verifierResults") or []) if isinstance(r, dict)]
5717
- if not rows:
3816
+ if not any(isinstance(row, dict) for row in implementation.get("verifierResults") or []):
5718
3817
  return
5719
3818
  _validators_dir = Path(__file__).resolve().parent
5720
3819
  if str(_validators_dir) not in sys.path:
@@ -5726,18 +3825,7 @@ def _validate_verifier_command_log_is_read_only(
5726
3825
  f"verifier-command-log: verifier_mutation_hits import failed — {exc}"
5727
3826
  )
5728
3827
  return
5729
- for row in rows:
5730
- log = row.get("readOnlyCommandLog")
5731
- if not isinstance(log, str) or not log.strip():
5732
- continue
5733
- who = str(row.get("workerId") or row.get("role") or "verifier")
5734
- for label, line in verifier_mutation_hits(log):
5735
- failures.append(
5736
- f"verifier-command-log: {who} 의 read-only 로그에 변조 모드 "
5737
- f"`{label}` 이 있다: {line[:120]}"
5738
- )
5739
-
5740
-
3828
+ validate_verifier_command_log_is_read_only(data, failures, verifier_mutation_hits)
5741
3829
 
5742
3830
  def _validate_verified_row_recorded(
5743
3831
  data: dict,
@@ -5796,40 +3884,6 @@ def _validate_verified_row_recorded(
5796
3884
  )
5797
3885
 
5798
3886
 
5799
- def _validate_verifier_fail_blocks_verdict(data: dict, failures: list[str]) -> None:
5800
- """A verifier FAIL cannot be dropped during synthesis.
5801
-
5802
- `implementation.verifierResults[]` was written, read by the stage-fix carry
5803
- helper, and by nothing else — no check compared a recorded `FAIL` against
5804
- the verdict the lead published. A FAIL lost in synthesis lets
5805
- `final-verification` reach `accepted` and `release-handoff` push work a
5806
- verifier rejected.
5807
- """
5808
- implementation = data.get("implementation")
5809
- if not isinstance(implementation, dict):
5810
- return
5811
- failed = sorted({
5812
- str(row.get("verifier") or "<unknown>")
5813
- for row in (implementation.get("verifierResults") or [])
5814
- if isinstance(row, dict) and str(row.get("verdict") or "").strip() == "FAIL"
5815
- })
5816
- if not failed:
5817
- return
5818
- token = str((data.get("finalVerdict") or {}).get("verdictToken") or "").strip()
5819
- if token in _PASSING_VERDICT_TOKENS:
5820
- failures.append(
5821
- f"final-report data.json: verifier(s) {failed} recorded "
5822
- f"`verdict: FAIL` but `finalVerdict.verdictToken` is `{token}`. A "
5823
- "verifier rejection MUST survive into the published verdict — "
5824
- "dropping it during synthesis is how rejected work reaches "
5825
- "`release-handoff`. Carry the FAIL into a blocking verdict. There "
5826
- "is no synthesis-time override: a verifier that produced no "
5827
- "verdict records `not-run` and a Tier 3 advisory divergence "
5828
- "records `CONCERNS`, and both are the verifier's to write, not "
5829
- "the lead's to substitute "
5830
- '(`_implementation-verifier.md` "All-verifier-failure policy").'
5831
- )
5832
-
5833
3887
 
5834
3888
 
5835
3889
  _LEAD_AUTHORED = "Okstra lead"
@@ -5932,1293 +3986,96 @@ def _validate_lead_authored_report(
5932
3986
  "unexplained failure. Record the tool error, the timeout, or the "
5933
3987
  "external blocker on the dispatch row"
5934
3988
  )
5935
-
5936
- fallback = header.get("leadAuthoredFallback")
5937
- if not isinstance(fallback, Mapping):
5938
- failures.append(
5939
- "final-report data.json: `header.reportAuthor` is `Okstra lead` but "
5940
- "`header.leadAuthoredFallback` is absent. The approval passes the "
5941
- "gate; it does not erase it — the failure reason and the approving "
5942
- "sidecar belong in the report a human reads, not only in the "
5943
- "sidecars they would have to go find"
5944
- )
5945
- else:
5946
- recorded = str(fallback.get("dispatchFailureReason") or "").strip()
5947
- reasons = {str(row.get("reason") or "").strip() for row in failed}
5948
- if recorded and reasons and recorded not in reasons:
5949
- failures.append(
5950
- "final-report data.json: "
5951
- "`header.leadAuthoredFallback.dispatchFailureReason` does not "
5952
- "match any reason recorded on a failed report-writer dispatch. "
5953
- "Quote the dispatch row verbatim rather than restating it"
5954
- )
5955
-
5956
- approval = _report_authoring_approval(report_path)
5957
- if approval != _REPORT_AUTHORING_APPROVED:
5958
- found = f"`{approval}`" if approval else "no `## REPORT AUTHORING` block"
5959
- failures.append(
5960
- "final-report data.json: `header.reportAuthor` is `Okstra lead` but "
5961
- f"the run's `user-responses/` sidecars carry {found}. Only the user "
5962
- "may permit the lead to author the report; ask at a gate and have "
5963
- "the answer written through `okstra user-response write`"
5964
- )
5965
-
5966
-
5967
- def _validate_stage_carry_sidecar_exists(
5968
- data: dict,
5969
- report_path: Path,
5970
- failures: list[str],
5971
- ) -> None:
5972
- """The stage carry sidecar must exist on disk, not only be transcribed.
5973
-
5974
- `implementation.stageSidecarEvidence` is prose the report quotes, so a
5975
- report can describe a sidecar that was never written. `consumers` treats
5976
- the carry file as the source of truth for marking a stage `done`, so a
5977
- missing file leaves the stage permanently un-done and blocks every
5978
- dependent stage with a `PrepareError` — while the run that caused it
5979
- finished reporting success.
5980
- """
5981
- implementation = data.get("implementation")
5982
- if not isinstance(implementation, dict):
5983
- return
5984
- evidence = implementation.get("stageSidecarEvidence")
5985
- if not isinstance(evidence, dict):
5986
- return
5987
- stage = evidence.get("stageNumber")
5988
- if not isinstance(stage, int):
5989
- return
5990
- # A stage whose verifier returned FAIL must NOT persist its carry: the carry
5991
- # file is what marks the stage `done`, and doing that would stack the next
5992
- # stage on a confirmed regression. Such a run states the reason in
5993
- # `withheld` and records a `failed` consumers row instead, so the absent
5994
- # file is the correct outcome, not a gap.
5995
- if str(evidence.get("withheld") or "").strip():
5996
- return
5997
- # Carry sidecars are stage-SHARED: the next stage's carry-in and
5998
- # `consumers.backfill_done_from_carry` glob them without knowing the
5999
- # producing run's layout. `RunRef.carry()` owns that flat-vs-staged rule;
6000
- # resolving it under the stage run dir forced the lead to write it twice.
6001
- carry_path = RunRef.from_report_path(report_path).carry(stage)
6002
- if not carry_path.exists():
6003
- failures.append(
6004
- f"implementation run declares stage-{stage} sidecar evidence but "
6005
- f"`{carry_path.parent.name}/{carry_path.name}` does not exist. The "
6006
- "carry file is what marks the stage `done` for dependent stages; "
6007
- "without it this stage never completes and every successor fails "
6008
- "to prepare, even though this run reported success "
6009
- '(_implementation-executor.md §"Sidecar evidence writer").'
6010
- )
6011
-
6012
-
6013
- def _validate_round_recorded_verdicts(data: dict, failures: list[str]) -> None:
6014
- """A round that ran must leave the votes it ran on — item by item.
6015
-
6016
- The gate is re-derived from `planItems[].verdicts[]`, so an empty table
6017
- removes the very evidence the recompute judges. A *healthier* declared gate
6018
- is already caught — empty verdicts recompute to `aborted-non-result`, which
6019
- every passing value outranks. What slipped through was the conservative
6020
- declaration: a lead writing `aborted-non-result` over an empty table
6021
- produces a gate nothing can audit, indistinguishable from a round that was
6022
- dispatched and whose results were never transcribed.
6023
-
6024
- The per-item form is what survives a self-fix loop. Round 2+ queues are
6025
- targeted, so an item the planner adds mid-loop and never puts in one keeps
6026
- an empty `verdicts[]` while every neighbour carries votes — and nothing
6027
- downstream reads that as a gap. An empty table classifies `all-non-result`
6028
- (`_classify_plan_item_gate`), which states as `needs-reverify`, which
6029
- `_recompute_plan_body_gate` folds into `passed-with-dissent`: a plan item
6030
- no verifier ever judged leaves the gate in a passing value. The whole-table
6031
- check could not see it, since it stands down the moment any one item has a
6032
- vote.
6033
-
6034
- An unjudged item is distinguishable from a legitimately unresolved one, and
6035
- the difference is what is recorded rather than what is missing. A peer that
6036
- returned nothing is a `verification-error` VOTE (§"Round protocol" step 3),
6037
- so an all-error item still carries rows and still folds to `needs-reverify`
6038
- on purpose. An empty table means no dispatch was accounted for at all.
6039
- """
6040
- ip = data.get("implementationPlanning")
6041
- if not isinstance(ip, dict):
6042
- return
6043
- pbv = ip.get("planBodyVerification")
6044
- if not isinstance(pbv, dict):
6045
- return
6046
- round_count = pbv.get("roundCount")
6047
- if not isinstance(round_count, int) or round_count < 1:
6048
- return
6049
- items = [
6050
- it for it in (pbv.get("planItems") or [])
6051
- if isinstance(it, dict) and _stage_scope_bucket(it, pbv) == "in-scope"
6052
- ]
6053
- if not items:
6054
- return
6055
- empty = [str(it.get("id") or "<unnamed>") for it in items if not it.get("verdicts")]
6056
- if not empty:
6057
- return
6058
- if len(empty) == len(items):
6059
- failures.append(
6060
- "final-report data.json: planBodyVerification declares "
6061
- f"`roundCount`={round_count} but every one of the {len(items)} "
6062
- "`planItems[]` carries an empty `verdicts[]`. A round that ran MUST "
6063
- "record the votes it produced — the gate is re-derived from this "
6064
- "table, so an empty one leaves the declared `gateResult` unauditable. "
6065
- "A dispatch that returned nothing is recorded as `verification-error`, "
6066
- 'not omitted (plan-body-verification.md §"Round protocol" step 4).'
6067
- )
6068
- return
6069
- shown = ", ".join(f"`{item_id}`" for item_id in empty[:5])
6070
- more = f" and {len(empty) - 5} more" if len(empty) > 5 else ""
6071
- failures.append(
6072
- "final-report data.json: planBodyVerification declares "
6073
- f"`roundCount`={round_count} but {len(empty)} of {len(items)} "
6074
- f"`planItems[]` carry an empty `verdicts[]`: {shown}{more}. Every "
6075
- "extracted plan item MUST be judged by the round — an item with no "
6076
- "vote at all is not a dissent the gate can weigh, it is a plan item "
6077
- "nobody verified, and it currently folds into `passed-with-dissent` "
6078
- "alongside items that were properly cross-checked. Either dispatch it "
6079
- "in this round's queue, or record the non-result as a "
6080
- "`verification-error` verdict per plan-body-verification.md "
6081
- '§"Round protocol" step 3 — an item is never left with no row.'
6082
- )
6083
-
6084
-
6085
- def _plan_items_routed_to_a_user_decision(data: dict) -> set[str]:
6086
- """`blocks: approval` C 행과 이어진 계획 항목 id.
6087
-
6088
- 처분 여부는 보지 않는다. 행이 존재한다는 것 자체가 그 항목이 사용자 결정
6089
- 채널로 나갔다는 뜻이고, 아직 답이 없는 행은 `row_blocks_progress` 가
6090
- 승인을 막는다 — `_validate_v3_approval_context` 가 그 상태의 `approved:
6091
- true` 를 거부한다. 링크는 양방향으로 읽는다: 항목 쪽 `clarificationRefs`
6092
- 와, 행 → 활동 원장 역추적(`_plan_item_ids_for_clarification`). 계약 3.0
6093
- 리포트는 후자로만 이어지는 경우가 있다.
6094
- """
6095
- rows = [r for r in (data.get("clarificationItems") or []) if isinstance(r, dict)]
6096
- approval_rows = [
6097
- row for row in rows if row.get("blocks") == "approval" and row.get("id")
6098
- ]
6099
- approval_ids = {str(row["id"]) for row in approval_rows}
6100
- linked: set[str] = set()
6101
- items = (
6102
- ((data.get("implementationPlanning") or {}).get("planBodyVerification") or {})
6103
- .get("planItems") or []
6104
- )
6105
- for item in items:
6106
- if isinstance(item, dict) and _plan_item_clarification_ids(item) & approval_ids:
6107
- linked.add(str(item.get("id") or "").strip())
6108
- for row in approval_rows:
6109
- context = row.get("approvalContext")
6110
- linked.update(
6111
- str(item_id).strip()
6112
- for item_id in _plan_item_ids_for_clarification(
6113
- row, context if isinstance(context, dict) else {}, data
6114
- )
6115
- )
6116
- linked.discard("")
6117
- return linked
6118
-
6119
-
6120
- def _validate_unresolved_tie_was_reverified(
6121
- data: dict,
6122
- failures: list[str],
6123
- ) -> None:
6124
- """A split panel is settled by critic-worker or by the user, not by silence.
6125
-
6126
- The gate needs a strict majority to block, so a panel splitting evenly on a
6127
- blocking kind reaches neither consensus nor `majority-disagree`. That state
6128
- is classified `needs-reverify`, which `_recompute_plan_body_gate` folds into
6129
- `passed-with-dissent` — so without a settlement the split passes with nobody
6130
- deciding it.
6131
-
6132
- 두 가지 해소가 있고 로스터가 어느 쪽인지 정한다. critic 이 배정된 run 은
6133
- `critic-worker` 표가 가른다. critic 이 없는 로스터(`invocationAssignments`
6134
- 에 `critic/*` 없음, `okstra_ctl.plan_items.critic_is_rostered`)는 라운드
6135
- 안에 가를 표가 아예 없으므로 `next_dispatch` 가 `user-decision` 을 내고
6136
- 리드가 항목마다 승인 결정을 연다. 그 결정 행이 이 항목의 해소다. 검증기는
6137
- 로스터를 볼 수 없으므로(이 검사의 입력은 리포트 정본뿐) 둘 중 하나가
6138
- 기록되어 있으면 해소로 읽고, 둘 다 없을 때만 발화한다.
6139
-
6140
- 같은 조건을 다른 술어로 한 번 더 세던 두 번째 동수 검사를 여기로 합쳤다.
6141
- 그쪽 술어는 판정 행의 `round` 를 1 로 눌러 놓고 `_is_unsettled_tie` 를
6142
- 불렀는데, `_is_even_analyser_split` 는 `round` 를 보지 않으므로 두 술어의
6143
- 값이 언제나 같았다 — 같은 항목이 두 번 실패로 올라왔다. 여기 남은 조건이
6144
- 두 집합의 합집합이다.
6145
- """
6146
- ip = data.get("implementationPlanning")
6147
- if not isinstance(ip, dict):
6148
- return
6149
- pbv = ip.get("planBodyVerification")
6150
- if not isinstance(pbv, dict):
6151
- return
6152
- # 사용자 결정 채널로 나간 항목은 제외한다. 진행 처분이 이미 붙은 항목
6153
- # (accept-risk 등)도 여기 포함된다 — 리드 계약이 accept-risk 를 "게이트를
6154
- # 끝내고 재검증 AGREE 를 요구하지 않는" 처분으로 정의하므로, 계속 실패를
6155
- # 올리면 승인 처분으로 빠져나갈 수 없는 규칙이 된다.
6156
- decided = _plan_items_routed_to_a_user_decision(data)
6157
- unsettled = sorted({
6158
- str(item.get("id") or "").strip()
6159
- for item in pbv.get("planItems") or []
6160
- if isinstance(item, dict)
6161
- and not item.get("carriedForwardFromSeq")
6162
- and str(item.get("id") or "").strip() not in decided
6163
- and not _lead_decision_applies(item, pbv)
6164
- and _stage_scope_bucket(item, pbv) == "in-scope"
6165
- and _is_unsettled_tie(item)
6166
- })
6167
- if not unsettled:
6168
- return
6169
- failures.append(
6170
- "final-report data.json: plan item(s) "
6171
- f"{unsettled} carry an even split on a blocking breakage kind and "
6172
- f"have no `{CRITIC_WORKER_ID}` vote and no `blocks: approval` "
6173
- "clarification row. A tie is not consensus. With a critic on the "
6174
- f"roster, dispatch `{CRITIC_WORKER_ID}` on those items only (`okstra "
6175
- "plan-items prepare --tie-vote`), read its answer with `okstra "
6176
- "plan-items collect-verdicts --items <the --tie-vote plan-items "
6177
- f"artifact> --result {CRITIC_WORKER_ID}=<path> --output <envelope>`, "
6178
- "then record it with `okstra plan-items apply-verdicts --append "
6179
- "--round 2`. Skipping collect-verdicts and pointing apply-verdicts at "
6180
- "the raw result is refused: this round's queue is the tie items, not "
6181
- "the round's full dispatch queue. Critic AGREE settles the split; "
6182
- "critic DISAGREE blocks. With no critic on the roster `okstra "
6183
- "plan-items next-dispatch` answers `user-decision` instead: open one "
6184
- "`okstra approval-decision open` per item (classification "
6185
- "`noncritical-dissent`) and write the matching `## 1. Clarification "
6186
- "Items` row, and that row settles the tie here while it gates approval."
6187
- )
6188
-
6189
-
6190
- def _validate_advisory_plan_body_gating(data: dict, failures: list[str]) -> None:
6191
- """gating=false 는 검출 표면 0 + 스테이지 1 일 때만 받는다."""
6192
- ip = data.get("implementationPlanning")
6193
- if not isinstance(ip, dict):
6194
- return
6195
- pbv = ip.get("planBodyVerification")
6196
- if not isinstance(pbv, dict) or pbv.get("gating") is not False:
6197
- return
6198
- facts = ip.get("designPreparation") is not None or ip.get("stageMap") or ip.get("stages")
6199
- if requires_plan_repair(pbv):
6200
- failures.append("plan-body-verification: objective verification defects require gating=true; repair the affected items")
6201
- if facts and not advisory_plan_body_gating(ip):
6202
- failures.append(
6203
- "final-report data.json: implementationPlanning.planBodyVerification "
6204
- "`gating` is false, but that is only legal when "
6205
- "designPreparation.mode is `no-design-inputs` (empty items) and the "
6206
- "Stage Map has exactly one row. Two-or-more stages, a PREP item, or "
6207
- "non-empty designPreparation items keep the gating contract."
6208
- )
6209
- applied = pbv.get("selfFixRoundsApplied")
6210
- if isinstance(applied, int) and applied > 0:
6211
- failures.append(
6212
- "final-report data.json: implementationPlanning.planBodyVerification "
6213
- "`gating` is false, so the self-fix loop must not run "
6214
- f"(`selfFixRoundsApplied`={applied}). Keep extraction and one "
6215
- "verification round."
6216
- )
6217
-
6218
-
6219
- def _validate_verdict_rounds_outlive_self_fix(
6220
- data: dict,
6221
- failures: list[str],
6222
- ) -> None:
6223
- """A verdict must judge the plan the gate is about to pass.
6224
-
6225
- Rounds interleave with rewrites: round 1, self-fix 1, round 2, self-fix 2 …
6226
- so a verdict cast in round R judged the text as it stood after self-fix
6227
- R-1. If any self-fix ran afterwards — `selfFixRoundsApplied >= R` — that
6228
- text has changed and the verdict is stale by construction. No semantic
6229
- analysis is needed to know that; the arithmetic settles it.
6230
-
6231
- The sibling `_validate_verdicts_match_current_subjects` cannot see this. It
6232
- compares each row's own recorded `subject`, which catches a positional shift
6233
- but not the case that matters here: an item whose own wording never changed
6234
- while the stage it points at was rewritten under it. Observed on a real run
6235
- — the gate read `passed-with-dissent` with zero blockers, and re-running one
6236
- round flipped 3 of 27 items to `majority-disagree`, all correctness-critical,
6237
- because their surviving verdicts predated two self-fix rounds.
6238
-
6239
- Scoped to items this run verified: a `carriedForwardFromSeq` row belongs to
6240
- the prior run's record and is judged by that run's seq, not this one's
6241
- rounds.
6242
- """
6243
- ip = data.get("implementationPlanning")
6244
- if not isinstance(ip, dict):
6245
- return
6246
- pbv = ip.get("planBodyVerification")
6247
- if not isinstance(pbv, dict):
6248
- return
6249
- applied = pbv.get("selfFixRoundsApplied")
6250
- if not isinstance(applied, int) or applied < 1:
6251
- # With no rewrite after any round there is nothing a verdict can be
6252
- # stale against, and an unstamped row is then simply unremarkable.
6253
- return
6254
-
6255
- stale: list[str] = []
6256
- unstamped: list[str] = []
6257
- for item in pbv.get("planItems") or []:
6258
- if not isinstance(item, dict) or item.get("carriedForwardFromSeq"):
6259
- continue
6260
- if _stage_scope_bucket(item, pbv) != "in-scope":
6261
- continue
6262
- item_id = str(item.get("id") or "").strip()
6263
- verified = item.get("verifiedContentHash")
6264
- current = item.get("contentHash")
6265
- if (
6266
- isinstance(verified, str)
6267
- and isinstance(current, str)
6268
- and verified == current
6269
- ):
6270
- # 본문이 같으면 라운드 번호가 self-fix 이전이어도 같은 텍스트다.
6271
- continue
6272
- for verdict in item.get("verdicts") or []:
6273
- if not isinstance(verdict, dict):
6274
- continue
6275
- round_number = verdict.get("round")
6276
- if not isinstance(round_number, int) or isinstance(round_number, bool):
6277
- unstamped.append(item_id)
6278
- elif round_number <= applied:
6279
- stale.append(item_id)
6280
- if unstamped:
6281
- failures.append(
6282
- f"final-report data.json: plan item(s) {sorted(set(unstamped))} carry "
6283
- f"a verdict with no `round`, and {applied} self-fix round(s) rewrote "
6284
- "the plan. Without the round there is no way to tell whether the "
6285
- "verdict judged the current text or a version two rewrites old. "
6286
- "Re-record the round's votes with `okstra plan-items apply-verdicts "
6287
- "--round <N>`."
6288
- )
6289
- if stale:
6290
- failures.append(
6291
- f"final-report data.json: plan item(s) {sorted(set(stale))} carry a "
6292
- f"verdict from a round at or before self-fix round {applied}, so the "
6293
- "text they judged has since been rewritten. The gate is computed "
6294
- "from these votes, so passing on them declares a plan verified that "
6295
- "nobody verified. Re-verify those items in a round after the last "
6296
- 'self-fix (plan-body-verification.md §"Round protocol" step 7).'
6297
- )
6298
-
6299
-
6300
- def _validate_verdicts_match_current_subjects(
6301
- data: dict,
6302
- failures: list[str],
6303
- ) -> None:
6304
- """A verdict must still be attached to the element it was cast on.
6305
-
6306
- `P-*` ids are positional (`plan_items.py` numbers rows by array index), so
6307
- when a self-fix round deletes a plan element every later row shifts up one.
6308
- A dangling id at the tail is already caught by
6309
- `_validate_plan_item_extraction_completeness`, but the shift itself is not:
6310
- the id set still matches while each surviving verdict now points at its
6311
- neighbour. The recorded `subject` is what makes the shift visible — it is a
6312
- snapshot of the row the worker actually judged.
6313
- """
6314
- ip = data.get("implementationPlanning")
6315
- if not isinstance(ip, dict):
6316
- return
6317
- pbv = ip.get("planBodyVerification")
6318
- if not isinstance(pbv, dict):
6319
- return
6320
- round_count = pbv.get("roundCount")
6321
- if not isinstance(round_count, int) or round_count < 1:
6322
- return
6323
- try:
6324
- current = {
6325
- str(item["id"]): str(item.get("subject") or "")
6326
- for item in extract_plan_items(ip)
6327
- }
6328
- except Exception: # noqa: BLE001
6329
- # Extraction failure is already reported by the completeness check;
6330
- # do not double-report it here as a spurious subject mismatch.
6331
- return
6332
-
6333
- drifted = []
6334
- for item in pbv.get("planItems") or []:
6335
- if not isinstance(item, dict):
6336
- continue
6337
- item_id = str(item.get("id") or "").strip()
6338
- recorded = str(item.get("subject") or "").strip()
6339
- expected = current.get(item_id)
6340
- if expected is None or not recorded:
6341
- continue
6342
- if recorded != expected.strip():
6343
- drifted.append(item_id)
6344
- if drifted:
6345
- failures.append(
6346
- f"final-report data.json: plan item(s) {sorted(drifted)} carry a "
6347
- "`subject` that no longer matches the plan element at that "
6348
- "position. `P-*` ids are positional, so deleting an element during "
6349
- "self-fix shifts every later row and silently re-points its "
6350
- "verdicts at a different element — a recorded blocker then refers "
6351
- "to something the reader cannot find. Re-extract the plan items "
6352
- "and re-verify the shifted ones instead of carrying the old votes "
6353
- 'forward (plan-body-verification.md §"Round protocol" step 7).'
6354
- )
6355
-
6356
-
6357
- def _validate_aborted_gate_has_clarification(data: dict, failures: list[str]) -> None:
6358
- """A gate nobody can act on is a stalled task.
6359
-
6360
- `aborted-non-result` correctly refuses approval and run-prep fail-closes
6361
- the `implementation` entry, but the clarification matcher only walks
6362
- `majority-disagree` items — and an aborted round has none. So the report
6363
- stated no blocker, `okstra-user-response` had nothing to present, and the
6364
- run stalled with no remedy until someone read the gate value by hand.
6365
- """
6366
- ip = data.get("implementationPlanning")
6367
- if not isinstance(ip, dict):
6368
- return
6369
- pbv = ip.get("planBodyVerification")
6370
- if not isinstance(pbv, dict):
6371
- return
6372
- if str(pbv.get("gateResult") or "").strip() != "aborted-non-result":
6373
- return
6374
- has_open_blocker = any(
6375
- isinstance(row, dict)
6376
- and row.get("blocks") == "approval"
6377
- and str(row.get("status") or "").strip() == "open"
6378
- for row in (data.get("clarificationItems") or [])
6379
- )
6380
- if not has_open_blocker:
6381
- failures.append(
6382
- "final-report data.json: planBodyVerification `gateResult` is "
6383
- "`aborted-non-result` but no open `Blocks=approval` clarification "
6384
- "row explains it. An aborted round blocks approval without "
6385
- "producing any majority-disagree item, so without this row the "
6386
- "report names no blocker, `okstra-user-response` has nothing to "
6387
- "present, and the task stalls with no stated remedy. Add a row "
6388
- "naming which dispatches returned no result and what re-running "
6389
- 'them requires (plan-body-verification.md §"Round protocol").'
6390
- )
6391
-
6392
-
6393
- def _plan_verify_result_workers(report_path: Path, task_type: str) -> set[str] | None:
6394
- """Worker roles that actually returned a plan-body reverify result.
6395
-
6396
- Result files are named
6397
- ``<role-slug>-plan-verify-r<N>-<task-type>-<seq>.md`` per
6398
- `plan-body-verification.md` §"Round protocol" step 3, and the role slug is
6399
- ``<role>-plan-verify-r<N>``. Returns ``None`` when the directory is absent
6400
- so the caller can distinguish "no artifacts to check against" from "nobody
6401
- voted".
6402
- """
6403
- worker_results_dir = report_path.parent.parent / "worker-results"
6404
- if not worker_results_dir.is_dir():
6405
- return None
6406
- # Scoped to this run's seq for the same reason the audit check is: the
6407
- # directory accumulates every run, so an unscoped glob would let a prior
6408
- # run's result file vouch for a vote this run never collected.
6409
- seqs = _plan_verify_seq_aliases(report_path)
6410
- workers = set()
6411
- for seq in seqs or {_report_run_seq(report_path) or "*"}:
6412
- for path in worker_results_dir.glob(f"*-plan-verify-r*-{task_type}-{seq}.md"):
6413
- role = path.name.split("-plan-verify-r", 1)[0]
6414
- if role:
6415
- workers.add(role)
6416
- return workers
6417
-
6418
-
6419
- def _manifest_seq_for_report(report_path: Path, category: str) -> set[str]:
6420
- """이 리포트 seq 를 낸 매니페스트가 그 카테고리에 기록한 seq 들.
6421
-
6422
- `paths.compute_run_paths` 는 7개 카테고리 seq 를 디렉터리별로 따로 스캔해
6423
- 배정한다(`paths.next_run_seq`). 같은 run dir 로 재실행하면 카테고리마다
6424
- 다른 속도로 올라가 reports 017 / state 025 같은 분기가 실제로 생긴다.
6425
- 매니페스트의 `runSequencesByCategory` 만이 그 분기를 한 런으로 묶는 기록이다.
6426
- """
6427
- seq = _report_run_seq(report_path)
6428
- manifests_dir = report_path.parent.parent / "manifests"
6429
- if not seq or not manifests_dir.is_dir():
6430
- return set()
6431
- found: set[str] = set()
6432
- for path in manifests_dir.glob("run-manifest-*.json"):
6433
- try:
6434
- payload = json.loads(path.read_text(encoding="utf-8"))
6435
- except (OSError, json.JSONDecodeError, UnicodeError):
6436
- continue
6437
- if not isinstance(payload, dict):
6438
- continue
6439
- categories = payload.get("runSequencesByCategory")
6440
- if not isinstance(categories, dict):
6441
- continue
6442
- if str(categories.get("reports") or "") != seq:
6443
- continue
6444
- value = str(categories.get(category) or "").strip()
6445
- if value:
6446
- found.add(value)
6447
- return found
6448
-
6449
-
6450
- def _plan_verify_seq_aliases(report_path: Path) -> set[str]:
6451
- """이 리포트 seq 와, 같은 리포트를 가리키는 런의 workerResults seq.
6452
-
6453
- reports 와 workerResults 가 갈라지면 워커는 018 로 쓰고 검사는 014 만
6454
- 본다. 같은 리포트를 연 매니페스트의 두 seq 를 모두 인정한다.
6455
- """
6456
- seq = _report_run_seq(report_path)
6457
- aliases: set[str] = {seq} if seq else set()
6458
- return aliases | _manifest_seq_for_report(report_path, "workerResults")
6459
-
6460
-
6461
- def _plan_verify_dispatched_results(
6462
- report_path: Path, task_type: str
6463
- ) -> dict[str, set[str]] | None:
6464
- """이 런이 실제로 디스패치한 plan-body 재검증 결과 파일명(역할별).
6465
-
6466
- 파일명을 seq 로 되짚는 대신 **기록된 디스패치**에서 읽는다. `okstra team`
6467
- 은 워커를 띄울 때마다 team-state 에 `workerDispatches[]` 행을 남기고
6468
- (`dispatch_core._dispatch_record` — `kind`, `workerResultPath` 포함), 그
6469
- `workerResultPath` 는 리드가 디스패치 요청에 실어 보낸 경로 그대로다
6470
- (`dispatch_state.py` 의 `require_string(item, "workerResultPath")`). 결과
6471
- 파일이 어느 seq 로 쓰였든 그 기록이 정답을 들고 있다.
6472
-
6473
- plan-body 상태 파일(`plan-body-verification-<task-type>-<seq>.json`)의
6474
- 라운드/판정 행에는 파일명이 없어서 이 용도로 못 쓴다.
6475
-
6476
- team-state 를 못 찾으면 ``None`` — 호출자가 seq 글롭으로 내려간다.
6477
- 빈 dict 은 "기록은 있는데 재검증 디스패치가 한 건도 없다" 로, 라운드가
6478
- 아예 안 돈 경우다.
6479
- """
6480
- state_dir = report_path.parent.parent / "state"
6481
- if not state_dir.is_dir():
6482
- return None
6483
- seqs = {_report_run_seq(report_path) or ""} | _manifest_seq_for_report(
6484
- report_path, "state"
6485
- )
6486
- dispatched: dict[str, set[str]] = {}
6487
- seen_state = False
6488
- for seq in sorted(s for s in seqs if s):
6489
- path = state_dir / f"team-state-{task_type}-{seq}.json"
6490
- if not path.is_file():
6491
- continue
6492
- try:
6493
- payload = json.loads(path.read_text(encoding="utf-8"))
6494
- except (OSError, json.JSONDecodeError, UnicodeError):
6495
- continue
6496
- if not isinstance(payload, dict):
6497
- continue
6498
- seen_state = True
6499
- for row in payload.get("workerDispatches") or []:
6500
- if not isinstance(row, dict):
6501
- continue
6502
- # critic 동수 라운드는 `kind: "critic"` 으로 나간다 — 결과 파일명은
6503
- # 같은 `-plan-verify-r<N>-` 꼴이다. reverify 계열만 세면 동수가 있던
6504
- # run 마다 critic 표가 "디스패치 기록 없음" 으로 오탐된다(2026-09-09,
6505
- # fontsninja-v3-site dev-10627 planning 002). 계획 본문 라운드 자신의
6506
- # kind 인 `plan-verify-r<N>` 도 같은 이유로 센다.
6507
- kind = str(row.get("kind") or "")
6508
- if not (
6509
- kind.startswith("reverify-r")
6510
- or kind.startswith("plan-verify-r")
6511
- or kind == "critic"
6512
- ):
6513
- continue
6514
- name = Path(str(row.get("workerResultPath") or "")).name
6515
- if "-plan-verify-r" not in name:
6516
- continue
6517
- role = name.split("-plan-verify-r", 1)[0]
6518
- if role:
6519
- dispatched.setdefault(role, set()).add(name)
6520
- return dispatched if seen_state else None
6521
-
6522
-
6523
- def _plan_verify_seq_near_misses(report_path: Path, task_type: str) -> list[str]:
6524
- """이 런의 것으로 인정되지 않은, 같은 디렉터리의 plan-verify 결과 파일.
6525
-
6526
- "파일이 없다" 와 "파일은 있는데 이 런의 seq 가 아니다" 는 해소책이 다르다.
6527
- 앞의 것은 디스패치를 다시 돌려야 하고, 뒤의 것은 이미 나온 결과로 게이트를
6528
- 다시 계산해야 한다. 다만 재실행이 누적된 디렉터리에서는 이 목록이 수백 건이
6529
- 되므로, 호출부가 표본만 싣는다(`_unbacked_remedy_clause`).
6530
- """
6531
- seq = _report_run_seq(report_path)
6532
- if not seq:
6533
- return []
6534
- directory = report_path.parent.parent / "worker-results"
6535
- accepted: set[Path] = set()
6536
- for alias in _plan_verify_seq_aliases(report_path):
6537
- accepted.update(directory.glob(f"*-plan-verify-r*-{task_type}-{alias}.md"))
6538
- for names in (_plan_verify_dispatched_results(report_path, task_type) or {}).values():
6539
- accepted.update(directory / name for name in names)
6540
- return sorted(
6541
- path.name
6542
- for path in directory.glob(f"*-plan-verify-r*-{task_type}-*.md")
6543
- if path not in accepted
6544
- )
6545
-
6546
-
6547
- def _validate_plan_body_verdict_provenance(
6548
- data: dict,
6549
- report_path: Path,
6550
- failures: list[str],
6551
- ) -> None:
6552
- """A recorded verdict must trace back to a worker that was actually asked.
6553
-
6554
- Every §5.5.9 gate computation reads `planItems[].verdicts[]` out of the
6555
- data.json the lead authored, and nothing tied a vote to a dispatch. A lead
6556
- that skipped the round entirely and wrote `AGREE` for two workers produced
6557
- `gateResult: passed`, a flippable `approved:`, and a clean validator run —
6558
- the same self-report weakness `selfFixRoundsApplied` had, but on the votes
6559
- the whole gate is computed from.
6560
- """
6561
- ip = data.get("implementationPlanning")
6562
- if not isinstance(ip, dict):
6563
- return
6564
- pbv = ip.get("planBodyVerification")
6565
- if not isinstance(pbv, dict):
6566
- return
6567
- round_count = pbv.get("roundCount")
6568
- if not isinstance(round_count, int) or round_count < 1:
6569
- return
6570
-
6571
- voters = {
6572
- str(v.get("worker") or "").strip()
6573
- for item in (pbv.get("planItems") or [])
6574
- if isinstance(item, dict)
6575
- for v in (item.get("verdicts") or [])
6576
- if isinstance(v, dict) and str(v.get("worker") or "").strip()
6577
- }
6578
- if not voters:
6579
- return
6580
-
6581
- task_type = (
6582
- str((data.get("header") or {}).get("taskType") or "")
6583
- or _report_task_type(report_path)
6584
- )
6585
- if not task_type:
6586
- # Neither source names it, so the glob below would be built from an
6587
- # empty segment and match nothing — reporting every verdict as unbacked
6588
- # on the strength of a path this check could not construct.
6589
- return
6590
- results_dir = report_path.parent.parent / "worker-results"
6591
- recorded = _plan_verify_dispatched_results(report_path, task_type)
6592
- if recorded is None:
6593
- # 기록된 디스패치가 없다 — seq 글롭으로 내려간다.
6594
- dispatched = _plan_verify_result_workers(report_path, task_type)
6595
- if dispatched is None:
6596
- return
6597
- source = (
6598
- f"no team-state for this run was readable under `runs/{task_type}/"
6599
- f"state/`, so this fell back to globbing `*-plan-verify-r*-"
6600
- f"{task_type}-<seq>.md` under `runs/{task_type}/worker-results/` "
6601
- f"for seq(s) {sorted(_plan_verify_seq_aliases(report_path))}"
6602
- )
6603
- returned = {_analyser_key(name) for name in dispatched}
6604
- never_dispatched: set[str] = set()
6605
- else:
6606
- # 기록된 디스패치가 정답이다. 투표가 뒷받침되려면 (1) 그 역할로 나간
6607
- # 재검증 디스패치 기록이 있고 (2) 그 기록이 적어 둔 결과 파일이 디스크에
6608
- # 실제로 있어야 한다. seq 는 어디에도 안 쓴다 — 갈라진 seq 로 나간
6609
- # 디스패치도 기록에는 자기 파일명 그대로 남아 있다.
6610
- source = (
6611
- f"resolved from the `workerDispatches[]` rows this run's team-state "
6612
- f"recorded under `runs/{task_type}/state/` (each row's own "
6613
- f"`workerResultPath`, not a seq glob)"
6614
- )
6615
- recorded_keys = {_analyser_key(role) for role in recorded}
6616
- returned = {
6617
- _analyser_key(role)
6618
- for role, names in recorded.items()
6619
- if any((results_dir / name).is_file() for name in names)
6620
- }
6621
- never_dispatched = {
6622
- _analyser_key(voter)
6623
- for voter in voters
6624
- if _analyser_key(voter) not in recorded_keys
6625
- }
6626
- # 파일명 슬러그와 투표 키를 같은 축으로 놓는다. 결과 파일명은 cmux 어댑터가
6627
- # `-worker-` 토큰을 요구하는데(`workerResultPath`) 투표 키는 워커 id 그대로다.
6628
- # 워커 id 가 `-worker` 로 끝나던 기본 로스터에서는 두 규칙이 우연히 같은
6629
- # 이름을 냈지만, `grok-planner` 처럼 역할 접미사가 붙은 id 에서는 두 규칙을
6630
- # 동시에 만족하는 이름이 존재하지 않는다.
6631
- unbacked = sorted(
6632
- voter for voter in voters if _analyser_key(voter) not in returned
6633
- )
6634
- if unbacked:
6635
- no_dispatch = sorted(
6636
- voter for voter in unbacked
6637
- if _analyser_key(voter) in never_dispatched
6638
- )
6639
- failures.append(
6640
- "final-report data.json: planBodyVerification records verdicts from "
6641
- f"{unbacked} but no plan-body reverify result file backs them — "
6642
- f"{source}."
6643
- + _unbacked_remedy_clause(report_path, task_type, no_dispatch)
6644
- + " A vote the gate is computed from MUST trace back to a dispatch "
6645
- "that actually returned — otherwise the round can be skipped and "
6646
- 'the gate still read `passed` (plan-body-verification.md §"Round '
6647
- 'protocol" step 3).'
6648
- )
6649
-
6650
-
6651
- _NEAR_MISS_SAMPLE = 6
6652
-
6653
-
6654
- def _unbacked_remedy_clause(
6655
- report_path: Path, task_type: str, no_dispatch: list[str]
6656
- ) -> str:
6657
- """뒷받침 없는 투표에 남길 실제 갈래와 정당한 해소책.
6658
-
6659
- 파일 이름을 바꾸라는 안내를 여기서 걷어냈다. 그 안내는 실행됐고(디렉터리에
6660
- 개명 사본이 남았다), 개명은 어느 디스패치가 그 결과를 냈는지를 지워 검사가
6661
- 막으려던 바로 그 상태 — 대조할 기록이 없는 투표 — 를 만든다.
6662
- """
6663
- parts: list[str] = []
6664
- if no_dispatch:
6665
- parts.append(
6666
- f" No reverify dispatch was recorded at all for {no_dispatch}, so "
6667
- "those verdicts are unbacked: either the round genuinely never ran "
6668
- "(re-dispatch it, or record `verification-error` for the workers "
6669
- "that produced no result) or it ran outside `okstra team dispatch` "
6670
- "and left no `workerDispatches[]` row, which is itself the "
6671
- "violation."
6672
- )
6673
- near = _plan_verify_seq_near_misses(report_path, task_type)
6674
- if near:
6675
- sample = near[:_NEAR_MISS_SAMPLE]
6676
- more = (
6677
- f" (+{len(near) - len(sample)} more; the directory accumulates "
6678
- "every rerun of this task-type)"
6679
- if len(near) > len(sample) else ""
6680
- )
6681
- parts.append(
6682
- f" The directory does hold plan-verify results under other "
6683
- f"sequences — {sample}{more}. If one of those is this round's "
6684
- "output, the round was dispatched under a sequence this report does "
6685
- "not carry: recompute the gate from the files that exist (re-run "
6686
- "`okstra plan-items apply-verdicts --result <worker>=<file>` "
6687
- "against them and re-record the round) so the verdicts and their "
6688
- "evidence agree. Do NOT rename a result file to this report's seq — "
6689
- "renaming destroys the link between a vote and the dispatch that "
6690
- "produced it, which is exactly what this check reads."
6691
- )
6692
- return "".join(parts)
6693
-
6694
-
6695
- _UNIFORM_VERIFIER_MIN_ITEMS = 5
6696
-
6697
-
6698
- def _detect_uniform_verifier(pbv: dict) -> list[str]:
6699
- """Verifiers whose every vote in the round was the same verdict.
6700
-
6701
- `participatingAnalysers` counts whether a worker voted, not whether the
6702
- votes carried information. A verifier that answers AGREE to every item is
6703
- counted as a third opinion while contributing no refutation signal, so the
6704
- report reads as a three-way cross-check backed by two. (fontsninja-nlpvibe
6705
- `nlpvibe-vs-fontradar-baseline` seq 001: 63/63 AGREE off six inspected
6706
- evidence paths, on a round where the two other analysers jointly refuted a
6707
- real defect.)
6708
-
6709
- Advisory only. A unanimous round is a legitimate outcome, and any ratio
6710
- strict enough to catch a rubber stamp also fails honest agreement, so this
6711
- reports the counts and leaves the judgement to the reader.
6712
- """
6713
- items = pbv.get("planItems") if isinstance(pbv, dict) else None
6714
- if not isinstance(items, list):
6715
- return []
6716
- verdicts_by_worker: dict[str, set[str]] = {}
6717
- counts: dict[str, int] = {}
6718
- for item in items:
6719
- if not isinstance(item, dict):
6720
- continue
6721
- for verdict in item.get("verdicts") or []:
6722
- if not isinstance(verdict, dict):
6723
- continue
6724
- worker = str(verdict.get("worker") or "").strip()
6725
- value = str(verdict.get("verdict") or "").strip()
6726
- if not worker or not value or value == "verification-error":
6727
- continue
6728
- verdicts_by_worker.setdefault(worker, set()).add(value)
6729
- counts[worker] = counts.get(worker, 0) + 1
6730
- warnings = []
6731
- for worker in sorted(verdicts_by_worker):
6732
- distinct = verdicts_by_worker[worker]
6733
- total = counts[worker]
6734
- if len(distinct) != 1 or total < _UNIFORM_VERIFIER_MIN_ITEMS:
6735
- continue
6736
- warnings.append(
6737
- f"plan-body verification: {worker} returned `{next(iter(distinct))}` "
6738
- f"for all {total} items it voted on, so this round's refutation "
6739
- "signal came from its peers alone. Confirm the worker actually "
6740
- "opened the cited evidence (its `-audit-` sidecar lists what it "
6741
- "read) before reading the gate as a full cross-check."
6742
- )
6743
- return warnings
6744
-
6745
-
6746
- def _validate_plan_item_extraction_completeness(
6747
- data: dict,
6748
- failures: list[str],
6749
- ) -> None:
6750
- """Require the exact deterministic P-* extraction when a round ran."""
6751
- ip = data.get("implementationPlanning")
6752
- if not isinstance(ip, dict):
6753
- return
6754
- pbv = ip.get("planBodyVerification")
6755
- if not isinstance(pbv, dict):
6756
- return
6757
- round_count = pbv.get("roundCount")
6758
- if not isinstance(round_count, int) or round_count < 1:
6759
- return
6760
- try:
6761
- expected_sequence = expected_plan_item_ids(ip)
6762
- except Exception as exc: # noqa: BLE001
6763
- failures.append(
6764
- "final-report data.json: deterministic plan-item extraction failed: "
6765
- f"{exc}"
6766
- )
6767
- return
6768
-
6769
- actual_sequence = [
6770
- str(item.get("id") or "").strip()
6771
- for item in (pbv.get("planItems") or [])
6772
- if isinstance(item, dict)
6773
- ]
6774
- expected_ids = set(expected_sequence)
6775
- actual_ids = set(actual_sequence)
6776
- missing = expected_ids - actual_ids
6777
- unexpected = actual_ids - expected_ids
6778
- duplicate_ids = {
6779
- item_id for item_id in actual_ids if actual_sequence.count(item_id) > 1
6780
- }
6781
-
6782
- if missing:
6783
- failures.append(
6784
- "final-report data.json: planBodyVerification.planItems is missing "
6785
- "deterministically extracted verdict item ID(s): "
6786
- + ", ".join(sorted(missing))
6787
- )
6788
- if unexpected:
6789
- failures.append(
6790
- "final-report data.json: planBodyVerification.planItems contains "
6791
- "unexpected verdict item ID(s): "
6792
- + ", ".join(sorted(unexpected))
6793
- )
6794
- if duplicate_ids:
6795
- failures.append(
6796
- "final-report data.json: planBodyVerification.planItems contains "
6797
- "duplicate verdict item ID(s): "
6798
- + ", ".join(sorted(duplicate_ids))
6799
- )
6800
-
6801
-
6802
- def _validate_variation_point_analysis(
6803
- vpa: object,
6804
- architecture_style: str,
6805
- failures: list[str],
6806
- ) -> None:
6807
- """Conditional rules + architecture-style overlay for variation points.
6808
-
6809
- The schema enforces shape only. Two layers of meaning sit on top:
6810
-
6811
- Layer 1 is style-agnostic. Declaring "no variation exists" is a claim that
6812
- needs a written reason, and it must not be paired with declared points —
6813
- `plan_items._extract_variation_point_items` emits a lone `P-Var-0` in that
6814
- branch and drops them, so the contradiction would silently exempt every
6815
- declared point from per-point verification. `extract: true` is a claim in
6816
- the same way: it names the interface the next implementation plugs into and
6817
- the Stage Map stage that builds it, so both fields have to be filled. The
6818
- schema cannot carry this as a `minLength` — the empty string is the natural
6819
- shape of an `extract: false` decision.
6820
-
6821
- Layer 2 fires only for a project that declares `architecture.style`
6822
- `hexagonal`: extracting a variation point there means introducing a port,
6823
- not a helper. An unconfigured project resolves to `none` and keeps layer-1
6824
- behaviour only.
6825
- """
6826
- if not isinstance(vpa, dict):
6827
- failures.append("variationPointAnalysis is missing or not an object")
6828
- return
6829
- # Schema violations are reported, not raised, so this check still runs on a
6830
- # malformed block. A type guard here keeps a bad field from aborting the
6831
- # whole validation and discarding every failure collected so far.
6832
- raw_points = vpa.get("points")
6833
- if raw_points is not None and not isinstance(raw_points, list):
6834
- failures.append("variationPointAnalysis: points must be an array")
6835
- return
6836
- points = raw_points or []
6837
- if not bool(vpa.get("hasMultipleImplementations")):
6838
- rationale = vpa.get("noVariationRationale")
6839
- if not isinstance(rationale, str) or not rationale.strip():
6840
- failures.append(
6841
- "variationPointAnalysis: hasMultipleImplementations=false "
6842
- "requires a non-empty noVariationRationale"
6843
- )
6844
- if points:
6845
- failures.append(
6846
- "variationPointAnalysis: hasMultipleImplementations=false but "
6847
- "points is non-empty — declared variation points would be "
6848
- "silently dropped"
6849
- )
6850
- return
6851
- if not points:
6852
- failures.append(
6853
- "variationPointAnalysis: hasMultipleImplementations=true requires "
6854
- "at least one point"
6855
- )
6856
- return
6857
- for index, point in enumerate(points, start=1):
6858
- if not isinstance(point, dict):
6859
- failures.append(
6860
- f"variationPointAnalysis point {index}: must be an object"
6861
- )
6862
- continue
6863
- decision = point.get("extractionDecision")
6864
- if not isinstance(decision, dict):
6865
- continue # shape is the schema's job; don't double-report it.
6866
- if not decision.get("extract"):
6867
- continue
6868
- for field in ("interfaceKind", "coveredBy"):
6869
- value = decision.get(field)
6870
- if not isinstance(value, str) or not value.strip():
6871
- failures.append(
6872
- f"variationPointAnalysis point {index}: extract=true "
6873
- f"requires a non-empty {field}, got {value!r}"
6874
- )
6875
- if architecture_style == "hexagonal" and decision.get("interfaceKind") != "port":
6876
- failures.append(
6877
- f"variationPointAnalysis point {index}: architecture style "
6878
- f"'hexagonal' requires interfaceKind 'port', got "
6879
- f"{decision.get('interfaceKind')!r}"
6880
- )
6881
-
6882
-
6883
- _DESIGN_PREP_CONTRACT = "implementation-design-prep-v1"
6884
- _DESIGN_PREP_REQUEST_STATUSES = {"provisional", "blocked"}
6885
- _DESIGN_PREP_TERMINAL_STATUSES = {"ready", "not-applicable"}
6886
-
6887
-
6888
- def _normalize_report_contracts(raw_contracts: object) -> set[str]:
6889
- if isinstance(raw_contracts, str):
6890
- candidates = [raw_contracts]
6891
- elif isinstance(raw_contracts, (list, tuple, set, frozenset)):
6892
- candidates = raw_contracts
6893
- else:
6894
- return set()
6895
- return {
6896
- value.strip()
6897
- for value in candidates
6898
- if isinstance(value, str) and value.strip()
6899
- }
6900
-
6901
-
6902
- def _design_prep_rows(
6903
- planning: dict,
6904
- failures: list[str],
6905
- ) -> tuple[list[dict], dict[str, dict]] | None:
6906
- preparation = planning.get("designPreparation")
6907
- if not isinstance(preparation, dict):
6908
- failures.append(
6909
- "final-report data.json: implementationPlanning.designPreparation "
6910
- "is malformed; expected an object"
6911
- )
6912
- return None
6913
- raw_items = preparation.get("items")
6914
- if not isinstance(raw_items, list) or any(
6915
- not isinstance(item, dict) for item in raw_items
6916
- ):
6917
- failures.append(
6918
- "final-report data.json: implementationPlanning.designPreparation.items "
6919
- "is malformed; expected an array of objects"
6920
- )
6921
- return None
6922
- items = list(raw_items)
6923
- items_by_id: dict[str, dict] = {}
6924
- for item in items:
6925
- item_id = item.get("id")
6926
- if not isinstance(item_id, str) or not item_id:
6927
- failures.append(
6928
- "final-report data.json: designPreparation item has malformed id"
6929
- )
6930
- continue
6931
- if item_id in items_by_id:
6932
- failures.append(
6933
- f"final-report data.json: duplicate designPreparation item {item_id}"
6934
- )
6935
- items_by_id[item_id] = item
6936
- return items, items_by_id
6937
-
6938
-
6939
- def _validate_design_prep_states(items: list[dict], failures: list[str]) -> None:
6940
- for item in items:
6941
- item_id = str(item.get("id") or "<missing>")
6942
- stage_refs = item.get("stageRefs") or []
6943
- review_at = item.get("reviewAt")
6944
- if isinstance(review_at, dict) and "stage" in review_at:
6945
- if review_at.get("stage") not in stage_refs:
6946
- failures.append(
6947
- f"final-report data.json: {item_id}.reviewAt.stage must belong "
6948
- "to stageRefs"
6949
- )
6950
- status = item.get("status")
6951
- if status == "blocked" and (
6952
- not isinstance(item.get("humanConfirmation"), dict)
6953
- or item["humanConfirmation"].get("required") is not True
6954
- ):
6955
- failures.append(
6956
- f"final-report data.json: blocked {item_id} requires "
6957
- "humanConfirmation.required=true"
6958
- )
6959
- if status in _DESIGN_PREP_REQUEST_STATUSES and not isinstance(
6960
- item.get("requestPath"), str
6961
- ):
6962
- failures.append(
6963
- f"final-report data.json: {status} {item_id} requires requestPath"
6964
- )
6965
- if status in _DESIGN_PREP_TERMINAL_STATUSES and "requestPath" in item:
6966
- failures.append(
6967
- f"final-report data.json: terminal {item_id} must not carry requestPath"
6968
- )
6969
-
6970
-
6971
- def _validate_design_prep_requests(
6972
- data: dict,
6973
- report_path: Path,
6974
- items: list[dict],
6975
- failures: list[str],
6976
- ) -> None:
6977
- data_path = _data_path_for(report_path)
6978
- planning_seq = _design_prep_planning_seq(data_path)
6979
- report_language = _design_prep_report_language(data)
6980
- for item in items:
6981
- if item.get("status") not in _DESIGN_PREP_REQUEST_STATUSES:
6982
- continue
6983
- item_id = str(item.get("id") or "<missing>")
6984
- target, expected = _render_design_prep_request(
6985
- data_path=data_path,
6986
- planning_seq=planning_seq,
6987
- report_language=report_language,
6988
- item=item,
6989
- )
6990
- if not target.is_file():
6991
- failures.append(
6992
- f"final-report data.json: design-prep request is missing for {item_id}: "
6993
- f"{target}"
6994
- )
6995
- continue
6996
- try:
6997
- actual = target.read_bytes()
6998
- except OSError as exc:
6999
- failures.append(
7000
- f"final-report data.json: cannot read design-prep request for "
7001
- f"{item_id}: {exc}"
7002
- )
7003
- continue
7004
- # 동일성 판정은 조립(`design_prep._request_conflicts`)과 같은 신원을 쓴다.
7005
- # 두 곳이 다른 기준을 걸면 조립이 통과시킨 파일을 검증이 stale 로 떨어뜨려,
7006
- # run 이 고칠 수 없는 실패에 갇힌다 — 이월된 요청은 발행 리포트 줄만
7007
- # 다르고, 그 줄을 현행화하려면 이전 run 의 기록을 덮어써야 한다.
7008
- if _design_prep_request_identity(actual) != _design_prep_request_identity(
7009
- expected
7010
- ):
7011
- failures.append(
7012
- f"final-report data.json: design-prep request content or "
7013
- f"fingerprint is stale for {item_id}: {target}"
7014
- )
7015
-
7016
-
7017
- def _validate_design_prep_contract(
7018
- data: dict,
7019
- report_path: Path | None,
7020
- report_contracts: set[str],
7021
- failures: list[str],
7022
- ) -> list[str]:
7023
- warnings: list[str] = []
7024
- planning = data.get("implementationPlanning")
7025
- if not isinstance(planning, dict):
7026
- if _DESIGN_PREP_CONTRACT in report_contracts:
7027
- failures.append(
7028
- "final-report data.json: implementationPlanning is malformed"
7029
- )
7030
- return warnings
7031
- preparation = planning.get("designPreparation")
7032
- if not isinstance(preparation, dict):
7033
- if _DESIGN_PREP_CONTRACT in report_contracts:
7034
- failures.append(
7035
- "final-report data.json: marker implementation-design-prep-v1 "
7036
- "requires implementationPlanning.designPreparation"
7037
- )
7038
- else:
7039
- warnings.append("legacy-unassessed")
7040
- return warnings
7041
- if _DESIGN_PREP_CONTRACT not in report_contracts:
7042
- return warnings
7043
- try:
7044
- # 탐지기 재실행 대조와 prep 항목 ↔ coverage 양방향 참조 대조는
7045
- # 삭제했다. 둘 다 `design_snapshot.build` 가 한 번에 만든 값을 같은
7046
- # 입력으로 되계산해 자기 자신과 맞춰 보는 항등식이었다.
7047
- parsed = _design_prep_rows(planning, failures)
7048
- if parsed is None:
7049
- return warnings
7050
- items, _ = parsed
7051
- _validate_design_prep_states(items, failures)
7052
- if report_path is not None:
7053
- _validate_design_prep_requests(data, report_path, items, failures)
7054
- except (DesignSurfaceError, DesignPrepError, KeyError, TypeError, ValueError) as exc:
7055
- failures.append(
7056
- "final-report data.json: design-preparation contract is malformed: "
7057
- f"{exc}"
7058
- )
7059
- except Exception as exc: # noqa: BLE001
7060
- failures.append(
7061
- "final-report data.json: design-preparation validation failed closed "
7062
- f"on malformed input: {exc}"
7063
- )
7064
- return warnings
7065
-
7066
-
7067
- # A `subject` this short or shaped like a bare `P-Opt-1` id is a placeholder,
7068
- # not the plain-language "what this item is" label §5.5.9 renders as a heading.
7069
- _MIN_SUBJECT_LEN = 3
7070
- _BARE_PLAN_ITEM_ID_RE = re.compile(r"^P-(?:Opt|Step|Dep|Val|Rb|Req)-\d+$", re.IGNORECASE)
7071
-
7072
-
7073
- def _validate_plan_item_subject_substance(data: dict, failures: list[str]) -> None:
7074
- """H2 follow-up — `planItems[].subject` must be a real label, not a
7075
- placeholder. The schema only enforces non-empty, so `"x"` or a copied
7076
- `P-Opt-1` id would otherwise slip through and defeat the whole point of the
7077
- subject (letting a reader see *what* each AGREE/DISAGREE is about).
7078
- """
7079
- ip = data.get("implementationPlanning")
7080
- if not isinstance(ip, dict):
7081
- return
7082
- pbv = ip.get("planBodyVerification")
7083
- if not isinstance(pbv, dict):
7084
- return
7085
- for item in pbv.get("planItems") or []:
7086
- if not isinstance(item, dict):
7087
- continue
7088
- item_id = str(item.get("id") or "").strip()
7089
- subject = str(item.get("subject") or "").strip()
7090
- if (
7091
- len(subject) < _MIN_SUBJECT_LEN
7092
- or subject == item_id
7093
- or _BARE_PLAN_ITEM_ID_RE.match(subject)
7094
- ):
3989
+
3990
+ fallback = header.get("leadAuthoredFallback")
3991
+ if not isinstance(fallback, Mapping):
3992
+ failures.append(
3993
+ "final-report data.json: `header.reportAuthor` is `Okstra lead` but "
3994
+ "`header.leadAuthoredFallback` is absent. The approval passes the "
3995
+ "gate; it does not erase it — the failure reason and the approving "
3996
+ "sidecar belong in the report a human reads, not only in the "
3997
+ "sidecars they would have to go find"
3998
+ )
3999
+ else:
4000
+ recorded = str(fallback.get("dispatchFailureReason") or "").strip()
4001
+ reasons = {str(row.get("reason") or "").strip() for row in failed}
4002
+ if recorded and reasons and recorded not in reasons:
7095
4003
  failures.append(
7096
- f"final-report data.json: plan item `{item_id or '<unknown>'}` has a "
7097
- f"placeholder subject `{subject}`. Give a plain-language label of "
7098
- "what the item is (e.g. 'Option A: upload v2 를 신규 모듈로 분리') so "
7099
- "the §5.5.9 reader knows what each verdict is about "
7100
- "(plan-body-verification.md Plan-item extraction)."
4004
+ "final-report data.json: "
4005
+ "`header.leadAuthoredFallback.dispatchFailureReason` does not "
4006
+ "match any reason recorded on a failed report-writer dispatch. "
4007
+ "Quote the dispatch row verbatim rather than restating it"
7101
4008
  )
7102
4009
 
4010
+ approval = _report_authoring_approval(report_path)
4011
+ if approval != _REPORT_AUTHORING_APPROVED:
4012
+ found = f"`{approval}`" if approval else "no `## REPORT AUTHORING` block"
4013
+ failures.append(
4014
+ "final-report data.json: `header.reportAuthor` is `Okstra lead` but "
4015
+ f"the run's `user-responses/` sidecars carry {found}. Only the user "
4016
+ "may permit the lead to author the report; ask at a gate and have "
4017
+ "the answer written through `okstra user-response write`"
4018
+ )
4019
+
7103
4020
 
7104
- def _validate_plan_body_clarification_matching(
4021
+ def _validate_stage_carry_sidecar_exists(
7105
4022
  data: dict,
4023
+ report_path: Path,
7106
4024
  failures: list[str],
7107
- accepted_item_ids: set[str] | None = None,
7108
4025
  ) -> None:
7109
- """H5 — every plan item whose *gate class after stage scope* is
7110
- `majority-disagree` must point at an existing `blocks: approval`
7111
- clarification row. Observed / deferred / record items stay in `setAside`
7112
- and must not become a new C row — that is what grew the clarification
7113
- list while the next stage was already executable.
4026
+ """The stage carry sidecar must exist on disk, not only be transcribed.
4027
+
4028
+ `implementation.stageSidecarEvidence` is prose the report quotes, so a
4029
+ report can describe a sidecar that was never written. `consumers` treats
4030
+ the carry file as the source of truth for marking a stage `done`, so a
4031
+ missing file leaves the stage permanently un-done and blocks every
4032
+ dependent stage with a `PrepareError` — while the run that caused it
4033
+ finished reporting success.
7114
4034
  """
7115
- ip = data.get("implementationPlanning")
7116
- if not isinstance(ip, dict):
4035
+ implementation = data.get("implementation")
4036
+ if not isinstance(implementation, dict):
7117
4037
  return
7118
- pbv = ip.get("planBodyVerification")
7119
- if not isinstance(pbv, dict):
4038
+ evidence = implementation.get("stageSidecarEvidence")
4039
+ if not isinstance(evidence, dict):
7120
4040
  return
7121
- round_count = pbv.get("roundCount")
7122
- if not isinstance(round_count, int) or round_count < 1:
4041
+ stage = evidence.get("stageNumber")
4042
+ if not isinstance(stage, int):
7123
4043
  return
7124
- # `gating=false` 는 이 라운드를 자문으로 돌린다 — plan-body-verification.md
7125
- # "If `false`, the round is advisory-only and never blocks approval".
7126
- # 게이트 계산은 이미 그것을 존중한다(`_recompute_plan_body_gate`,
7127
- # `_gate_blocking_causes`). 이 검사만 그 상태를 안 보면 자문 라운드가
7128
- # 승인 차단 행을 강제하게 되어, 막지 않기로 한 판정이 다시 막는다.
7129
- if pbv.get("gating") is False and not requires_plan_repair(pbv):
4044
+ # A stage whose verifier returned FAIL must NOT persist its carry: the carry
4045
+ # file is what marks the stage `done`, and doing that would stack the next
4046
+ # stage on a confirmed regression. Such a run states the reason in
4047
+ # `withheld` and records a `failed` consumers row instead, so the absent
4048
+ # file is the correct outcome, not a gap.
4049
+ if str(evidence.get("withheld") or "").strip():
7130
4050
  return
7131
- accepted = (
7132
- _resolved_noncritical_dissent_ids(data)
7133
- if accepted_item_ids is None
7134
- else accepted_item_ids
7135
- )
7136
- clar_rows = [r for r in (data.get("clarificationItems") or []) if isinstance(r, dict)]
7137
- all_ids = {r.get("id") for r in clar_rows if r.get("id")}
7138
- approval_ids = {r.get("id") for r in clar_rows if r.get("blocks") == "approval" and r.get("id")}
7139
- for item in pbv.get("planItems") or []:
7140
- if not isinstance(item, dict):
7141
- continue
7142
- if _plan_item_gate_class(item, pbv, accepted) != "majority-disagree":
7143
- continue
7144
- item_id = item.get("id") or "<unknown>"
7145
- cids = _plan_item_clarification_ids(item)
7146
- if not cids:
7147
- failures.append(
7148
- f"final-report data.json: plan item `{item_id}` is majority-disagree "
7149
- "but carries no `clarificationRefs`. A blocking disagreement MUST "
7150
- "surface as a `## 1. Clarification Items` row (blocks=approval) so "
7151
- "the user sees the blocker (implementation-planning.md self-review "
7152
- "step 12). Report assembly derives this link from the activity "
7153
- "ledger's `clarificationRefs[]` + `planItemIds[]`, so record the "
7154
- "decision through `okstra approval-decision` rather than editing "
7155
- "the report."
7156
- )
7157
- continue
7158
- for cid in sorted(cids - approval_ids):
7159
- reason = (
7160
- "references a non-existent §1 row"
7161
- if cid not in all_ids
7162
- else "references a §1 row whose `blocks` is not `approval`"
7163
- )
7164
- failures.append(
7165
- f"final-report data.json: plan item `{item_id}` (majority-disagree) "
7166
- f"has clarificationRefs entry `{cid}` which {reason}. Every "
7167
- "majority-disagree item MUST reach a `blocks: approval` "
7168
- "Clarification row."
7169
- )
4051
+ # Carry sidecars are stage-SHARED: the next stage's carry-in and
4052
+ # `consumers.backfill_done_from_carry` glob them without knowing the
4053
+ # producing run's layout. `RunRef.carry()` owns that flat-vs-staged rule;
4054
+ # resolving it under the stage run dir forced the lead to write it twice.
4055
+ carry_path = RunRef.from_report_path(report_path).carry(stage)
4056
+ if not carry_path.exists():
4057
+ failures.append(
4058
+ f"implementation run declares stage-{stage} sidecar evidence but "
4059
+ f"`{carry_path.parent.name}/{carry_path.name}` does not exist. The "
4060
+ "carry file is what marks the stage `done` for dependent stages; "
4061
+ "without it this stage never completes and every successor fails "
4062
+ "to prepare, even though this run reported success "
4063
+ '(_implementation-executor.md §"Sidecar evidence writer").'
4064
+ )
7170
4065
 
7171
4066
 
7172
- def _validate_self_fix_before_clarification(data: dict, failures: list[str]) -> None:
7173
- """A planner-fixable defect MUST exhaust the self-fix budget before it is
7174
- promoted to a `## 1. Clarification Items` row. Closes the hole where the
7175
- lead dumps a fixable plan defect (abbreviated path, prose command,
7176
- placeholder, coverage remap) onto the user instead of having report-writer
7177
- correct it (plan-body-verification.md "Self-fix round").
7178
- """
7179
- ip = data.get("implementationPlanning")
7180
- if not isinstance(ip, dict):
7181
- return
7182
- pbv = ip.get("planBodyVerification")
7183
- if not isinstance(pbv, dict):
7184
- return
7185
- round_count = pbv.get("roundCount")
7186
- if not isinstance(round_count, int) or round_count < 1:
7187
- return
7188
- # `gating=false` 는 이 라운드를 자문으로 돌린다 — plan-body-verification.md
7189
- # "If `false`, the round is advisory-only and never blocks approval" 이고
7190
- # 같은 행이 "does not run the self-fix loop" 라고 못박는다.
7191
- #
7192
- # 이 검사가 그 상태를 안 보면 `_validate_advisory_plan_body_gating` 과 정면
7193
- # 충돌한다: 그쪽은 `gating=false` 에서 `selfFixRoundsApplied > 0` 을 실패로
7194
- # 잡는데 이 검사는 `>= 1` 을 요구한다. 한 필드에 반대 방향 요구가 걸리므로
7195
- # 자문 라운드에 planner-fixable 과반 반대가 하나라도 나오면 통과 가능한
7196
- # 값이 없다. `okstra plan-items complete-round` 도 자문 라운드의 self-fix
7197
- # 기록을 거부하므로(plan_items_cli.py) 우회로도 없다.
7198
- if pbv.get("gating") is False and not requires_plan_repair(pbv):
7199
- return
7200
- if _self_fix_budget_exhausted(pbv):
7201
- return
7202
- rounds_applied = pbv.get("selfFixRoundsApplied")
7203
- stop_reason = pbv.get("selfFixStopReason")
7204
- for item in pbv.get("planItems") or []:
7205
- if not isinstance(item, dict):
7206
- continue
7207
- if _plan_item_gate_class(item, pbv, set()) != "majority-disagree":
7208
- continue
7209
- if _has_planner_fixable_majority(item):
7210
- allowed = " / ".join(sorted(_SELF_FIX_EXHAUSTED_REASONS))
7211
- failures.append(
7212
- "final-report data.json: plan item "
7213
- f"`{item.get('id') or '<unknown>'}` is majority-disagree with a "
7214
- f"planner-fixable majority but the self-fix budget is not "
7215
- f"exhausted (`selfFixRoundsApplied`={rounds_applied!r}, "
7216
- f"`selfFixStopReason`={stop_reason!r}; need >=1 rounds and a stop "
7217
- f"reason of {allowed}). A planner-fixable defect MUST be corrected "
7218
- "by report-writer self-fix rounds until the budget runs out or a "
7219
- "round makes no progress, before it becomes a clarification row "
7220
- "(plan-body-verification.md Self-fix round)."
7221
- )
4067
+ def _normalize_report_contracts(raw_contracts: object) -> set[str]:
4068
+ if isinstance(raw_contracts, str):
4069
+ candidates = [raw_contracts]
4070
+ elif isinstance(raw_contracts, (list, tuple, set, frozenset)):
4071
+ candidates = raw_contracts
4072
+ else:
4073
+ return set()
4074
+ return {
4075
+ value.strip()
4076
+ for value in candidates
4077
+ if isinstance(value, str) and value.strip()
4078
+ }
7222
4079
 
7223
4080
 
7224
4081
  CRITIC_UNVERIFIED_SOURCE = "critic-unverified"
@@ -7270,162 +4127,6 @@ def _validate_unverified_critic_gaps_recorded(data: dict, failures: list[str]) -
7270
4127
  )
7271
4128
 
7272
4129
 
7273
- # Allowed `fixability` values, mirroring the schema enum
7274
- # (schemas/final-report-v2.0.schema.json planItems[].verdicts[].fixability).
7275
- _FIXABILITY_VALUES = frozenset({"planner-fixable", "needs-user-input"})
7276
-
7277
-
7278
- def _validate_disagree_has_fixability(data: dict, failures: list[str]) -> None:
7279
- """Every `DISAGREE` verdict MUST carry a valid `fixability`
7280
- (`planner-fixable` / `needs-user-input`). The schema enum only constrains a
7281
- *present* value; it does not require the field, so a DISAGREE with a missing
7282
- or mislabelled fixability is schema-valid. That silently degrades
7283
- `_validate_self_fix_before_clarification`, which counts a non-`planner-fixable`
7284
- DISAGREE as non-fixable and thus lets a genuinely planner-fixable defect
7285
- skip the self-fix round and land on the user. This is the enforcement point
7286
- for the "fixability is DISAGREE-only 필수" MUST in plan-body-verification.md.
7287
- """
7288
- ip = data.get("implementationPlanning")
7289
- if not isinstance(ip, dict):
7290
- return
7291
- pbv = ip.get("planBodyVerification")
7292
- if not isinstance(pbv, dict):
7293
- return
7294
- round_count = pbv.get("roundCount")
7295
- if not isinstance(round_count, int) or round_count < 1:
7296
- return
7297
- for item in pbv.get("planItems") or []:
7298
- if not isinstance(item, dict):
7299
- continue
7300
- item_id = item.get("id") or "<unknown>"
7301
- for verdict in item.get("verdicts") or []:
7302
- if not isinstance(verdict, dict):
7303
- continue
7304
- if str(verdict.get("verdict") or "").upper() != "DISAGREE":
7305
- continue
7306
- fixability = verdict.get("fixability")
7307
- if fixability not in _FIXABILITY_VALUES:
7308
- worker = verdict.get("worker") or "<worker>"
7309
- allowed = " / ".join(sorted(_FIXABILITY_VALUES))
7310
- failures.append(
7311
- f"final-report data.json: plan item `{item_id}` has a "
7312
- f"`DISAGREE` verdict from `{worker}` with "
7313
- f"fixability `{fixability}` — a DISAGREE MUST declare a "
7314
- f"fixability of {allowed}. A missing/invalid value is "
7315
- "counted as non-fixable and lets a planner-fixable defect "
7316
- "skip the self-fix round (plan-body-verification.md "
7317
- "\"fixability (DISAGREE 전용, 필수)\")."
7318
- )
7319
-
7320
-
7321
- def validate_plan_body_section(
7322
- data: dict,
7323
- report_path: Path,
7324
- failures: list[str],
7325
- ) -> list[str]:
7326
- """Run every §5.5.9 plan-body check and return the advisory warnings.
7327
-
7328
- Grouped into one callable so the round protocol can run the same checks at
7329
- each round boundary that the full run validation runs at the end. Before
7330
- this seam existed the only way to reach them was a finished report plus all
7331
- four manifests, so a lead computing the gate by hand mid-loop had nothing to
7332
- check itself against until Phase 7 — and a whole self-fix budget could be
7333
- spent against a mis-scored gate.
7334
- """
7335
- pbv = (data.get("implementationPlanning") or {}).get("planBodyVerification") or {}
7336
- accepted_item_ids = _resolved_noncritical_dissent_ids(data)
7337
- _validate_plan_body_gate_recompute(data, failures, accepted_item_ids)
7338
- _validate_gate_blocked_by(data, failures, accepted_item_ids)
7339
- _validate_participating_analysers(data, failures)
7340
- _validate_self_fix_grouping(data, failures)
7341
- _validate_plan_body_verdict_provenance(data, report_path, failures)
7342
- _validate_aborted_gate_has_clarification(data, failures)
7343
- _validate_round_recorded_verdicts(data, failures)
7344
- _validate_verdicts_match_current_subjects(data, failures)
7345
- _validate_verdict_rounds_outlive_self_fix(data, failures)
7346
- _validate_unresolved_tie_was_reverified(data, failures)
7347
- _validate_advisory_plan_body_gating(data, failures)
7348
- _validate_plan_item_extraction_completeness(data, failures)
7349
- _validate_plan_item_subject_substance(data, failures)
7350
- _validate_plan_body_clarification_matching(data, failures, accepted_item_ids)
7351
- _validate_disagree_has_fixability(data, failures)
7352
- _validate_self_fix_before_clarification(data, failures)
7353
- return [*_detect_self_fix_recurrence(pbv), *_detect_uniform_verifier(pbv)]
7354
-
7355
-
7356
- def _gate_summary_item(
7357
- item: dict,
7358
- pbv: dict,
7359
- accepted_item_ids: set[str],
7360
- ) -> dict:
7361
- """One `gate.items[]` row: the gate class plus its state-file counterpart."""
7362
- classification = _plan_item_gate_class(item, pbv, accepted_item_ids)
7363
- return {
7364
- "id": item.get("id"),
7365
- "classification": classification,
7366
- "stateClassification": _state_classification(item, classification),
7367
- "correctnessCritical": _is_correctness_critical(item),
7368
- "decisionAuthority": _plan_item_decision_authority(item, pbv),
7369
- "leadDecisionApplied": _lead_decision_applies(item, pbv),
7370
- # 왜 안 막는지가 기록에 남아야 한다. 이 값이 없으면 범위 밖 강등과
7371
- # 실제 합의가 산출물에서 같은 모양으로 읽힌다.
7372
- "stageScope": _stage_scope_bucket(item, pbv),
7373
- "block": str(item.get("block") or "execution"),
7374
- }
7375
-
7376
-
7377
- def plan_body_gate_summary(data: dict) -> dict | None:
7378
- """The §5.5.9 gate as the round protocol's step 5 needs it — per-item
7379
- classification, the whole-gate value, and the `gateBlockedBy` causes, all
7380
- recomputed from `planItems[].verdicts`. Returns ``None`` when the report
7381
- carries no plan items to judge.
7382
-
7383
- This is what a lead records instead of tallying the verdicts by hand: the
7384
- single-vote-blocking kinds, the advisory-only kinds and the P-Var / P-Rb
7385
- exemptions are one implementation here, not a rule to be re-derived per
7386
- round from the prompt's prose.
7387
- """
7388
- ip = data.get("implementationPlanning")
7389
- if not isinstance(ip, dict):
7390
- return None
7391
- pbv = ip.get("planBodyVerification")
7392
- if not isinstance(pbv, dict):
7393
- return None
7394
- accepted_item_ids = _resolved_noncritical_dissent_ids(data)
7395
- recomputed = _recompute_plan_body_gate(pbv, accepted_item_ids)
7396
- if recomputed is None:
7397
- return None
7398
- items = [
7399
- _gate_summary_item(item, pbv, accepted_item_ids)
7400
- for item in (pbv.get("planItems") or [])
7401
- if isinstance(item, dict)
7402
- ]
7403
- coverage_blockers = _independent_coverage_blockers(ip, pbv)
7404
- return {
7405
- "declared": pbv.get("gateResult"),
7406
- "recomputed": recomputed,
7407
- "declaredBlockedBy": sorted(
7408
- str(c) for c in (pbv.get("gateBlockedBy") or []) if isinstance(c, str)
7409
- ),
7410
- "blockedBy": sorted(
7411
- _gate_blocking_causes(pbv, coverage_blockers, accepted_item_ids)
7412
- ),
7413
- "coverageBlockers": coverage_blockers,
7414
- "setAside": _set_aside_register(pbv, accepted_item_ids),
7415
- "blockingItems": [
7416
- item["id"] for item in items if item["classification"] == "majority-disagree"
7417
- ],
7418
- "items": items,
7419
- }
7420
-
7421
-
7422
- _COVERED_BY_ANCHOR_RE = re.compile(r"option|stage|step", re.IGNORECASE)
7423
- _COVERED_BY_STAGE_REF_RE = re.compile(r"stage\s*(\d+)", re.IGNORECASE)
7424
- _COVERED_BY_VAGUE = {"recommended option", "the recommended option", "recommended"}
7425
- _DEVIATION_DECISION_REF_RE = re.compile(r"^(C-\d{3,}|D-\d{4,})$")
7426
- _DEVIATION_BLOCKED_DISPOSITION_RE = re.compile(r"^blocked (C-\d{3,})$")
7427
-
7428
-
7429
4130
  def _carried_decision_map(
7430
4131
  run_manifest: Mapping[str, Any] | None,
7431
4132
  *,
@@ -7461,221 +4162,6 @@ def _carried_decision_map(
7461
4162
  return carried
7462
4163
 
7463
4164
 
7464
- def _deviation_target(
7465
- ref: str,
7466
- clarifications: dict,
7467
- decisions: dict,
7468
- carried: dict,
7469
- ) -> dict | None:
7470
- if ref.startswith("C-"):
7471
- row = clarifications.get(ref) or carried.get(ref)
7472
- return row if isinstance(row, dict) else None
7473
- row = decisions.get(ref)
7474
- return row if isinstance(row, dict) else None
7475
-
7476
-
7477
- def _deviation_is_user_confirmed(
7478
- ref: str, clarifications: dict, carried: dict,
7479
- ) -> bool:
7480
- row = clarifications.get(ref)
7481
- if (
7482
- isinstance(row, dict)
7483
- and row.get("status") in {"answered", "resolved"}
7484
- and str(row.get("userInput") or "").strip()
7485
- ):
7486
- return True
7487
- carried_row = carried.get(ref)
7488
- if not isinstance(carried_row, dict):
7489
- return False
7490
- resolution = carried_row.get("resolutionInput")
7491
- if isinstance(resolution, dict) and str(resolution.get("userText") or "").strip():
7492
- return True
7493
- return bool(str(carried_row.get("userConfirmation") or "").strip())
7494
-
7495
-
7496
- def _resolved_deviation_refs(
7497
- row_id: str,
7498
- refs: object,
7499
- clarifications: dict,
7500
- decisions: dict,
7501
- failures: list[str],
7502
- carried: dict | None = None,
7503
- ledger_ids: set[str] | None = None,
7504
- ) -> list[str]:
7505
- valid_refs: list[str] = []
7506
- carried_rows = carried or {}
7507
- known_ledger = ledger_ids or set()
7508
- for ref in refs if isinstance(refs, list) else []:
7509
- if not isinstance(ref, str) or not _DEVIATION_DECISION_REF_RE.fullmatch(ref):
7510
- failures.append(
7511
- f"final-report data.json: requirementCoverage `{row_id}` has "
7512
- f"unsupported decisionRef `{ref}`; expected C-NNN or D-NNNN."
7513
- )
7514
- continue
7515
- if (
7516
- _deviation_target(ref, clarifications, decisions, carried_rows) is None
7517
- and ref not in known_ledger
7518
- ):
7519
- failures.append(
7520
- f"final-report data.json: requirementCoverage `{row_id}` "
7521
- f"decisionRef `{ref}` does not exist in this report."
7522
- )
7523
- continue
7524
- valid_refs.append(ref)
7525
- return valid_refs
7526
-
7527
-
7528
- def _validate_deviation_disposition(
7529
- row_id: str,
7530
- disposition: object,
7531
- refs: list[str],
7532
- clarifications: dict,
7533
- failures: list[str],
7534
- carried: dict | None = None,
7535
- ledger_ids: set[str] | None = None,
7536
- ) -> None:
7537
- carried_rows = carried or {}
7538
- known_ledger = ledger_ids or set()
7539
- if disposition == "accepted":
7540
- confirmed = any(
7541
- ref.startswith("C-")
7542
- and (
7543
- ref in known_ledger
7544
- or _deviation_is_user_confirmed(ref, clarifications, carried_rows)
7545
- )
7546
- for ref in refs
7547
- )
7548
- if not confirmed:
7549
- failures.append(
7550
- f"final-report data.json: requirementCoverage `{row_id}` is "
7551
- "documented-deviation with approvalDisposition `accepted`, but "
7552
- "none of its decisionRefs is a user-confirmed clarification "
7553
- "(`status` answered/resolved with non-empty `userInput`)."
7554
- )
7555
- return
7556
- blocked = (
7557
- _DEVIATION_BLOCKED_DISPOSITION_RE.fullmatch(disposition)
7558
- if isinstance(disposition, str)
7559
- else None
7560
- )
7561
- if blocked is None:
7562
- return
7563
- clarification_id = blocked.group(1)
7564
- clarification = clarifications.get(clarification_id) or carried_rows.get(
7565
- clarification_id
7566
- )
7567
- if clarification is None:
7568
- failures.append(
7569
- f"final-report data.json: requirementCoverage `{row_id}` "
7570
- f"approvalDisposition references `{clarification_id}`, which does "
7571
- "not exist in this report."
7572
- )
7573
- elif (
7574
- clarification.get("status") != "open"
7575
- or clarification.get("blocks") != "approval"
7576
- ):
7577
- failures.append(
7578
- f"final-report data.json: requirementCoverage `{row_id}` "
7579
- f"approvalDisposition `{disposition}` must point to an open "
7580
- "clarification with `blocks: approval`."
7581
- )
7582
-
7583
-
7584
- def _validate_requirement_deviations(
7585
- data: dict, failures: list[str], *, carried: dict | None = None,
7586
- ) -> None:
7587
- """Require documented deviations to reference real decisions and approval."""
7588
- planning = data.get("implementationPlanning")
7589
- if not isinstance(planning, dict):
7590
- return
7591
- clarifications = {
7592
- row.get("id"): row
7593
- for row in (data.get("clarificationItems") or [])
7594
- if isinstance(row, dict) and row.get("id")
7595
- }
7596
- decisions = {
7597
- f"D-{row.get('number')}": row
7598
- for row in (planning.get("decisionDrafts") or [])
7599
- if isinstance(row, dict) and row.get("number")
7600
- }
7601
- carried_rows = carried or {}
7602
- ledger_ids = {
7603
- str(entry.get("clarificationId") or "").strip()
7604
- for entry in (planning.get("supersessionLedger") or [])
7605
- if isinstance(entry, dict) and str(entry.get("clarificationId") or "").strip()
7606
- }
7607
- for row in planning.get("requirementCoverage") or []:
7608
- if not isinstance(row, dict) or row.get("status") != "documented-deviation":
7609
- continue
7610
- row_id = row.get("id") or "<row>"
7611
- refs = _resolved_deviation_refs(
7612
- row_id,
7613
- row.get("decisionRefs"),
7614
- clarifications,
7615
- decisions,
7616
- failures,
7617
- carried_rows,
7618
- ledger_ids,
7619
- )
7620
- _validate_deviation_disposition(
7621
- row_id,
7622
- row.get("approvalDisposition"),
7623
- refs,
7624
- clarifications,
7625
- failures,
7626
- carried_rows,
7627
- ledger_ids,
7628
- )
7629
-
7630
-
7631
- def _validate_requirement_coverage_covered_by(data: dict, failures: list[str]) -> None:
7632
- """H3 (partial) — a `covered` requirement row's `coveredBy` must name a
7633
- concrete plan element that actually exists, not the spec-forbidden bare
7634
- "recommended option" nor a phantom stage. Whether the cited step truly
7635
- *satisfies* the requirement stays a worker DISAGREE(f) judgment; this closes
7636
- the coarser hole where `coveredBy` points at nothing real.
7637
- """
7638
- ip = data.get("implementationPlanning")
7639
- if not isinstance(ip, dict):
7640
- return
7641
- rows = [r for r in (ip.get("requirementCoverage") or []) if isinstance(r, dict)]
7642
- if not rows:
7643
- return
7644
- stage_numbers = {
7645
- s.get("stage") for s in (ip.get("stages") or []) if isinstance(s, dict)
7646
- }
7647
- for row in rows:
7648
- if row.get("status") != "covered":
7649
- continue
7650
- rid = row.get("id") or "<row>"
7651
- covered = str(row.get("coveredBy") or "").strip()
7652
- if covered.lower() in _COVERED_BY_VAGUE:
7653
- failures.append(
7654
- f"final-report data.json: requirementCoverage `{rid}` is `covered` "
7655
- f"but coveredBy is just `{covered}`. Name the specific Option "
7656
- "Candidate and Stage/Step that satisfies it, not 'recommended "
7657
- "option' (profile Requirement Coverage)."
7658
- )
7659
- continue
7660
- if not _COVERED_BY_ANCHOR_RE.search(covered):
7661
- failures.append(
7662
- f"final-report data.json: requirementCoverage `{rid}` coveredBy "
7663
- f"`{covered}` names no Option / Stage / Step. A `covered` row must "
7664
- "cite the concrete plan element that satisfies the requirement."
7665
- )
7666
- continue
7667
- phantom = [
7668
- n for m in _COVERED_BY_STAGE_REF_RE.finditer(covered)
7669
- if stage_numbers and (n := int(m.group(1))) not in stage_numbers
7670
- ]
7671
- if phantom:
7672
- failures.append(
7673
- f"final-report data.json: requirementCoverage `{rid}` coveredBy "
7674
- f"cites Stage {phantom[0]} which does not exist in the Stage Map. "
7675
- "A coverage row must point at a real stage."
7676
- )
7677
-
7678
-
7679
4165
  # The phases that read the run's brief. Resolving the brief outside this set
7680
4166
  # would fire the missing-brief warning on runs (implementation,
7681
4167
  # final-verification, release-handoff) that never consult one.
@@ -7855,107 +4341,6 @@ def _validate_end_state_coverage(
7855
4341
  )
7856
4342
 
7857
4343
 
7858
- def _validate_requirement_provenance(
7859
- data: dict, brief_path: Path, failures: list[str]
7860
- ) -> None:
7861
- """Every requirement must declare where it came from.
7862
-
7863
- The planning input template already states that any change beyond what
7864
- `Requirement Summary` demands is out of scope by default; without this
7865
- check that sentence has no enforcement point and a phase can invent
7866
- requirements freely.
7867
- """
7868
- ip = data.get("implementationPlanning")
7869
- if not isinstance(ip, dict):
7870
- return
7871
- rows = [r for r in (ip.get("requirementCoverage") or []) if isinstance(r, dict)]
7872
- if not rows:
7873
- return
7874
-
7875
- refs = {
7876
- str(r.get("id") or "").strip(): parse_source(str(r.get("source") or ""))
7877
- for r in rows
7878
- }
7879
- headings = brief_headings(brief_path)
7880
- end_state = brief_end_state_ids(brief_path)
7881
-
7882
- for rid, ref in refs.items():
7883
- problem = brief_citation_problem(ref, headings, end_state)
7884
- if problem:
7885
- failures.append(
7886
- f"final-report data.json: requirementCoverage `{rid}` {problem}. A "
7887
- "requirement must trace to a line the reporter actually wrote."
7888
- )
7889
-
7890
- for rid, verdict in resolve_chain(refs).items():
7891
- if verdict != "ok":
7892
- failures.append(
7893
- f"final-report data.json: requirementCoverage `{rid}` provenance "
7894
- f"failed — {verdict}. An item with no admissible source is not a "
7895
- "requirement: raise it as a `Blocks=approval` clarification instead."
7896
- )
7897
-
7898
-
7899
- # Deliberately not `_COVERED_BY_STAGE_REF_RE`: the two directions disagree on
7900
- # what "no match" means — forward treats it as nothing to verify and passes,
7901
- # reverse treats it as nothing cited and orphans every stage — so the reverse
7902
- # reader must cover `Stages 1, 2` and `Stage 1 and 2` too.
7903
- #
7904
- # It reads hand-enumerated numbers only. A range's interior is deliberately NOT
7905
- # coverage evidence: one `Stages 1-64` cell would otherwise stamp every stage in
7906
- # the map while the planner confirmed none of them. The incremental-scope
7907
- # back-trace keeps the widening reader (`cited_stage_numbers`) because "could
7908
- # this answer reach stage 5" is the opposite question and must over-approximate.
7909
- _enumerated_stage_numbers = enumerated_stage_numbers
7910
-
7911
-
7912
- # Statuses under which a row asserts the plan does work for the requirement.
7913
- # `gap` is excluded: it states the plan does NOT cover the requirement, so it
7914
- # can justify no stage. `blocked C-NNN` and `documented-deviation` are included:
7915
- # each records a real requirement with a planned treatment, even when the
7916
- # treatment awaits approval or deliberately differs from the original request.
7917
- _COVERAGE_CLAIMING_STATUS_RE = re.compile(
7918
- r"^(covered|blocked C-\d{3,}|documented-deviation)$"
7919
- )
7920
-
7921
-
7922
- def _validate_stage_has_requirement(data: dict, failures: list[str]) -> None:
7923
- """Reverse of `_validate_requirement_coverage_covered_by`.
7924
-
7925
- That check proves every requirement reaches a stage; this one proves every
7926
- stage traces back to a requirement. Without it a plan can carry stages no
7927
- one asked for and still pass every gate.
7928
- """
7929
- ip = data.get("implementationPlanning")
7930
- if not isinstance(ip, dict):
7931
- return
7932
- stage_numbers = {
7933
- s.get("stage")
7934
- for s in (ip.get("stages") or [])
7935
- if isinstance(s, dict) and isinstance(s.get("stage"), int)
7936
- }
7937
- if not stage_numbers:
7938
- return
7939
-
7940
- cited: set[int] = set()
7941
- for row in (ip.get("requirementCoverage") or []):
7942
- if not isinstance(row, dict):
7943
- continue
7944
- if not _COVERAGE_CLAIMING_STATUS_RE.match(str(row.get("status") or "").strip()):
7945
- continue
7946
- cited |= _enumerated_stage_numbers(str(row.get("coveredBy") or ""))
7947
-
7948
- for orphan in sorted(stage_numbers - cited):
7949
- failures.append(
7950
- f"final-report data.json: Stage {orphan} is cited by no requirementCoverage "
7951
- "row — it traces back to nothing the brief asked for. Either cite the "
7952
- "requirement it serves, or drop it and raise it as a `Blocks=approval` "
7953
- "clarification (profile: scope provenance). A range cites only its "
7954
- "endpoints here: write `Stages 1, 2, 3` rather than `Stages 1-3`, so each "
7955
- "stage this requirement covers is one the planner named."
7956
- )
7957
-
7958
-
7959
4344
  def _validate_final_verification_consistency(data: dict, failures: list[str]) -> None:
7960
4345
  """단계 내용 판정 뒤에 이동 적합성을 본다. 다른 작업 유형은 건너뛴다."""
7961
4346
  if (data.get("header") or {}).get("taskType") != "final-verification":
@@ -8180,12 +4565,8 @@ def _validate_improvement_discovery(
8180
4565
  ``improvement-discovery: `` to match the style used by sibling validators
8181
4566
  (e.g. ``report-views: <line>``).
8182
4567
  """
8183
- _VALIDATORS_DIR_LOCAL = Path(__file__).resolve().parent
8184
- if str(_VALIDATORS_DIR_LOCAL) not in sys.path:
8185
- sys.path.insert(0, str(_VALIDATORS_DIR_LOCAL))
8186
-
8187
4568
  try:
8188
- from validate_improvement_report import validate_improvement_report # noqa: E402
4569
+ from okstra_ctl.phases.improvement_discovery.validation import validate_improvement_report
8189
4570
  except ImportError as exc:
8190
4571
  failures.append(
8191
4572
  f"improvement-discovery: validate_improvement_report import failed — {exc}"
@@ -8876,29 +5257,6 @@ def _validate_reverify_prompt_matches_plan(run_dir, failures, suffix=None) -> No
8876
5257
  )
8877
5258
 
8878
5259
 
8879
- def _validate_requirements_discovery_fanout(run_dir, failures, brief_path=None) -> None:
8880
- """requirements-discovery run 에 fan-out/ 이 있으면 packet+index 를 검증해
8881
- 실패를 ``requirements-discovery: `` 접두로 folding 한다. fan-out 이 없으면 no-op.
8882
- """
8883
- from pathlib import Path as _Path
8884
- if not (_Path(run_dir) / "fan-out").is_dir():
8885
- return
8886
- _validators_dir = _Path(__file__).resolve().parent
8887
- if str(_validators_dir) not in sys.path:
8888
- sys.path.insert(0, str(_validators_dir))
8889
- try:
8890
- from validate_fanout import validate_fanout # noqa: E402
8891
- except Exception as exc: # pragma: no cover - import guard
8892
- failures.append(
8893
- f"requirements-discovery: validate_fanout import failed — {exc}"
8894
- )
8895
- return
8896
- result = validate_fanout(_Path(run_dir), brief_path)
8897
- if not result.ok:
8898
- for err in result.errors:
8899
- failures.append(f"requirements-discovery: {err}")
8900
-
8901
-
8902
5260
  def _refresh_task_catalog(project_root: Path, task_manifest: dict) -> tuple[bool, str]:
8903
5261
  """Regenerate `discovery/task-catalog.json` so it stops trailing the
8904
5262
  authoritative `task-manifest.json` after validation.
@@ -9617,7 +5975,8 @@ def main() -> int:
9617
5975
  )
9618
5976
  if task_type == "implementation-planning" and not selected_direction_plan:
9619
5977
  _validate_requirement_provenance(
9620
- validation_data, brief_path, failures
5978
+ validation_data, brief_headings(brief_path),
5979
+ brief_end_state_ids(brief_path), failures
9621
5980
  )
9622
5981
  _validate_stage_has_requirement(validation_data, failures)
9623
5982
  if task_type == "implementation-planning":