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
@@ -0,0 +1,597 @@
1
+ """선택된 방향을 구현 계획으로 구체화한 결과의 전용 검증."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import posixpath
6
+ import re
7
+ from collections import Counter
8
+ from pathlib import Path
9
+ from typing import Any, Mapping
10
+
11
+ from okstra_ctl.exact_coverage import ExactCoverageError, calculate_plan_exact_coverage
12
+ from okstra_ctl.implementation_direction import (
13
+ SelectedDirectionSnapshot,
14
+ load_selected_direction_snapshot,
15
+ validate_selected_direction_ref,
16
+ stage_validation_executability_errors,
17
+ )
18
+ from okstra_ctl.stage_citations import enumerated_stage_numbers
19
+ from okstra_ctl.scope_provenance import (
20
+ brief_end_state_id_sequence,
21
+ brief_citation_problem,
22
+ parse_source,
23
+ resolve_chain,
24
+ )
25
+
26
+
27
+ _PLAN_EXECUTION_FIELDS = (
28
+ "directionRealization",
29
+ "stageMap",
30
+ "stages",
31
+ "designPreparation",
32
+ "dependencyMigrationRisk",
33
+ "validationChecklist",
34
+ "rollbackStrategy",
35
+ "requirementCoverage",
36
+ "coverageSummary",
37
+ "variationPointAnalysis",
38
+ "planBodyVerification",
39
+ "stepwiseExecution",
40
+ "supersessionLedger",
41
+ "crossProjectDependencies",
42
+ "decisionDrafts",
43
+ "skippedAdrCandidates",
44
+ "incrementalDecision",
45
+ "userNarrative",
46
+ )
47
+
48
+
49
+ def _direction_realization_errors(
50
+ realization: object, snapshot: SelectedDirectionSnapshot
51
+ ) -> list[str]:
52
+ if not isinstance(realization, Mapping):
53
+ return ["directionRealization is missing from a plan-ready result"]
54
+ direction = snapshot.data.get("direction")
55
+ if not isinstance(direction, Mapping):
56
+ return ["selected direction snapshot is missing its direction object"]
57
+ comparisons = {
58
+ "coreMechanism": direction.get("coreMechanism"),
59
+ "architectureBoundaries": direction.get("architectureBoundaries"),
60
+ "planningInvariants": snapshot.data.get("planningInvariants"),
61
+ "userConstraints": snapshot.data.get("userConstraints"),
62
+ }
63
+ return [
64
+ f"directionRealization.{field} must exactly preserve the selected-direction snapshot"
65
+ for field, expected in comparisons.items()
66
+ if realization.get(field) != expected
67
+ ]
68
+
69
+
70
+ def _plan_reference_sets(
71
+ planning: Mapping[str, Any],
72
+ ) -> tuple[set[int], set[str], set[str], tuple[str, ...]]:
73
+ stages = [row for row in planning.get("stages") or () if isinstance(row, Mapping)]
74
+ stage_ids = {row["stage"] for row in stages if isinstance(row.get("stage"), int)}
75
+ step_ids = {
76
+ f"{stage['stage']}.{step['step']}"
77
+ for stage in stages
78
+ if isinstance(stage.get("stage"), int)
79
+ for step in stage.get("stepwiseExecution") or ()
80
+ if isinstance(step, Mapping) and isinstance(step.get("step"), int)
81
+ }
82
+ validation_ids = {
83
+ str(row.get("id"))
84
+ for row in planning.get("validationChecklist") or ()
85
+ if isinstance(row, Mapping) and row.get("id")
86
+ }
87
+ realization = planning.get("directionRealization")
88
+ file_rows = (
89
+ realization.get("fileStructure") or ()
90
+ if isinstance(realization, Mapping)
91
+ else ()
92
+ )
93
+ file_paths = tuple(
94
+ str(row.get("path"))
95
+ for row in file_rows
96
+ if isinstance(row, Mapping) and row.get("path")
97
+ )
98
+ return stage_ids, step_ids, validation_ids, file_paths
99
+
100
+
101
+ def _duplicate_values(values: list[object]) -> tuple[object, ...]:
102
+ return tuple(value for value, count in Counter(values).items() if count > 1)
103
+
104
+
105
+ def _normalized_plan_file_path(value: object) -> str:
106
+ return posixpath.normpath(str(value).replace("\\", "/"))
107
+
108
+
109
+ def _plan_structure_errors(planning: Mapping[str, Any]) -> list[str]:
110
+ failures: list[str] = []
111
+ stage_map = [
112
+ row for row in planning.get("stageMap") or () if isinstance(row, Mapping)
113
+ ]
114
+ stages = [row for row in planning.get("stages") or () if isinstance(row, Mapping)]
115
+ stage_map_numbers = [
116
+ row["stage"] for row in stage_map if isinstance(row.get("stage"), int)
117
+ ]
118
+ stage_numbers = [
119
+ row["stage"] for row in stages if isinstance(row.get("stage"), int)
120
+ ]
121
+ duplicate_stage_map = set(_duplicate_values(stage_map_numbers))
122
+ duplicate_stages = set(_duplicate_values(stage_numbers))
123
+ failures.extend(
124
+ f"stageMap contains duplicate stage {number}"
125
+ for number in sorted(duplicate_stage_map)
126
+ )
127
+ failures.extend(
128
+ f"stages contains duplicate stage {number}"
129
+ for number in sorted(duplicate_stages)
130
+ )
131
+
132
+ stage_map_set = set(stage_map_numbers)
133
+ stage_set = set(stage_numbers)
134
+ failures.extend(
135
+ f"stageMap is missing stages stage {number}"
136
+ for number in sorted(stage_set - stage_map_set)
137
+ )
138
+ failures.extend(
139
+ f"stages is missing stageMap stage {number}"
140
+ for number in sorted(stage_map_set - stage_set)
141
+ )
142
+ unique_stage_map = {
143
+ row["stage"]: row
144
+ for row in stage_map
145
+ if isinstance(row.get("stage"), int) and row["stage"] not in duplicate_stage_map
146
+ }
147
+ unique_stages = {
148
+ row["stage"]: row
149
+ for row in stages
150
+ if isinstance(row.get("stage"), int) and row["stage"] not in duplicate_stages
151
+ }
152
+ failures.extend(
153
+ f"stageMap and stages stage {number} title must match"
154
+ for number in sorted(unique_stage_map.keys() & unique_stages.keys())
155
+ if unique_stage_map[number].get("title") != unique_stages[number].get("title")
156
+ )
157
+
158
+ for stage in stages:
159
+ stage_number = stage.get("stage")
160
+ step_numbers = [
161
+ step["step"]
162
+ for step in stage.get("stepwiseExecution") or ()
163
+ if isinstance(step, Mapping) and isinstance(step.get("step"), int)
164
+ ]
165
+ failures.extend(
166
+ f"stages stage {stage_number} step {step_number} is duplicate"
167
+ for step_number in sorted(_duplicate_values(step_numbers))
168
+ )
169
+
170
+ validation_ids = [
171
+ str(row["id"])
172
+ for row in planning.get("validationChecklist") or ()
173
+ if isinstance(row, Mapping) and row.get("id")
174
+ ]
175
+ failures.extend(
176
+ f"validationChecklist id {validation_id} is duplicate"
177
+ for validation_id in sorted(_duplicate_values(validation_ids))
178
+ )
179
+
180
+ realization = planning.get("directionRealization")
181
+ file_rows = (
182
+ [
183
+ row
184
+ for row in realization.get("fileStructure") or ()
185
+ if isinstance(row, Mapping)
186
+ ]
187
+ if isinstance(realization, Mapping)
188
+ else []
189
+ )
190
+ file_ids = [str(row["id"]) for row in file_rows if row.get("id")]
191
+ failures.extend(
192
+ f"directionRealization.fileStructure id {file_id} is duplicate"
193
+ for file_id in sorted(_duplicate_values(file_ids))
194
+ )
195
+ normalized_paths = [
196
+ _normalized_plan_file_path(row["path"]) for row in file_rows if row.get("path")
197
+ ]
198
+ failures.extend(
199
+ f"directionRealization.fileStructure path {path} is duplicate after normalization"
200
+ for path in sorted(_duplicate_values(normalized_paths))
201
+ )
202
+ return failures
203
+
204
+
205
+ def _coverage_reference_errors(
206
+ rows: list[Mapping[str, Any]],
207
+ stage_ids: set[int],
208
+ step_ids: set[str],
209
+ validation_ids: set[str],
210
+ file_paths: tuple[str, ...],
211
+ dependencies: list[Mapping[str, Any]],
212
+ ) -> list[str]:
213
+ failures: list[str] = []
214
+ reference_sets = {
215
+ "stageRefs": stage_ids,
216
+ "stepRefs": step_ids,
217
+ "validationRefs": validation_ids,
218
+ "fileRefs": set(file_paths),
219
+ "crossProjectDependencyRefs": {row.get("id") for row in dependencies},
220
+ }
221
+ for row in rows:
222
+ requirement_id = str(row.get("originalRequirementId") or "<missing>")
223
+ external_refs = row.get("crossProjectDependencyRefs") or ()
224
+ if row.get("status") == "externally-tracked":
225
+ if not external_refs:
226
+ failures.append(
227
+ f"requirementCoverage {requirement_id} requires crossProjectDependencyRefs"
228
+ )
229
+ for ref in external_refs:
230
+ matches = [item for item in dependencies if item.get("id") == ref]
231
+ if (
232
+ len(matches) != 1
233
+ or any(
234
+ not isinstance(matches[0].get(field), str)
235
+ or not matches[0][field].strip()
236
+ for field in (
237
+ "project",
238
+ "requiredWork",
239
+ "verificationSignal",
240
+ "linkedWork",
241
+ "howToStart",
242
+ )
243
+ )
244
+ or matches[0].get("direction")
245
+ not in {"upstream-precondition", "downstream-carry"}
246
+ ):
247
+ failures.append(
248
+ f"requirementCoverage {requirement_id} crossProjectDependencyRefs must resolve to one complete dependency: {ref}"
249
+ )
250
+ elif external_refs:
251
+ failures.append(
252
+ f"requirementCoverage {requirement_id} external references require externally-tracked status"
253
+ )
254
+ elif any(
255
+ not row.get(field)
256
+ for field in ("stageRefs", "stepRefs", "validationRefs", "fileRefs")
257
+ ):
258
+ failures.append(
259
+ f"requirementCoverage {requirement_id} requires local stage, step, validation and file references"
260
+ )
261
+ for field, valid_values in reference_sets.items():
262
+ dangling = [
263
+ value for value in row.get(field) or () if value not in valid_values
264
+ ]
265
+ if dangling:
266
+ failures.append(
267
+ f"requirementCoverage {requirement_id} {field} contains "
268
+ f"dangling reference(s): {dangling}"
269
+ )
270
+ return failures
271
+
272
+
273
+ def _requirement_ids_for_scope(
274
+ rows: list[Mapping[str, Any]], field: str, value: object
275
+ ) -> tuple[str, ...]:
276
+ return tuple(
277
+ str(row.get("originalRequirementId"))
278
+ for row in rows
279
+ if value in (row.get(field) or ()) and row.get("originalRequirementId")
280
+ )
281
+
282
+
283
+ def _coverage_summary_errors(
284
+ planning: Mapping[str, Any], original_ids: tuple[str, ...]
285
+ ) -> list[str]:
286
+ rows = [
287
+ row
288
+ for row in planning.get("requirementCoverage") or ()
289
+ if isinstance(row, Mapping)
290
+ ]
291
+ row_ids = tuple(str(row.get("originalRequirementId") or "") for row in rows)
292
+ failures: list[str] = []
293
+ if not original_ids:
294
+ return ["original requirement denominator from taskBriefPath is empty"]
295
+ if row_ids != original_ids:
296
+ failures.append(
297
+ "original requirement rows must match the taskBriefPath requirement "
298
+ "sequence exactly once"
299
+ )
300
+ return failures
301
+ structure_failures = _plan_structure_errors(planning)
302
+ failures.extend(structure_failures)
303
+ stage_ids, step_ids, validation_ids, file_paths = _plan_reference_sets(planning)
304
+ failures.extend(
305
+ _coverage_reference_errors(
306
+ rows,
307
+ stage_ids,
308
+ step_ids,
309
+ validation_ids,
310
+ file_paths,
311
+ [
312
+ row
313
+ for row in planning.get("crossProjectDependencies") or ()
314
+ if isinstance(row, Mapping)
315
+ ],
316
+ )
317
+ )
318
+ if failures:
319
+ return failures
320
+ # 검증된 외부 작업 연결은 계획 범위에만 포함하며 실제 완료 상태는 바꾸지 않는다.
321
+ statuses = {
322
+ row_id: "covered"
323
+ if row.get("status") == "externally-tracked"
324
+ else str(row.get("status") or "")
325
+ for row_id, row in zip(row_ids, rows)
326
+ }
327
+ stage_requirements = {
328
+ stage: _requirement_ids_for_scope(rows, "stageRefs", stage)
329
+ for stage in sorted(stage_ids)
330
+ }
331
+ file_requirements = {
332
+ path: _requirement_ids_for_scope(rows, "fileRefs", path) for path in file_paths
333
+ }
334
+ try:
335
+ result, unmapped_stages, unmapped_files = calculate_plan_exact_coverage(
336
+ original_ids, statuses, stage_requirements, file_requirements
337
+ )
338
+ except ExactCoverageError as exc:
339
+ return [*failures, f"plan-ready requires exact 100% coverage: {exc}"]
340
+ failures.extend(
341
+ f"unmapped stage {stage} has no original requirement"
342
+ for stage in unmapped_stages
343
+ )
344
+ failures.extend(
345
+ f"unmapped file change {path} has no original requirement"
346
+ for path in unmapped_files
347
+ )
348
+ failures.extend(
349
+ _declared_coverage_summary_errors(
350
+ planning.get("coverageSummary"), result, unmapped_stages, unmapped_files
351
+ )
352
+ )
353
+ if result.verdict != "exact":
354
+ failures.append("plan-ready requires exact 100% coverage")
355
+ return failures
356
+
357
+
358
+ def _declared_coverage_summary_errors(
359
+ summary: object,
360
+ result: Any,
361
+ unmapped_stages: tuple[int, ...],
362
+ unmapped_files: tuple[str, ...],
363
+ ) -> list[str]:
364
+ if not isinstance(summary, Mapping):
365
+ return ["coverageSummary is missing from a plan-ready result"]
366
+ expected = {
367
+ "coveragePercent": result.coverage_percent,
368
+ "scopePrecisionPercent": result.scope_precision_percent,
369
+ "coverageVerdict": result.verdict,
370
+ "unmappedStages": list(unmapped_stages),
371
+ "unmappedFileChanges": list(unmapped_files),
372
+ }
373
+ return [
374
+ f"coverageSummary.{field} does not match recalculated plan coverage"
375
+ for field, value in expected.items()
376
+ if summary.get(field) != value
377
+ ]
378
+
379
+
380
+ def _is_config_only_path(path: str) -> bool:
381
+ name = posixpath.basename(posixpath.normpath(str(path).replace("\\", "/")))
382
+ if name == "Dockerfile" or name.startswith("Dockerfile."):
383
+ return True
384
+ if name.startswith("docker-compose") or name.startswith(".env"):
385
+ return True
386
+ return name.endswith((".yml", ".yaml"))
387
+
388
+
389
+ def _micro_stage_fold_errors(planning: Mapping[str, Any]) -> list[str]:
390
+ """설정 파일 전용 종속 stage 는 독립 stage 가 아니다 — 커밋으로만 나눈다.
391
+
392
+ 실측(dev-10341): 다른 stage 가 도입한 플래그를 Dockerfile/compose 2줄,
393
+ workflow 2줄로 전파하는 작업이 stage 2·3 을 각각 차지해 run 2회(회당
394
+ 30분+)와 검증 차단 1회를 낳았다. 값 전파는 도입 stage 의 스텝이고,
395
+ 분리가 필요하면 커밋을 나눈다. 첫 stage(의존 없음)는 예외다 — 설정만
396
+ 바꾸는 태스크 자체는 정당하다.
397
+ """
398
+ dependent: dict[int, bool] = {}
399
+ for row in planning.get("stageMap") or ():
400
+ if isinstance(row, Mapping) and isinstance(row.get("stage"), int):
401
+ raw = str(row.get("dependsOn") or "").strip().lower()
402
+ dependent[row["stage"]] = raw not in ("", "(none)", "none")
403
+ failures: list[str] = []
404
+ for stage in planning.get("stages") or ():
405
+ if not isinstance(stage, Mapping):
406
+ continue
407
+ number = stage.get("stage")
408
+ if not isinstance(number, int) or not dependent.get(number):
409
+ continue
410
+ paths = [
411
+ str(path)
412
+ for step in stage.get("stepwiseExecution") or ()
413
+ if isinstance(step, Mapping)
414
+ for path in step.get("plannedPaths") or ()
415
+ ]
416
+ if paths and all(_is_config_only_path(path) for path in paths):
417
+ failures.append(
418
+ f"stage {number} is a config-only propagation stage "
419
+ f"({sorted(set(paths))}); fold it into the stage that introduces "
420
+ "the value and split the work by commits, not stages"
421
+ )
422
+ return failures
423
+
424
+
425
+ def _direction_invalidation_errors(planning: Mapping[str, Any]) -> list[str]:
426
+ failures: list[str] = []
427
+ invalidation = planning.get("directionInvalidation")
428
+ if not isinstance(invalidation, Mapping):
429
+ failures.append("direction-invalidated requires directionInvalidation evidence")
430
+ else:
431
+ for field in ("reasons", "codeEvidence"):
432
+ values = invalidation.get(field)
433
+ if (
434
+ not isinstance(values, list)
435
+ or not values
436
+ or any(
437
+ not isinstance(value, str) or not value.strip() for value in values
438
+ )
439
+ ):
440
+ failures.append(f"directionInvalidation.{field} must contain evidence")
441
+ if planning.get("routing") != "implementation-option-selection":
442
+ failures.append(
443
+ "direction-invalidated routing must be implementation-option-selection"
444
+ )
445
+ for field in _PLAN_EXECUTION_FIELDS:
446
+ if field in planning:
447
+ failures.append(
448
+ f"direction-invalidated must not contain execution field {field}"
449
+ )
450
+ return failures
451
+
452
+
453
+ def validate_selected_direction_plan(
454
+ data: Mapping[str, Any], brief_path: Path, snapshot_path: Path
455
+ ) -> list[str]:
456
+ """Validate selected-direction planning semantics against independent inputs."""
457
+ planning = data.get("implementationPlanning")
458
+ if not isinstance(planning, Mapping):
459
+ return ["implementationPlanning is missing"]
460
+ if planning.get("planningContract") != "selected-direction":
461
+ return ["implementationPlanning.planningContract must be selected-direction"]
462
+ snapshot, failures = load_selected_direction_snapshot(snapshot_path)
463
+ if snapshot is None:
464
+ return failures
465
+ header = data.get("header")
466
+ plan_task_key = header.get("taskKey") if isinstance(header, Mapping) else None
467
+ snapshot_task_key = snapshot.data.get("taskKey")
468
+ if not isinstance(plan_task_key, str) or not plan_task_key:
469
+ failures.append("plan header.taskKey must be a non-empty string")
470
+ if not isinstance(snapshot_task_key, str) or not snapshot_task_key:
471
+ failures.append(
472
+ "selected direction snapshot taskKey must be a non-empty string"
473
+ )
474
+ elif isinstance(plan_task_key, str) and snapshot_task_key != plan_task_key:
475
+ failures.append(
476
+ "selected direction snapshot taskKey must exactly match plan header.taskKey"
477
+ )
478
+ ref = planning.get("selectedDirectionRef")
479
+ if not isinstance(ref, Mapping):
480
+ failures.append("selectedDirectionRef is missing")
481
+ else:
482
+ failures.extend(validate_selected_direction_ref(ref, snapshot))
483
+ outcome = planning.get("outcome")
484
+ if outcome == "direction-invalidated":
485
+ failures.extend(_direction_invalidation_errors(planning))
486
+ return failures
487
+ if outcome != "plan-ready":
488
+ return [*failures, f"selected-direction outcome is invalid: {outcome!r}"]
489
+ failures.extend(
490
+ _direction_realization_errors(planning.get("directionRealization"), snapshot)
491
+ )
492
+ failures.extend(
493
+ _coverage_summary_errors(
494
+ planning, brief_end_state_id_sequence(Path(brief_path))
495
+ )
496
+ )
497
+ failures.extend(stage_validation_executability_errors(planning))
498
+ failures.extend(_micro_stage_fold_errors(planning))
499
+ return failures
500
+
501
+
502
+ # Deliberately not `_COVERED_BY_STAGE_REF_RE`: the two directions disagree on
503
+ # what "no match" means — forward treats it as nothing to verify and passes,
504
+ # reverse treats it as nothing cited and orphans every stage — so the reverse
505
+ # reader must cover `Stages 1, 2` and `Stage 1 and 2` too.
506
+ #
507
+ # It reads hand-enumerated numbers only. A range's interior is deliberately NOT
508
+ # coverage evidence: one `Stages 1-64` cell would otherwise stamp every stage in
509
+ # the map while the planner confirmed none of them. The incremental-scope
510
+ # back-trace keeps the widening reader (`cited_stage_numbers`) because "could
511
+ # this answer reach stage 5" is the opposite question and must over-approximate.
512
+
513
+
514
+ # Statuses under which a row asserts the plan does work for the requirement.
515
+ # `gap` is excluded: it states the plan does NOT cover the requirement, so it
516
+ # can justify no stage. `blocked C-NNN` and `documented-deviation` are included:
517
+ # each records a real requirement with a planned treatment, even when the
518
+ # treatment awaits approval or deliberately differs from the original request.
519
+ _COVERAGE_CLAIMING_STATUS_RE = re.compile(
520
+ r"^(covered|blocked C-\d{3,}|documented-deviation)$"
521
+ )
522
+
523
+
524
+ def _validate_requirement_provenance(
525
+ data: dict, headings: set[str], end_state: set[str], failures: list[str]
526
+ ) -> None:
527
+ """Every requirement must declare where it came from.
528
+
529
+ The planning input template already states that any change beyond what
530
+ `Requirement Summary` demands is out of scope by default; without this
531
+ check that sentence has no enforcement point and a phase can invent
532
+ requirements freely.
533
+ """
534
+ ip = data.get("implementationPlanning")
535
+ if not isinstance(ip, dict):
536
+ return
537
+ rows = [r for r in (ip.get("requirementCoverage") or []) if isinstance(r, dict)]
538
+ if not rows:
539
+ return
540
+
541
+ refs = {
542
+ str(r.get("id") or "").strip(): parse_source(str(r.get("source") or ""))
543
+ for r in rows
544
+ }
545
+
546
+ for rid, ref in refs.items():
547
+ problem = brief_citation_problem(ref, headings, end_state)
548
+ if problem:
549
+ failures.append(
550
+ f"final-report data.json: requirementCoverage `{rid}` {problem}. A "
551
+ "requirement must trace to a line the reporter actually wrote."
552
+ )
553
+
554
+ for rid, verdict in resolve_chain(refs).items():
555
+ if verdict != "ok":
556
+ failures.append(
557
+ f"final-report data.json: requirementCoverage `{rid}` provenance "
558
+ f"failed — {verdict}. An item with no admissible source is not a "
559
+ "requirement: raise it as a `Blocks=approval` clarification instead."
560
+ )
561
+
562
+
563
+ def _validate_stage_has_requirement(data: dict, failures: list[str]) -> None:
564
+ """Reverse of `_validate_requirement_coverage_covered_by`.
565
+
566
+ That check proves every requirement reaches a stage; this one proves every
567
+ stage traces back to a requirement. Without it a plan can carry stages no
568
+ one asked for and still pass every gate.
569
+ """
570
+ ip = data.get("implementationPlanning")
571
+ if not isinstance(ip, dict):
572
+ return
573
+ stage_numbers = {
574
+ s.get("stage")
575
+ for s in (ip.get("stages") or [])
576
+ if isinstance(s, dict) and isinstance(s.get("stage"), int)
577
+ }
578
+ if not stage_numbers:
579
+ return
580
+
581
+ cited: set[int] = set()
582
+ for row in ip.get("requirementCoverage") or []:
583
+ if not isinstance(row, dict):
584
+ continue
585
+ if not _COVERAGE_CLAIMING_STATUS_RE.match(str(row.get("status") or "").strip()):
586
+ continue
587
+ cited |= enumerated_stage_numbers(str(row.get("coveredBy") or ""))
588
+
589
+ for orphan in sorted(stage_numbers - cited):
590
+ failures.append(
591
+ f"final-report data.json: Stage {orphan} is cited by no requirementCoverage "
592
+ "row — it traces back to nothing the brief asked for. Either cite the "
593
+ "requirement it serves, or drop it and raise it as a `Blocks=approval` "
594
+ "clarification (profile: scope provenance). A range cites only its "
595
+ "endpoints here: write `Stages 1, 2, 3` rather than `Stages 1-3`, so each "
596
+ "stage this requirement covers is one the planner named."
597
+ )