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,2345 @@
1
+ """CLI adapter for deterministic implementation-planning item extraction.
2
+
3
+ `extract` / `validate` own the `P-*` queue; `collect-verdicts` / `apply-verdicts`
4
+ own the round's votes on that queue. Both halves exist so the round's fidelity
5
+ does not depend on a parser the lead re-writes each time.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from . import plan_body
11
+
12
+ import argparse
13
+ import copy
14
+ import json
15
+ import re
16
+ from collections.abc import Mapping, Sequence
17
+ from datetime import datetime, timezone
18
+ from pathlib import Path
19
+ import sys
20
+ from typing import Any
21
+
22
+ from okstra_ctl.convergence import (
23
+ ConvergenceContractError,
24
+ canonical_run_state_artifact,
25
+ validated_run_authority,
26
+ )
27
+ from okstra_ctl.convergence_store import write_json_atomic
28
+ from okstra_ctl.incremental_carry import sync_prepared_dispatch_queue
29
+ from okstra_ctl.json_boundary import JsonBoundaryError, load_owned_object
30
+ from okstra_ctl.report_finalize import task_manifest_path
31
+ from okstra_ctl.qa_commands import split_verification_command
32
+ from okstra_ctl.plan_derivations import extract_tokens, find_derivations
33
+ from okstra_ctl.plan_items import (
34
+ NextDispatch,
35
+ PlanItemContractError,
36
+ advisory_plan_body_gating,
37
+ requires_plan_repair,
38
+ content_hash,
39
+ correction_prompt_text,
40
+ critic_is_rostered,
41
+ is_critic_worker,
42
+ critic_tie_prompt_text,
43
+ dispatch_item_ids,
44
+ extract_plan_items,
45
+ lead_decision_basis,
46
+ next_dispatch,
47
+ planning_stage_ledger,
48
+ reverify_item_ids,
49
+ reverify_prompt_text,
50
+ self_fix_rounds,
51
+ tie_vote_item_ids,
52
+ voting_analyser_keys,
53
+ with_rendered_by,
54
+ with_response_format,
55
+ )
56
+ from okstra_ctl.paths import RunRef
57
+ from okstra_ctl.stage_ledger import build_stage_ledger
58
+ from okstra_ctl.claim_reproduction import NOT_RUNNABLE, reproduce
59
+ from okstra_ctl.final_report_schema import load_schema_version
60
+ from okstra_ctl.report_narrative import parse_narrative
61
+ from okstra_ctl.report_assembly import validate_plan_draft
62
+ from okstra_ctl.error_log_write import record_runtime_failure
63
+ from okstra_ctl.user_response import parse_user_response_entries
64
+ from okstra_ctl.verdict_blocks import (
65
+ PLAN_ITEM_VERDICTS,
66
+ VerdictBlock,
67
+ VerdictBlockError,
68
+ parse_verdict_blocks,
69
+ )
70
+ from okstra_ctl.fixed_text import line, scalar
71
+
72
+
73
+ def _load_json_object(path: Path) -> dict[str, Any]:
74
+ try:
75
+ return load_owned_object(path, artifact="plan-items artifact")
76
+ except JsonBoundaryError as exc:
77
+ raise PlanItemContractError(str(exc)) from exc
78
+
79
+
80
+ def _planning(data: Mapping[str, Any]) -> Mapping[str, Any]:
81
+ planning = data.get("implementationPlanning")
82
+ if not isinstance(planning, Mapping):
83
+ raise PlanItemContractError("implementationPlanning must be an object")
84
+ return planning
85
+
86
+
87
+ def _envelope(data: Mapping[str, Any]) -> dict[str, Any]:
88
+ return {
89
+ "schemaVersion": "1.0",
90
+ "taskType": "implementation-planning",
91
+ "items": extract_plan_items(_planning(data)),
92
+ }
93
+
94
+
95
+ def _merged_ledger(
96
+ planning: Mapping[str, Any],
97
+ run_manifest: Path | None,
98
+ ) -> dict[str, str]:
99
+ return planning_stage_ledger(planning, _stage_ledger_snapshot(run_manifest))
100
+
101
+
102
+ def _previous_hashes(state_path: Path | None) -> dict[str, str]:
103
+ if state_path is None or not state_path.is_file():
104
+ return {}
105
+ rows = _state_plan_body_items(_load_json_object(state_path), state_path)
106
+ return {
107
+ str(row["id"]): str(row["verifiedContentHash"])
108
+ for row in rows
109
+ if isinstance(row, Mapping)
110
+ and isinstance(row.get("id"), str)
111
+ and isinstance(row.get("verifiedContentHash"), str)
112
+ }
113
+
114
+
115
+ def _queue_for(
116
+ items: list[dict[str, Any]],
117
+ planning: Mapping[str, Any],
118
+ run_manifest: Path | None,
119
+ previous_hashes: Mapping[str, str] | None = None,
120
+ tied_ids: Sequence[str] | None = None,
121
+ ) -> list[str]:
122
+ ledger = _merged_ledger(planning, run_manifest)
123
+ if tied_ids is not None:
124
+ return tie_vote_item_ids(items, ledger, tied_ids)
125
+ if previous_hashes:
126
+ return reverify_item_ids(items, previous_hashes, ledger)
127
+ return dispatch_item_ids(items, ledger)
128
+
129
+
130
+ def _queue_kwargs(args: argparse.Namespace) -> dict[str, Any]:
131
+ if getattr(args, "tie_vote", False):
132
+ state = getattr(args, "state", None)
133
+ if state is None:
134
+ raise PlanItemContractError("--tie-vote requires --state")
135
+ return {"tied_ids": _tied_ids(state)}
136
+ return {"previous_hashes": _previous_hashes(getattr(args, "state", None))}
137
+
138
+
139
+ def _tied_ids(state_path: Path) -> list[str]:
140
+ if not state_path.is_file():
141
+ raise PlanItemContractError(
142
+ "--tie-vote requires --state with recorded verdicts"
143
+ )
144
+ items = _state_plan_body_items(_load_json_object(state_path), state_path)
145
+ gate = plan_body
146
+ return [
147
+ str(item["id"])
148
+ for item in items
149
+ if isinstance(item, Mapping)
150
+ and isinstance(item.get("id"), str)
151
+ and gate._is_unsettled_tie(dict(item))
152
+ ]
153
+
154
+
155
+ def _parser() -> argparse.ArgumentParser:
156
+ parser = argparse.ArgumentParser(
157
+ prog="okstra plan-items",
158
+ description="Extract and validate deterministic plan-body items.",
159
+ )
160
+ commands = parser.add_subparsers(dest="command", required=True)
161
+ extract = commands.add_parser("extract")
162
+ _add_plan_source(extract)
163
+ extract.add_argument("--output", type=Path, required=True)
164
+ validate = commands.add_parser("validate")
165
+ _add_plan_source(validate)
166
+ validate.add_argument("--items", type=Path, required=True)
167
+ prepare = commands.add_parser("prepare")
168
+ _add_plan_source(prepare)
169
+ prepare.add_argument("--run-manifest", type=Path, required=True)
170
+ prepare.add_argument(
171
+ "--state",
172
+ type=Path,
173
+ help="previous plan-body state; when it carries verifiedContentHash "
174
+ "rows the dispatch queue shrinks to the self-fix reverify set, and "
175
+ "each queued item's recorded votes and selfFixNote are carried "
176
+ "into the prompt as its `**Prior round dissent**` block",
177
+ )
178
+ prepare.add_argument(
179
+ "--tie-vote",
180
+ action="store_true",
181
+ help="dispatch queue is the needs-reverify ties in --state for critic-worker",
182
+ )
183
+ prompt = commands.add_parser("prompt")
184
+ prompt.add_argument("--run-manifest", type=Path, required=True)
185
+ validate_prepared = commands.add_parser("validate-prepared")
186
+ _add_plan_source(validate_prepared)
187
+ validate_prepared.add_argument("--run-manifest", type=Path, required=True)
188
+ validate_prepared.add_argument(
189
+ "--state",
190
+ type=Path,
191
+ help="same previous state prepare used to shrink the dispatch queue "
192
+ "and to build the prior-dissent carry; both are re-derived here",
193
+ )
194
+ validate_prepared.add_argument(
195
+ "--tie-vote",
196
+ action="store_true",
197
+ help="same --tie-vote prepare used to shrink the dispatch queue",
198
+ )
199
+ collect = commands.add_parser(
200
+ "collect-verdicts",
201
+ help="read this round's worker responses into a verdicts envelope",
202
+ )
203
+ collect.add_argument(
204
+ "--result",
205
+ action="append",
206
+ default=[],
207
+ required=True,
208
+ metavar="<worker-id>=<path>",
209
+ help="one worker's plan-verify result file (repeatable)",
210
+ )
211
+ collect.add_argument(
212
+ "--items",
213
+ type=Path,
214
+ required=True,
215
+ metavar="<plan-items artifact>",
216
+ help="the plan-items artifact this round dispatched (not a list of "
217
+ "ids). Its `dispatchQueue` is the id set the results must answer, "
218
+ "so a tie round takes the `--tie-vote` artifact, not the full "
219
+ "round's one",
220
+ )
221
+ collect.add_argument("--output", type=Path, required=True)
222
+ derivations = commands.add_parser(
223
+ "derivations",
224
+ help="list plan statements an answered clarification may have falsified",
225
+ )
226
+ derivations.add_argument("--data", type=Path, required=True)
227
+ derivations.add_argument(
228
+ "--response",
229
+ type=Path,
230
+ required=True,
231
+ help="the user-responses sidecar for this run",
232
+ )
233
+ derivations.add_argument(
234
+ "--clarification",
235
+ default=None,
236
+ help="only this C-id (default: every answered one)",
237
+ )
238
+ seed = commands.add_parser(
239
+ "seed",
240
+ help="create the planBodyVerification.planItems[] rows a round lands in",
241
+ )
242
+ _add_plan_source(seed)
243
+ seed.add_argument("--state", type=Path)
244
+ seed.add_argument(
245
+ "--run-manifest",
246
+ type=Path,
247
+ help="record the stage ledger so the gate can scope itself to the "
248
+ "stage about to start; without it the gate does not narrow",
249
+ )
250
+ seed.add_argument(
251
+ "--prior-state",
252
+ type=Path,
253
+ help="the previous run's plan-body-verification-<task-type>-<seq>.json. "
254
+ "A newly seeded item whose contentHash still equals that run's "
255
+ "verifiedContentHash inherits its verdicts and is tagged "
256
+ "carriedForwardFromSeq, so round 1 does not re-judge text nobody "
257
+ "changed. Requires --state, refuses a state file belonging to "
258
+ "another task, and never carries on a matching id alone",
259
+ )
260
+ apply_verdicts = commands.add_parser(
261
+ "apply-verdicts",
262
+ help="overwrite planBodyVerification.planItems[].verdicts in owned state",
263
+ )
264
+ target = apply_verdicts.add_mutually_exclusive_group(required=True)
265
+ target.add_argument("--data", type=Path)
266
+ target.add_argument("--state", type=Path)
267
+ incoming = apply_verdicts.add_mutually_exclusive_group(required=True)
268
+ incoming.add_argument(
269
+ "--verdicts",
270
+ type=Path,
271
+ help="historical automation envelope; model-facing calls use --result",
272
+ )
273
+ incoming.add_argument(
274
+ "--result",
275
+ action="append",
276
+ default=[],
277
+ metavar="<worker-id>=<path>",
278
+ help="one worker's plan-verify Markdown result (repeatable)",
279
+ )
280
+ apply_verdicts.add_argument(
281
+ "--items",
282
+ type=Path,
283
+ metavar="<plan-items artifact>",
284
+ help="the plan-items artifact this round dispatched, when it dispatched "
285
+ "part of the queue (the `--tie-vote` artifact). Its dispatchQueue "
286
+ "is what each --result must answer; without it a result is checked "
287
+ "against the whole persisted queue",
288
+ )
289
+ apply_verdicts.add_argument(
290
+ "--run-manifest",
291
+ type=Path,
292
+ help="resolve the project a `fact` claim's probe runs against; without "
293
+ "it every such claim records `not-runnable` and takes the quorum "
294
+ "route instead of blocking on one vote",
295
+ )
296
+ apply_verdicts.add_argument(
297
+ "--round",
298
+ type=int,
299
+ required=True,
300
+ dest="round_number",
301
+ help="the verification round these verdicts were cast in; stamped on "
302
+ "every row so a later self-fix can be told from a current judgement",
303
+ )
304
+ apply_verdicts.add_argument(
305
+ "--append",
306
+ action="store_true",
307
+ help="add critic corrections while preserving analyser votes; a later "
308
+ "critic verdict updates that critic's current row after its earlier round is complete; "
309
+ "without it every recorded verdict row is replaced, so a round "
310
+ "that was never closed with complete-round is refused first",
311
+ )
312
+ apply_verdicts.add_argument(
313
+ "--discard-open-rounds",
314
+ action="store_true",
315
+ help="replace even the rows of a round that complete-round never "
316
+ "closed — the recovery path when those rows are being re-applied "
317
+ "from their result files in order; the discarded rows are listed, "
318
+ "and dispatchQueue is restored to the items those result files "
319
+ "answer, since the persisted queue belongs to the latest round",
320
+ )
321
+ complete = commands.add_parser(
322
+ "complete-round",
323
+ help="derive and atomically record one verified plan-body round",
324
+ )
325
+ complete.add_argument("--state", type=Path, required=True)
326
+ complete.add_argument("--run-manifest", type=Path, required=True)
327
+ complete.add_argument("--round", type=int, required=True, dest="round_number")
328
+ complete.add_argument(
329
+ "--items",
330
+ type=Path,
331
+ help="restore this round's dispatched queue from its prepared items artifact; "
332
+ "defaults to this run's canonical prepared queue when it covers the round's "
333
+ "recorded votes; earlier verdicts outside that queue remain unchanged",
334
+ )
335
+ complete.add_argument(
336
+ "--self-fix-note",
337
+ action="append",
338
+ default=[],
339
+ metavar="<item-id>=<markdown-file>",
340
+ )
341
+ complete.add_argument(
342
+ "--self-fix-group",
343
+ action="append",
344
+ default=[],
345
+ metavar="<cause-file>=<item-id>[,<item-id>...]",
346
+ help="this round's cause groups; requires --self-fix-stop-reason",
347
+ )
348
+ complete.add_argument(
349
+ "--self-fix-stop-reason",
350
+ choices=("all-resolved", "no-progress", "max-rounds-reached"),
351
+ help="why the self-fix loop stops. Required with --self-fix-group, and "
352
+ "valid on its own to record a stop the round did not rewrite for; "
353
+ "on its own it leaves selfFixRoundsApplied and selfFixGroups alone, "
354
+ "and is refused while a planner-fixable majority DISAGREE remains "
355
+ "with no self-fix rewrite recorded",
356
+ )
357
+ _add_dispatch_commands(commands)
358
+ return parser
359
+
360
+
361
+ def _add_dispatch_commands(commands: Any) -> None:
362
+ resolve = commands.add_parser(
363
+ "resolve-dissent",
364
+ help="record an evidence-based lead decision after the single self-fix",
365
+ )
366
+ resolve.add_argument("--state", type=Path, required=True)
367
+ resolve.add_argument("--item", required=True)
368
+ resolve.add_argument("--decision-file", type=Path, required=True)
369
+ nxt = commands.add_parser(
370
+ "next-dispatch",
371
+ help="decide whether this round opens a worker batch",
372
+ )
373
+ nxt.add_argument("--state", type=Path, required=True)
374
+ nxt.add_argument("--run-manifest", type=Path)
375
+ correction = commands.add_parser(
376
+ "correction-prompt",
377
+ help="environment-exception preamble then the assigned queue",
378
+ )
379
+ correction.add_argument("--state", type=Path, required=True)
380
+ correction.add_argument("--run-manifest", type=Path, required=True)
381
+ correction.add_argument("--worker", required=True)
382
+
383
+
384
+ def _add_plan_source(parser: argparse.ArgumentParser) -> None:
385
+ source = parser.add_mutually_exclusive_group(required=True)
386
+ source.add_argument("--data", type=Path)
387
+ source.add_argument("--narrative", type=Path)
388
+
389
+
390
+ def _plan_source(args: argparse.Namespace) -> dict[str, Any]:
391
+ if args.data is not None:
392
+ return _load_json_object(args.data)
393
+ try:
394
+ markdown = args.narrative.read_text(encoding="utf-8")
395
+ except (OSError, UnicodeError) as exc:
396
+ raise PlanItemContractError(
397
+ f"cannot read report narrative {args.narrative}: {exc}"
398
+ ) from exc
399
+ try:
400
+ return parse_narrative(markdown, load_schema_version("3.0"))
401
+ except ValueError as exc:
402
+ raise PlanItemContractError(f"invalid report narrative: {exc}") from exc
403
+
404
+
405
+ def _extract(args: argparse.Namespace) -> dict[str, Any]:
406
+ envelope = _envelope(_plan_source(args))
407
+ write_json_atomic(args.output, envelope)
408
+ return {"ok": True, "operation": "extract", "path": str(args.output)}
409
+
410
+
411
+ def _validate(args: argparse.Namespace) -> dict[str, Any]:
412
+ expected = _envelope(_plan_source(args))
413
+ actual = _load_json_object(args.items)
414
+ if actual != expected:
415
+ raise PlanItemContractError(
416
+ "plan items envelope does not match deterministic extraction"
417
+ )
418
+ return {"ok": True, "operation": "validate", "path": str(args.items)}
419
+
420
+
421
+ def _prepared_items_path(run_manifest: Path, *, require_regular: bool = False) -> Path:
422
+ try:
423
+ authority = validated_run_authority(run_manifest)
424
+ state = canonical_run_state_artifact(
425
+ authority,
426
+ manifest_field="planBodyVerificationPath",
427
+ prefix="plan-body-verification",
428
+ label="plan body verification path",
429
+ )
430
+ except ConvergenceContractError as exc:
431
+ raise PlanItemContractError(str(exc)) from exc
432
+ path = state.with_name(
433
+ "plan-items-" + state.name.removeprefix("plan-body-verification-")
434
+ )
435
+ if path.absolute() != path.resolve(strict=False):
436
+ raise PlanItemContractError("plan items path does not match run authority")
437
+ if require_regular and not path.is_file():
438
+ raise PlanItemContractError("plan items path is not a regular file")
439
+ if path.exists() and not path.is_file():
440
+ raise PlanItemContractError("plan items path is not a regular file")
441
+ return path
442
+
443
+
444
+ def _sync_task_manifest_gating(run_manifest: Path, gating: bool) -> None:
445
+ """준비 이후 매니페스트 ``gating`` 을 계획 사실로 맞춘다."""
446
+ try:
447
+ authority = validated_run_authority(run_manifest)
448
+ path = task_manifest_path(authority.project_root, authority.payload)
449
+ payload = load_owned_object(path, artifact="task manifest")
450
+ except (ConvergenceContractError, JsonBoundaryError, OSError, ValueError):
451
+ return
452
+ block = payload.get("convergence")
453
+ if not isinstance(block, dict):
454
+ return
455
+ pbv = block.get("planBodyVerification")
456
+ if not isinstance(pbv, dict):
457
+ return
458
+ pbv["gating"] = gating
459
+ write_json_atomic(path, payload)
460
+
461
+
462
+ def _prepare(args: argparse.Namespace) -> dict[str, Any]:
463
+ output = _prepared_items_path(args.run_manifest)
464
+ source = _plan_source(args)
465
+ authority = validated_run_authority(args.run_manifest)
466
+ if authority.payload.get("reportContractVersion") == "3.0":
467
+ failures = validate_plan_draft(
468
+ source, authority.project_root, authority.payload
469
+ )
470
+ if failures:
471
+ raise PlanItemContractError(
472
+ "owner=report-writer: "
473
+ + "; ".join(failures)
474
+ + "; correct the narrative in this run, then retry plan-items prepare"
475
+ )
476
+ envelope = _envelope(source)
477
+ envelope["dispatchQueue"] = _queue_for(
478
+ envelope["items"],
479
+ _planning(source),
480
+ args.run_manifest,
481
+ **_queue_kwargs(args),
482
+ )
483
+ if getattr(args, "tie_vote", False):
484
+ envelope["dispatchKind"] = "critic-tie"
485
+ envelope["tieSplits"] = _tie_splits_from_state(
486
+ getattr(args, "state", None),
487
+ envelope["dispatchQueue"],
488
+ )
489
+ else:
490
+ envelope.update(_reverify_carry(args, envelope["dispatchQueue"]))
491
+ gating = not advisory_plan_body_gating(_planning(source), envelope["items"])
492
+ _sync_task_manifest_gating(args.run_manifest, gating)
493
+ write_json_atomic(output, envelope)
494
+ return {
495
+ "ok": True,
496
+ "operation": "prepare",
497
+ "path": str(output),
498
+ "gating": gating,
499
+ }
500
+
501
+
502
+ _PAYLOAD_FIELDS = {
503
+ "Dir": (
504
+ "goal",
505
+ "coreMechanism",
506
+ "architectureBoundaries",
507
+ "expectedChangeAreas",
508
+ "fileStructure",
509
+ "interfaces",
510
+ "blastRadius",
511
+ "testSeams",
512
+ "assumptions",
513
+ "planningInvariants",
514
+ "userConstraints",
515
+ ),
516
+ "Opt": ("name", "ticketId", "fileStructure", "interfaces", "blastRadius"),
517
+ "Step": (
518
+ "stage",
519
+ "step",
520
+ "ticketId",
521
+ "action",
522
+ "files",
523
+ "plannedPaths",
524
+ "command",
525
+ "outcome",
526
+ "expectedDetail",
527
+ ),
528
+ "Dep": ("id", "ticketId", "kind", "item", "impact", "mitigation"),
529
+ "Val": (
530
+ "id",
531
+ "phase",
532
+ "ticketId",
533
+ "check",
534
+ "commandOrObservation",
535
+ "expectedOutcome",
536
+ "stageRefs",
537
+ ),
538
+ "Rb": ("id", "ticketId", "step", "action", "triggerSignal", "verificationMethod"),
539
+ # 두 계약의 합집합이다. `ImplementationRequirementCoverageRow` 는 id/source/
540
+ # requirement/coveredBy/status 와 사용자 처분 기록(approvalDisposition,
541
+ # decisionRefs)을, `SelectedDirectionRequirementCoverage` 는 originalRequirementId
542
+ # 와 네 개의 *Refs 를 싣는다. 어느 한쪽만 남기면 다른 계약의 계획이 렌더에서
543
+ # 거부되므로 줄이지 말 것.
544
+ "Req": (
545
+ "id",
546
+ "source",
547
+ "requirement",
548
+ "coveredBy",
549
+ "originalRequirementId",
550
+ "stageRefs",
551
+ "stepRefs",
552
+ "validationRefs",
553
+ "fileRefs",
554
+ "ticketId",
555
+ "status",
556
+ "approvalDisposition",
557
+ "decisionRefs",
558
+ "crossProjectDependencyRefs",
559
+ ),
560
+ "Prep": ("stage", "kind", "evidence"),
561
+ }
562
+ _VAR_ANALYSIS_FIELDS = (
563
+ "hasMultipleImplementations",
564
+ "noVariationRationale",
565
+ "points",
566
+ )
567
+ _VAR_POINT_FIELDS = (
568
+ "behavior",
569
+ "implementations",
570
+ "evidence",
571
+ "extractionDecision",
572
+ )
573
+ _OBJECT_LIST_FIELDS = {
574
+ "fileStructure": ("id", "ticketId", "action", "path", "summary", "details"),
575
+ "testSeams": ("boundary", "injectedAs", "replacedInTest"),
576
+ "planningInvariants": ("id", "statement", "requirementIds", "evidence"),
577
+ "evidence": ("step", "field", "match"),
578
+ }
579
+
580
+
581
+ def _label(key: str) -> str:
582
+ return " ".join(part.capitalize() for part in key.replace("Id", " ID").split())
583
+
584
+
585
+ def _render_literal(label: str, value: str, indent: str = "") -> list[str]:
586
+ """명령·코드의 줄바꿈을 보존하고 본문보다 긴 울타리로 감싼다."""
587
+ fence = "`" * max(
588
+ 3, 1 + max((len(part) for part in re.findall(r"`+", value)), default=0)
589
+ )
590
+ body = "".join(indent + " " + row for row in value.splitlines(keepends=True))
591
+ ending = "" if value.endswith("\n") else "\n"
592
+ return [
593
+ f"{indent}- {label}:\n\n{indent} {fence}text\n{body}{ending}{indent} {fence}\n"
594
+ ]
595
+
596
+
597
+ def _render_object_list(key: str, value: object) -> list[str]:
598
+ if not isinstance(value, list):
599
+ raise PlanItemContractError(f"plan item {key} must be an array")
600
+ rows = [f"- {_label(key)}:\n"]
601
+ allowed = _OBJECT_LIST_FIELDS[key]
602
+ for index, entry in enumerate(value, 1):
603
+ if not isinstance(entry, Mapping) or set(entry) - set(allowed):
604
+ raise PlanItemContractError(f"plan item {key}[{index}] has unknown fields")
605
+ rows.append(f" - Entry {index}:\n")
606
+ for field in allowed:
607
+ if field in entry:
608
+ field_path = f"{key}[{index - 1}].{field}"
609
+ field_value = entry[field]
610
+ if isinstance(field_value, list):
611
+ rows.append(f" - `{field_path}`:\n")
612
+ rows.extend(f" - `{scalar(item)}`\n" for item in field_value)
613
+ elif isinstance(field_value, Mapping):
614
+ raise PlanItemContractError(
615
+ f"plan item {field_path} must be scalar"
616
+ )
617
+ elif field == "details" and isinstance(field_value, str):
618
+ rows.extend(_render_literal(f"`{field_path}`", field_value, " "))
619
+ else:
620
+ rows.append(f" - `{field_path}`: `{scalar(field_value)}`\n")
621
+ return rows
622
+
623
+
624
+ def _render_variation_points(value: object) -> list[str]:
625
+ if not isinstance(value, list):
626
+ raise PlanItemContractError("plan item points must be an array")
627
+ rows = ["- Points:\n"]
628
+ allowed = {"behavior", "implementations", "evidence", "extractionDecision"}
629
+ decision_fields = ("extract", "interfaceKind", "coveredBy", "rationale")
630
+ for index, point in enumerate(value, 1):
631
+ if not isinstance(point, Mapping) or set(point) - allowed:
632
+ raise PlanItemContractError(f"plan item points[{index}] has unknown fields")
633
+ rows.append(f" - Point {index}:\n")
634
+ for field in ("behavior", "implementations", "evidence"):
635
+ rows.extend(_render_scalar_or_list(field, point.get(field), " "))
636
+ decision = point.get("extractionDecision")
637
+ if not isinstance(decision, Mapping) or set(decision) - set(decision_fields):
638
+ raise PlanItemContractError(
639
+ "variation extractionDecision has unknown fields"
640
+ )
641
+ rows.append(" - Extraction decision:\n")
642
+ for field in decision_fields:
643
+ rows.append(" " + line(_label(field), decision.get(field)))
644
+ return rows
645
+
646
+
647
+ def _render_variation_point_payload(payload: Mapping[str, Any]) -> list[str]:
648
+ rows: list[str] = []
649
+ for field in ("behavior", "implementations", "evidence"):
650
+ rows.extend(_render_scalar_or_list(field, payload.get(field)))
651
+ decision = payload.get("extractionDecision")
652
+ decision_fields = ("extract", "interfaceKind", "coveredBy", "rationale")
653
+ if not isinstance(decision, Mapping) or set(decision) - set(decision_fields):
654
+ raise PlanItemContractError("variation extractionDecision has unknown fields")
655
+ rows.append("- Extraction decision:\n")
656
+ for field in decision_fields:
657
+ rows.append(" " + line(_label(field), decision.get(field)))
658
+ return rows
659
+
660
+
661
+ def _render_scalar_or_list(key: str, value: object, indent: str = "") -> list[str]:
662
+ if isinstance(value, list):
663
+ if any(isinstance(entry, (Mapping, list)) for entry in value):
664
+ raise PlanItemContractError(f"plan item {key} must contain scalars")
665
+ rows = [f"{indent}- {_label(key)}:\n"]
666
+ rows.extend(f"{indent} - `{scalar(entry)}`\n" for entry in value)
667
+ return rows
668
+ if isinstance(value, Mapping):
669
+ raise PlanItemContractError(f"plan item {key} must be scalar")
670
+ if key in {"command", "commandOrObservation"} and isinstance(value, str):
671
+ command, annotation = split_verification_command(value)
672
+ rows = _render_literal(_label(key), command, indent)
673
+ if annotation is not None:
674
+ rows.append(
675
+ indent + line("Command annotation (not executable)", annotation)
676
+ )
677
+ return rows
678
+ return [indent + line(_label(key), value)]
679
+
680
+
681
+ def _render_payload(item_id: str, payload: object) -> list[str]:
682
+ if not isinstance(payload, Mapping):
683
+ raise PlanItemContractError(f"plan item {item_id} payload must be an object")
684
+ kind = item_id.split("-", 2)[1] if item_id.startswith("P-") else ""
685
+ allowed = (
686
+ _VAR_ANALYSIS_FIELDS
687
+ if item_id == "P-Var-0"
688
+ else _VAR_POINT_FIELDS
689
+ if kind == "Var"
690
+ else _PAYLOAD_FIELDS.get(kind)
691
+ )
692
+ if allowed is None or set(payload) - set(allowed):
693
+ raise PlanItemContractError(f"plan item {item_id} has unknown payload fields")
694
+ if kind == "Var" and item_id != "P-Var-0":
695
+ return _render_variation_point_payload(payload)
696
+ rows: list[str] = []
697
+ for key in allowed:
698
+ if key not in payload:
699
+ continue
700
+ if key in _OBJECT_LIST_FIELDS:
701
+ rows.extend(_render_object_list(key, payload[key]))
702
+ elif key == "points":
703
+ rows.extend(_render_variation_points(payload[key]))
704
+ else:
705
+ rows.extend(_render_scalar_or_list(key, payload[key]))
706
+ return rows
707
+
708
+
709
+ # 검증 시점의 서사에는 조립 소유 필드가 없다는 계약을 검증자에게 직접 알린다.
710
+ # run 003 실측: critic 이 P-Prep 항목에 `designSurfaceCoverage` 처분을 요구하며
711
+ # DISAGREE(b) — writer 가 그 필드를 쓰면 계약 위반이라 self-fix 로도 풀 수 없는
712
+ # 차단이었다.
713
+ _PREP_OWNERSHIP_NOTE = (
714
+ "\nOwnership boundary: `designPreparation` / `designSurfaceCoverage` rows "
715
+ "are produced by report assembly after this verification, so the narrative "
716
+ "under review carries neither — their absence is the contract, not a "
717
+ "defect. Judge the trigger evidence above; do not DISAGREE because the "
718
+ "writer did not produce those fields.\n"
719
+ )
720
+
721
+
722
+ def _step_coordinate(payload: object, field: str) -> int | None:
723
+ if not isinstance(payload, Mapping):
724
+ return None
725
+ value = payload.get(field)
726
+ if isinstance(value, int) and not isinstance(value, bool) and value >= 1:
727
+ return value
728
+ return None
729
+
730
+
731
+ def _render_prior_steps(item: Mapping[str, Any], all_items: object) -> str:
732
+ """같은 stage 의 선행 step 요약. run 003 실측: step 1.1 이 만드는 스크립트를
733
+ 1.2 검증자가 '없다'고 판정했다 — 검증 단위는 step 인데 payload 가 자기 행뿐이라
734
+ 계획이 앞 step 에서 만드는 것을 볼 수 없었다. dispatchQueue 가 좁혀져도 선행
735
+ 맥락은 봉투의 전체 items 에서 온다."""
736
+ stage = _step_coordinate(item.get("payload"), "stage")
737
+ step = _step_coordinate(item.get("payload"), "step")
738
+ if stage is None or step is None or step < 2:
739
+ return ""
740
+ priors: list[tuple[int, str]] = []
741
+ for other in all_items if isinstance(all_items, list) else []:
742
+ if not isinstance(other, Mapping):
743
+ continue
744
+ other_step = _step_coordinate(other.get("payload"), "step")
745
+ if (
746
+ other_step is None
747
+ or other_step >= step
748
+ or _step_coordinate(other.get("payload"), "stage") != stage
749
+ ):
750
+ continue
751
+ payload = other.get("payload")
752
+ action = str(payload.get("action") or other.get("subject") or "").strip()
753
+ files = payload.get("files")
754
+ if isinstance(files, str) and files.strip():
755
+ files = [files]
756
+ suffix = (
757
+ " — files: " + ", ".join(f"`{scalar(entry)}`" for entry in files)
758
+ if isinstance(files, list) and files
759
+ else ""
760
+ )
761
+ priors.append((other_step, f"- step {other_step}: {action}{suffix}\n"))
762
+ if not priors:
763
+ return ""
764
+ priors.sort()
765
+ return (
766
+ "\nPrior steps in this stage (already applied when this step runs):\n"
767
+ + "".join(text for _, text in priors)
768
+ + "Judge this step against the state after them — a script, file, or "
769
+ "path a prior step creates is not missing.\n"
770
+ )
771
+
772
+
773
+ def _render_analyser_split(verdicts: object) -> str:
774
+ """동수 항목에 이미 찍힌 분석자 표를 critic 프롬프트에 붙인다."""
775
+ if not isinstance(verdicts, list):
776
+ return ""
777
+ rows: list[str] = []
778
+ for verdict in verdicts:
779
+ if not isinstance(verdict, Mapping):
780
+ continue
781
+ worker = str(verdict.get("worker") or "").strip()
782
+ token = str(verdict.get("verdict") or "").strip()
783
+ if not worker or not token:
784
+ continue
785
+ kind = str(verdict.get("breakageKind") or "").strip()
786
+ label = f"{token}({kind})" if kind else token
787
+ rows.append(f"- `{worker}`: `{label}`\n")
788
+ if not rows:
789
+ return ""
790
+ return "Analyser split:\n" + "".join(rows)
791
+
792
+
793
+ def _tie_splits_from_state(
794
+ state_path: Path | None,
795
+ queue: Sequence[str],
796
+ ) -> dict[str, list[Any]]:
797
+ if state_path is None or not state_path.is_file():
798
+ return {}
799
+ items = _state_plan_body_items(_load_json_object(state_path), state_path)
800
+ allowed = set(queue)
801
+ splits: dict[str, list[Any]] = {}
802
+ for row in items:
803
+ if not isinstance(row, Mapping):
804
+ continue
805
+ item_id = str(row.get("id") or "")
806
+ verdicts = row.get("verdicts")
807
+ if item_id in allowed and isinstance(verdicts, list):
808
+ splits[item_id] = verdicts
809
+ return splits
810
+
811
+
812
+ _PRIOR_DISSENT_PROMPT_ANCHOR = "**Prior round dissent**"
813
+ _NO_SELF_FIX_NOTE = (
814
+ "no correction — this item's text shifted under a neighbouring rewrite"
815
+ )
816
+
817
+
818
+ def _prior_vote(row: Mapping[str, Any]) -> dict[str, Any]:
819
+ """직전 라운드의 한 표를 프롬프트가 렌더할 만큼만 옮긴다.
820
+
821
+ `basis` 는 워커가 적은 근거다. `explanation` 이 계약상 필수 줄이고 `note` 는
822
+ 반증 후보라, 설명이 비어 있을 때만 후자로 내려간다. 둘 다 없으면 근거 없는
823
+ 표이므로 빈 문자열을 남긴다 — 없는 근거를 지어내지 않는다.
824
+ """
825
+ vote: dict[str, Any] = {
826
+ "worker": str(row.get("worker") or ""),
827
+ "verdict": str(row.get("verdict") or ""),
828
+ }
829
+ kind = str(row.get("breakageKind") or "").strip()
830
+ if kind:
831
+ vote["breakageKind"] = kind
832
+ fixability = str(row.get("fixability") or "").strip()
833
+ if fixability:
834
+ vote["fixability"] = fixability
835
+ basis = (
836
+ str(row.get("explanation") or "").strip() or str(row.get("note") or "").strip()
837
+ )
838
+ if basis:
839
+ vote["basis"] = basis
840
+ return vote
841
+
842
+
843
+ def _prior_rounds_from_state(
844
+ state_path: Path | None,
845
+ queue: Sequence[str],
846
+ ) -> dict[str, dict[str, Any]]:
847
+ """큐에 오른 항목별로 직전 라운드의 표와 자가수정 노트를 모은다.
848
+
849
+ `planBodyVerification.planItems[].verdicts[]` 는 라운드마다 덮어써지므로 지금
850
+ 거기 남아 있는 것이 직전 라운드의 판정이다. 자가수정 노트는 감사용
851
+ `planItems[]` 행의 `selfFixNote` 에 본문 그대로 들어 있다.
852
+
853
+ 라운드 번호가 없는 표는 어느 라운드의 것인지 알 수 없어 싣지 않는다 —
854
+ `apply-verdicts --round` 가 필수라 계약을 지킨 행에는 항상 번호가 있다.
855
+ 큐 순서를 그대로 따라 담아 같은 입력이 같은 바이트를 내도록 한다.
856
+ """
857
+ if state_path is None or not state_path.is_file():
858
+ return {}
859
+ state = _load_json_object(state_path)
860
+ audit = state.get("planItems")
861
+ notes = {
862
+ str(row["id"]): row["selfFixNote"].strip()
863
+ for row in (audit if isinstance(audit, list) else [])
864
+ if isinstance(row, Mapping)
865
+ and isinstance(row.get("id"), str)
866
+ and isinstance(row.get("selfFixNote"), str)
867
+ and row["selfFixNote"].strip()
868
+ }
869
+ voted = {
870
+ str(row["id"]): row
871
+ for row in _state_plan_body_items(state, state_path)
872
+ if isinstance(row, Mapping) and isinstance(row.get("id"), str)
873
+ }
874
+ prior: dict[str, dict[str, Any]] = {}
875
+ for item_id in queue:
876
+ row = voted.get(item_id)
877
+ if row is None:
878
+ continue
879
+ verdicts = [
880
+ vote
881
+ for vote in (row.get("verdicts") or [])
882
+ if isinstance(vote, Mapping) and isinstance(vote.get("round"), int)
883
+ ]
884
+ if not verdicts:
885
+ continue
886
+ last = max(int(vote["round"]) for vote in verdicts)
887
+ entry: dict[str, Any] = {
888
+ "round": last,
889
+ "votes": [
890
+ _prior_vote(vote) for vote in verdicts if int(vote["round"]) == last
891
+ ],
892
+ }
893
+ if item_id in notes:
894
+ entry["selfFixNote"] = notes[item_id]
895
+ prior[item_id] = entry
896
+ return prior
897
+
898
+
899
+ def _reverify_carry(
900
+ args: argparse.Namespace,
901
+ queue: Sequence[str],
902
+ ) -> dict[str, Any]:
903
+ """라운드 2+ 봉투에 실을 직전 라운드 맥락. 실을 것이 없으면 빈 dict.
904
+
905
+ 라운드 1 은 `--state` 없이 준비되고, `--state` 가 있어도 판정이 하나도
906
+ 기록돼 있지 않으면 옮길 반대 의견이 없다. 두 경우 모두 봉투는 라운드 1 의
907
+ 모양 그대로다.
908
+ """
909
+ prior = _prior_rounds_from_state(getattr(args, "state", None), queue)
910
+ if not prior:
911
+ return {}
912
+ return {"dispatchKind": "reverify", "priorRounds": prior}
913
+
914
+
915
+ def _render_prior_round(entry: object) -> str:
916
+ """직전 라운드의 반대 의견을 이 항목의 블록으로 렌더한다.
917
+
918
+ 앵커는 `**Prior round dissent**` 이고, 워커가 답에 적는 줄의 앵커
919
+ (`**Prior dissent**`, `verdict_blocks.py` 가 읽는다) 와 다른 문자열이다.
920
+ 두 표기는 서로 다른 산출물의 서로 다른 앵커다.
921
+ """
922
+ if not isinstance(entry, Mapping):
923
+ return ""
924
+ votes = entry.get("votes")
925
+ if not isinstance(votes, list) or not votes:
926
+ return ""
927
+ rows = [f"{_PRIOR_DISSENT_PROMPT_ANCHOR} (round {scalar(entry.get('round'))}):\n"]
928
+ for vote in votes:
929
+ if not isinstance(vote, Mapping):
930
+ continue
931
+ kind = str(vote.get("breakageKind") or "")
932
+ token = str(vote.get("verdict") or "")
933
+ if kind:
934
+ token = f"{token}({kind})"
935
+ rows.append(
936
+ f"- `{scalar(vote.get('worker'))}`: `{scalar(token)}` — "
937
+ f"`{scalar(vote.get('basis'))}`\n"
938
+ )
939
+ if vote.get("fixability"):
940
+ rows.append(" " + line("Fixability", vote.get("fixability")))
941
+ rows.append(
942
+ line("What the planner changed", entry.get("selfFixNote") or _NO_SELF_FIX_NOTE)
943
+ )
944
+ return "".join(rows)
945
+
946
+
947
+ def _prompt(args: argparse.Namespace) -> str:
948
+ envelope = _load_json_object(
949
+ _prepared_items_path(args.run_manifest, require_regular=True)
950
+ )
951
+ all_items = envelope.get("items") if isinstance(envelope.get("items"), list) else []
952
+ items = all_items
953
+ queue = envelope.get("dispatchQueue")
954
+ if isinstance(queue, list):
955
+ allowed = {item_id for item_id in queue if isinstance(item_id, str)}
956
+ items = [
957
+ item
958
+ for item in items
959
+ if isinstance(item, Mapping) and item.get("id") in allowed
960
+ ]
961
+ prior_rounds = (
962
+ envelope.get("priorRounds")
963
+ if isinstance(envelope.get("priorRounds"), Mapping)
964
+ else {}
965
+ )
966
+ rows = ["# Plan verification queue\n", line("Item count", len(items))]
967
+ for index, item in enumerate(items, 1):
968
+ if not isinstance(item, Mapping):
969
+ continue
970
+ item_id = str(item.get("id") or "")
971
+ rows.extend(
972
+ (
973
+ f"\n## Plan item {index}\n",
974
+ line("Item ID", item_id),
975
+ line("Subject", item.get("subject")),
976
+ )
977
+ )
978
+ # 직전 반대 의견이 현재 본문보다 앞이다 — 계약이 요구하는 판단 순서가
979
+ # "반대 의견과 정정을 읽고 나서 지금 본문을 본다" 이기 때문이다.
980
+ rows.append(_render_prior_round(prior_rounds.get(item_id)))
981
+ rows.extend(_render_payload(item_id, item.get("payload")))
982
+ rows.append(_render_prior_steps(item, all_items))
983
+ if item_id.startswith("P-Prep-"):
984
+ rows.append(_PREP_OWNERSHIP_NOTE)
985
+ split = _render_analyser_split(
986
+ (envelope.get("tieSplits") or {}).get(item_id)
987
+ if isinstance(envelope.get("tieSplits"), Mapping)
988
+ else None
989
+ )
990
+ if split:
991
+ rows.append(split)
992
+ body = with_response_format("".join(rows))
993
+ if envelope.get("dispatchKind") == "critic-tie":
994
+ return with_rendered_by(critic_tie_prompt_text(body))
995
+ if envelope.get("dispatchKind") == "reverify":
996
+ return with_rendered_by(reverify_prompt_text(body))
997
+ return with_rendered_by(body)
998
+
999
+
1000
+ def _validate_prepared(args: argparse.Namespace) -> dict[str, Any]:
1001
+ source = _plan_source(args)
1002
+ expected = _envelope(source)
1003
+ path = _prepared_items_path(args.run_manifest, require_regular=True)
1004
+ actual = _load_json_object(path)
1005
+ if actual.get("items") != expected["items"]:
1006
+ raise PlanItemContractError(
1007
+ "prepared plan items do not match deterministic extraction"
1008
+ )
1009
+ expected_queue = _queue_for(
1010
+ expected["items"],
1011
+ _planning(source),
1012
+ args.run_manifest,
1013
+ **_queue_kwargs(args),
1014
+ )
1015
+ if actual.get("dispatchQueue") != expected_queue:
1016
+ raise PlanItemContractError("prepared dispatch queue does not match")
1017
+ if not getattr(args, "tie_vote", False):
1018
+ # 라운드 2+ 의 반대 의견 반입은 여기서만 검사된다. 봉투에서 빠지면
1019
+ # 프롬프트에도 빠지고, 반대한 워커는 자기 판정을 그대로 다시 적는다.
1020
+ expected_carry = _reverify_carry(args, expected_queue)
1021
+ if actual.get("priorRounds") != expected_carry.get("priorRounds"):
1022
+ raise PlanItemContractError(
1023
+ "prepared plan items do not carry the previous round's dissent "
1024
+ "— re-run `okstra plan-items prepare` with the same --state so "
1025
+ "each re-dispatched item carries its `**Prior round dissent**` "
1026
+ "block"
1027
+ )
1028
+ return {"ok": True, "operation": "validate-prepared", "path": str(path)}
1029
+
1030
+
1031
+ def _split_result_arg(raw: str) -> tuple[str, Path]:
1032
+ worker, separator, path = raw.partition("=")
1033
+ if not separator or not worker.strip() or not path.strip():
1034
+ raise PlanItemContractError(f"--result must be <worker-id>=<path>, got: {raw}")
1035
+ return worker.strip(), Path(path.strip())
1036
+
1037
+
1038
+ def _assigned_item_ids(items_path: Path) -> list[str]:
1039
+ envelope = _load_json_object(items_path)
1040
+ items = envelope.get("items")
1041
+ if not isinstance(items, list):
1042
+ raise PlanItemContractError(
1043
+ f"items envelope has no `items` array: {items_path}"
1044
+ )
1045
+ queue = envelope.get("dispatchQueue")
1046
+ if isinstance(queue, list):
1047
+ return [item_id for item_id in queue if isinstance(item_id, str) and item_id]
1048
+ ids: list[str] = []
1049
+ for item in items:
1050
+ item_id = item.get("id") if isinstance(item, Mapping) else None
1051
+ if not isinstance(item_id, str) or not item_id:
1052
+ raise PlanItemContractError(f"every item needs an `id`: {items_path}")
1053
+ ids.append(item_id)
1054
+ return ids
1055
+
1056
+
1057
+ def _verdict_row(worker: str, block: VerdictBlock) -> dict[str, Any]:
1058
+ """One `planItems[].verdicts[]` row. Optional fields stay absent when empty
1059
+ so the recorded table shows what the worker actually said.
1060
+
1061
+ The verdict crosses a vocabulary boundary here: a worker answers
1062
+ `UNVERIFIABLE`, and the schema persists that as `verification-error`
1063
+ (`PLAN_ITEM_VERDICTS`). Writing the worker's token straight through produced
1064
+ a data.json its own schema rejects, and the mapping the contract prescribes
1065
+ had to be applied by hand every round.
1066
+ """
1067
+ row: dict[str, Any] = {
1068
+ "worker": worker,
1069
+ "verdict": PLAN_ITEM_VERDICTS[block.verdict],
1070
+ }
1071
+ for key, value in (
1072
+ ("breakageKind", block.breakage_kind),
1073
+ ("fixability", block.fixability),
1074
+ ("note", block.note),
1075
+ ("explanation", block.explanation),
1076
+ ):
1077
+ if value:
1078
+ row[key] = value
1079
+ return row
1080
+
1081
+
1082
+ def _worker_blocks(
1083
+ raw_results: list[str], assigned: set[str]
1084
+ ) -> list[tuple[str, dict[str, VerdictBlock]]]:
1085
+ """Each worker's parsed response, refusing any queue mismatch.
1086
+
1087
+ A missing vote and an invented item are both silent in a hand-written
1088
+ parser; each is a round scored on a table that does not match the queue.
1089
+ """
1090
+ collected: list[tuple[str, dict[str, VerdictBlock]]] = []
1091
+ seen_workers: set[str] = set()
1092
+ for raw in raw_results:
1093
+ worker, path = _split_result_arg(raw)
1094
+ if worker in seen_workers:
1095
+ raise PlanItemContractError(
1096
+ f"duplicate worker result for `{worker}` — one worker may cast "
1097
+ "only one verdict per plan-item round"
1098
+ )
1099
+ seen_workers.add(worker)
1100
+ try:
1101
+ blocks = parse_verdict_blocks(path.read_text(encoding="utf-8"))
1102
+ except (OSError, UnicodeError) as exc:
1103
+ raise PlanItemContractError(f"cannot read result {path}: {exc}") from exc
1104
+ answered = set(blocks)
1105
+ missing = sorted(assigned - answered)
1106
+ if missing:
1107
+ raise PlanItemContractError(
1108
+ f"worker `{worker}` was assigned {len(assigned)} items but "
1109
+ f"returned no verdict for {missing} — an unanswered item cannot "
1110
+ f"be scored, and dropping it silently is what makes a round look "
1111
+ f"complete when it is not"
1112
+ )
1113
+ unknown = sorted(answered - assigned)
1114
+ if unknown:
1115
+ raise PlanItemContractError(
1116
+ f"worker `{worker}` returned verdicts for {unknown}, which are "
1117
+ f"not in the persisted plan-item queue"
1118
+ )
1119
+ collected.append((worker, blocks))
1120
+ return collected
1121
+
1122
+
1123
+ def _collect_verdicts(args: argparse.Namespace) -> dict[str, Any]:
1124
+ assigned = _assigned_item_ids(args.items)
1125
+ collected = _worker_blocks(args.result, set(assigned))
1126
+ envelope = {
1127
+ "schemaVersion": "1.0",
1128
+ "taskType": "implementation-planning",
1129
+ "planItems": [
1130
+ {
1131
+ "id": item_id,
1132
+ "verdicts": [
1133
+ _verdict_row(worker, blocks[item_id])
1134
+ for worker, blocks in collected
1135
+ ],
1136
+ }
1137
+ for item_id in assigned
1138
+ ],
1139
+ }
1140
+ write_json_atomic(args.output, envelope)
1141
+ return {"ok": True, "operation": "collect-verdicts", "path": str(args.output)}
1142
+
1143
+
1144
+ def _plan_body_items(data: dict[str, Any], data_path: Path) -> list[dict[str, Any]]:
1145
+ verification = _planning(data).get("planBodyVerification")
1146
+ if not isinstance(verification, Mapping):
1147
+ raise PlanItemContractError(
1148
+ f"implementationPlanning.planBodyVerification must be an object: {data_path}"
1149
+ )
1150
+ items = verification.get("planItems")
1151
+ if not isinstance(items, list):
1152
+ raise PlanItemContractError(
1153
+ f"planBodyVerification.planItems must be an array: {data_path}"
1154
+ )
1155
+ return items
1156
+
1157
+
1158
+ def _state_plan_body_items(
1159
+ state: dict[str, Any],
1160
+ state_path: Path,
1161
+ ) -> list[dict[str, Any]]:
1162
+ if state.get("owner") != "convergence":
1163
+ raise PlanItemContractError(
1164
+ f"plan-body state owner must be convergence: {state_path}"
1165
+ )
1166
+ verification = state.get("planBodyVerification")
1167
+ if not isinstance(verification, Mapping):
1168
+ raise PlanItemContractError(
1169
+ f"planBodyVerification must be an object: {state_path}"
1170
+ )
1171
+ items = verification.get("planItems")
1172
+ if not isinstance(items, list):
1173
+ raise PlanItemContractError(
1174
+ f"planBodyVerification.planItems must be an array: {state_path}"
1175
+ )
1176
+ return items
1177
+
1178
+
1179
+ def _new_v3_state() -> dict[str, Any]:
1180
+ return {
1181
+ "schemaVersion": "1.1",
1182
+ "owner": "convergence",
1183
+ "planItems": [],
1184
+ "roundHistory": [],
1185
+ "selfFixRoundsApplied": 0,
1186
+ "planBodyVerification": {
1187
+ "roundCount": 0,
1188
+ "gateResult": "passed",
1189
+ "gateBlockedBy": [],
1190
+ "selfFixRoundsApplied": 0,
1191
+ "selfFixStopReason": "not-attempted",
1192
+ "planItems": [],
1193
+ "dissentLog": [],
1194
+ },
1195
+ }
1196
+
1197
+
1198
+ def _derivations(args: argparse.Namespace) -> dict[str, Any]:
1199
+ """Candidate statements each answered clarification may have falsified.
1200
+
1201
+ Advisory by construction: it reports where a decision's subject is mentioned
1202
+ and never which mentions are now wrong. Both contracts require the author to
1203
+ enumerate before editing; this supplies the enumeration, which is the half
1204
+ that was being skipped, and leaves the judgement where it belongs.
1205
+ """
1206
+ try:
1207
+ sidecar = args.response.read_text(encoding="utf-8")
1208
+ except (OSError, UnicodeError) as exc:
1209
+ raise PlanItemContractError(
1210
+ f"cannot read user-response sidecar {args.response}: {exc}"
1211
+ ) from exc
1212
+ planning = _planning(_load_json_object(args.data))
1213
+ entries = [
1214
+ entry
1215
+ for entry in parse_user_response_entries(sidecar)
1216
+ if args.clarification is None or entry.response_id == args.clarification
1217
+ ]
1218
+ if args.clarification is not None and not entries:
1219
+ raise PlanItemContractError(
1220
+ f"{args.response} has no response block for {args.clarification}"
1221
+ )
1222
+ clarifications = []
1223
+ for entry in entries:
1224
+ tokens = extract_tokens(f"{entry.value}\n{entry.rationale or ''}")
1225
+ clarifications.append(
1226
+ {
1227
+ "id": entry.response_id,
1228
+ "disposition": entry.disposition,
1229
+ "tokens": tokens,
1230
+ "candidates": find_derivations(planning, tokens),
1231
+ }
1232
+ )
1233
+ return {
1234
+ "ok": True,
1235
+ "operation": "derivations",
1236
+ "advisory": True,
1237
+ "clarifications": clarifications,
1238
+ }
1239
+
1240
+
1241
+ def _stage_ledger_snapshot(run_manifest: Path | None) -> dict[str, str] | None:
1242
+ """`{스테이지 번호: 상태}`. 원장을 못 읽으면 ``None``.
1243
+
1244
+ 게이트가 범위를 좁히려면 어느 스테이지가 지금 시작 가능한지 알아야 한다. 그
1245
+ 사실은 Stage 원장에 있고, 상태 어휘(`done` / `active` / `ready` / `blocked`)는
1246
+ `stage_targets.StageLifecycle.status` 의 것을 그대로 쓴다 — 원장이 자기 어휘를
1247
+ 따로 가지면 같은 stage 가 소비처마다 다르게 읽힌다.
1248
+
1249
+ ``None`` 은 판정 근거가 없다는 뜻이고, 소비처는 좁히지 않는다. 첫 계획 run 이라
1250
+ 원장이 아직 없는 경우와 `--run-manifest` 를 안 넘긴 경우가 여기 해당한다.
1251
+ """
1252
+ if run_manifest is None:
1253
+ return None
1254
+ try:
1255
+ authority = validated_run_authority(run_manifest)
1256
+ task_root = RunRef.from_run_dir(authority.run_dir).task_root
1257
+ ledger = build_stage_ledger(task_root)
1258
+ except (ConvergenceContractError, ValueError, OSError):
1259
+ return None
1260
+ if not isinstance(ledger, Mapping) or not isinstance(ledger.get("stages"), list):
1261
+ return None
1262
+ snapshot = {
1263
+ str(row["stage"]): str(row["status"])
1264
+ for row in ledger["stages"]
1265
+ if isinstance(row, Mapping)
1266
+ and isinstance(row.get("stage"), int)
1267
+ and isinstance(row.get("status"), str)
1268
+ }
1269
+ return snapshot or None
1270
+
1271
+
1272
+ # `plan-body-verification-<task-type-segment>-<seq>.json`. seq 는 파일 이름에만
1273
+ # 있다 — 상태 본문은 자기 run 의 순번을 담지 않는다.
1274
+ _PRIOR_STATE_NAME_RE = re.compile(r"^plan-body-verification-.+-(?P<seq>\d{3,})\.json$")
1275
+
1276
+
1277
+ class _PriorRunCarry:
1278
+ """직전 run 에서 실제로 이월된 것. 이월을 요청하지 않았으면 만들지 않는다."""
1279
+
1280
+ def __init__(self, prev_seq: str, count: int) -> None:
1281
+ self.prev_seq = prev_seq
1282
+ self.count = count
1283
+
1284
+
1285
+ def _state_task_root(path: Path, *, option: str) -> Path:
1286
+ """``<task_root>/runs/<task-type>/state/<file>`` 의 task_root.
1287
+
1288
+ plan-body 상태 파일은 자기 안에 task 신원을 담지 않는다(`_new_v3_state` 는
1289
+ `schemaVersion` 과 `owner` 만 쓴다). 남은 신원은 경로뿐이므로, 이월 양쪽이
1290
+ 같은 task 의 기록인지는 여기서만 판정할 수 있다. 정규 run 경로가 아니면
1291
+ 판정할 근거가 없다는 뜻이고, 그때는 이월을 거절한다 — 판정 못 하는 신원을
1292
+ 통과시키면 남의 task 판정이 이 run 의 게이트를 결정한다.
1293
+ """
1294
+ try:
1295
+ return RunRef.from_run_dir(path.parent.parent).task_root
1296
+ except ValueError as exc:
1297
+ raise PlanItemContractError(
1298
+ f"{option} must be a plan-body state under "
1299
+ f"<task-root>/runs/<task-type>/state/: {path}"
1300
+ ) from exc
1301
+
1302
+
1303
+ def _carry_prior_run_verdicts(
1304
+ args: argparse.Namespace,
1305
+ added: list[dict[str, Any]],
1306
+ hashes: Mapping[str, str],
1307
+ ) -> _PriorRunCarry | None:
1308
+ """직전 run 이 같은 본문에 내린 판정을 새로 시드된 행에 옮긴다.
1309
+
1310
+ 실패한 계획 run 을 다시 돌면 판정이 하나도 넘어오지 않았다. 이월은 답변된
1311
+ clarification 이 있는 경로에만 있고(`incremental_scope.decide_scope` 는
1312
+ 답변된 `C-NNN` 이 없으면 `full` 을 되돌려 `incremental_carry` 를 도달 불가로
1313
+ 만든다), 그래서 아무도 고치지 않은 문장이 매 재실행마다 다시 채점됐다.
1314
+
1315
+ 행 모양은 `incremental_carry._stamp_carried` 와 같다 — `verdicts` 를 그대로
1316
+ 옮기고, `carriedForwardFromSeq` 에 직전 seq 를 찍고, `verifiedContentHash` 를
1317
+ 이번 추출 해시로 맞춘다. 그 해시가 시드의 `dispatchQueue` 계산에 그대로
1318
+ 들어가므로(`_queue_for` → `reverify_item_ids`), 이월된 행은 라운드 1 큐에서
1319
+ 빠진다. 큐를 따로 줄이지 않는 이유가 이것이다: incremental 경로가
1320
+ `incremental_carry._recompute_dispatch_queue` 에서 쓰는 것과 같은 장치다.
1321
+
1322
+ id 만 같은 행은 절대 이월하지 않는다. `P-*` id 는 위치에서 나오므로 계획이
1323
+ 한 줄만 밀려도 같은 번호가 다른 문장을 가리킨다. 해시가 같아야 그 문장이
1324
+ 같은 문장이다.
1325
+ """
1326
+ prior = getattr(args, "prior_state", None)
1327
+ if prior is None:
1328
+ return None
1329
+ if args.state is None:
1330
+ raise PlanItemContractError("--prior-state requires --state")
1331
+ matched = _PRIOR_STATE_NAME_RE.match(prior.name)
1332
+ if matched is None:
1333
+ raise PlanItemContractError(
1334
+ "--prior-state must name a plan-body-verification-<task-type>-<seq>"
1335
+ f".json file; the seq is what a carried row is tagged with: {prior}"
1336
+ )
1337
+ if _state_task_root(prior, option="--prior-state") != _state_task_root(
1338
+ args.state, option="--state"
1339
+ ):
1340
+ raise PlanItemContractError(
1341
+ f"--prior-state belongs to another task than --state: {prior}"
1342
+ )
1343
+ prev_seq = matched.group("seq")
1344
+ prior_rows = {
1345
+ str(row["id"]): row
1346
+ for row in _state_plan_body_items(_load_json_object(prior), prior)
1347
+ if isinstance(row, Mapping) and isinstance(row.get("id"), str)
1348
+ }
1349
+ count = 0
1350
+ for row in added:
1351
+ item_id = row.get("id")
1352
+ previous = prior_rows.get(item_id)
1353
+ if previous is None:
1354
+ continue
1355
+ verdicts = previous.get("verdicts")
1356
+ if not isinstance(verdicts, list) or not verdicts:
1357
+ continue
1358
+ current_hash = hashes.get(item_id)
1359
+ if not current_hash or previous.get("verifiedContentHash") != current_hash:
1360
+ continue
1361
+ row["verdicts"] = copy.deepcopy(verdicts)
1362
+ row["carriedForwardFromSeq"] = prev_seq
1363
+ row["verifiedContentHash"] = current_hash
1364
+ count += 1
1365
+ return _PriorRunCarry(prev_seq, count)
1366
+
1367
+
1368
+ def _seed(args: argparse.Namespace) -> dict[str, Any]:
1369
+ """Create the `planBodyVerification.planItems[]` rows a round lands in.
1370
+
1371
+ `apply-verdicts` refuses a verdict whose item has no row — correctly, since
1372
+ the gate is re-derived from that table and a verdict with nowhere to land
1373
+ would score as never cast. But nothing created the rows: the report writer
1374
+ leaves `planItems: []` (§5.5.9 is a lead substep that runs after it), and
1375
+ there was no step, in code or in the contract, that filled them. Every round
1376
+ had to be hand-seeded before the CLI would accept its own output.
1377
+
1378
+ Idempotent by id. An existing row keeps everything it carries — verdicts
1379
+ already applied, `carriedForwardFromSeq`, `selfFixNote` — because a re-seed
1380
+ between rounds must not erase the round before it.
1381
+
1382
+ `--prior-state` extends that to the previous *run*: a newly seeded item
1383
+ whose text the previous run already judged inherits its verdicts instead of
1384
+ entering round 1 again (`_carry_prior_run_verdicts`).
1385
+ """
1386
+ source = _plan_source(args)
1387
+ extracted = _envelope(source)["items"]
1388
+ hashes = {item["id"]: content_hash(item) for item in extracted}
1389
+ if args.state is not None:
1390
+ data = (
1391
+ _load_json_object(args.state) if args.state.is_file() else _new_v3_state()
1392
+ )
1393
+ recorded = _state_plan_body_items(data, args.state)
1394
+ target = args.state
1395
+ else:
1396
+ if args.data is None:
1397
+ raise PlanItemContractError("--narrative requires --state")
1398
+ data = _load_json_object(args.data)
1399
+ recorded = _plan_body_items(data, args.data)
1400
+ target = args.data
1401
+ known = {item.get("id") for item in recorded if isinstance(item, Mapping)}
1402
+ added = [
1403
+ # Only the fields a `planItems[]` row may carry. The extraction also
1404
+ # yields `payload` and `ticketId` for the verifier prompt, and the row
1405
+ # schema is `additionalProperties: false` — copying the item wholesale
1406
+ # put two schema violations in every seeded row, on the exact path the
1407
+ # contract tells a lead to follow.
1408
+ {key: item[key] for key in ("id", "subject", "sourceSection") if key in item}
1409
+ | ({"stageScope": item["stageScope"]} if item.get("stageScope") else {})
1410
+ | ({"block": item["block"]} if item.get("block") else {})
1411
+ | ({"contentHash": hashes[item["id"]]} if item["id"] in hashes else {})
1412
+ | {"verdicts": []}
1413
+ for item in extracted
1414
+ if item["id"] not in known
1415
+ ]
1416
+ carried = _carry_prior_run_verdicts(args, added, hashes)
1417
+ recorded.extend(added)
1418
+ for row in recorded:
1419
+ item_id = row.get("id") if isinstance(row, Mapping) else None
1420
+ if isinstance(item_id, str) and item_id in hashes:
1421
+ row["contentHash"] = hashes[item_id]
1422
+ if args.state is not None:
1423
+ audit_rows = data.get("planItems")
1424
+ if not isinstance(audit_rows, list):
1425
+ raise PlanItemContractError("state planItems must be an array")
1426
+ audited = {item.get("id") for item in audit_rows if isinstance(item, Mapping)}
1427
+ audit_rows.extend(
1428
+ {
1429
+ "id": item["id"],
1430
+ "subject": item["subject"],
1431
+ "sourceSection": item["sourceSection"],
1432
+ "ticketId": item.get("ticketId") or "unknown",
1433
+ "rounds": [],
1434
+ "clarificationId": None,
1435
+ }
1436
+ for item in extracted
1437
+ if item["id"] not in audited
1438
+ )
1439
+ verification = (
1440
+ data["planBodyVerification"]
1441
+ if args.state is not None
1442
+ else _planning(data)["planBodyVerification"]
1443
+ )
1444
+ ledger = _merged_ledger(_planning(source), getattr(args, "run_manifest", None))
1445
+ if ledger:
1446
+ verification["stageLedger"] = ledger
1447
+ previous = {
1448
+ str(row["id"]): str(row["verifiedContentHash"])
1449
+ for row in recorded
1450
+ if isinstance(row, Mapping)
1451
+ and isinstance(row.get("id"), str)
1452
+ and isinstance(row.get("verifiedContentHash"), str)
1453
+ }
1454
+ verification["dispatchQueue"] = _queue_for(
1455
+ extracted,
1456
+ _planning(source),
1457
+ getattr(args, "run_manifest", None),
1458
+ previous,
1459
+ )
1460
+ verification["gating"] = (
1461
+ verification.get("gating") is True
1462
+ or requires_plan_repair(verification)
1463
+ or not advisory_plan_body_gating(_planning(source), extracted)
1464
+ )
1465
+ write_json_atomic(target, data)
1466
+ result = {
1467
+ "ok": True,
1468
+ "operation": "seed",
1469
+ "path": str(target),
1470
+ "seeded": len(added),
1471
+ "existing": len(known),
1472
+ }
1473
+ if carried is not None:
1474
+ result["carried"] = carried.count
1475
+ result["carriedForwardFromSeq"] = carried.prev_seq
1476
+ if carried.count:
1477
+ # 시드는 prepare **뒤에** 돈다(리드 계약 step 1). 이월이 상태의
1478
+ # 큐만 줄이면 `plan-items prompt` 가 읽는 형제 파일은 여전히 전량
1479
+ # 이라, 워커는 직전 run 이 이미 판정한 행을 다시 받는다.
1480
+ sync_prepared_dispatch_queue(target, data)
1481
+ return result
1482
+
1483
+
1484
+ def _judged_row(row: Mapping[str, Any], project_root: Path | None) -> dict[str, Any]:
1485
+ """한 verdict 행에 재현 결과를 채워 돌려준다.
1486
+
1487
+ `reproductionResult` 는 들어온 값을 쓰지 않고 **항상 여기서 덮어쓴다.** 그
1488
+ 필드가 1표 차단의 자격을 결정하므로, 워커가 보낸 값을 그대로 실으면 검증자가
1489
+ 자기 주장의 판정을 스스로 적는 것이 된다. 계약이 "okstra 가 쓴다" 고 적은 것이
1490
+ 이 뜻이고, 그 문장은 이 덮어쓰기가 있어야 참이다.
1491
+
1492
+ 프로젝트를 못 찾으면 `not-runnable` 이다 — 못 돌렸다는 사실이 기록되고, 그
1493
+ 주장은 정족수로 내려간다. 조용히 통과시키지도, 근거 없이 차단하지도 않는다.
1494
+ """
1495
+ judged = dict(row)
1496
+ if str(judged.get("claimKind") or "") != "fact":
1497
+ judged.pop("reproductionResult", None)
1498
+ return judged
1499
+ judged["reproductionResult"] = (
1500
+ reproduce(judged.get("reproduction"), project_root=project_root)
1501
+ if project_root is not None
1502
+ else NOT_RUNNABLE
1503
+ )
1504
+ return judged
1505
+
1506
+
1507
+ def _probe_project_root(run_manifest: Path | None) -> Path | None:
1508
+ if run_manifest is None:
1509
+ return None
1510
+ try:
1511
+ return validated_run_authority(run_manifest).project_root
1512
+ except ConvergenceContractError:
1513
+ return None
1514
+
1515
+
1516
+ def _apply_verdicts(args: argparse.Namespace) -> dict[str, Any]:
1517
+ target = args.state or args.data
1518
+ data = _load_json_object(target)
1519
+ recorded = (
1520
+ _state_plan_body_items(data, target)
1521
+ if args.state is not None
1522
+ else _plan_body_items(data, target)
1523
+ )
1524
+ known = {item.get("id") for item in recorded if isinstance(item, Mapping)}
1525
+ verification = (
1526
+ data["planBodyVerification"]
1527
+ if args.state is not None
1528
+ else _planning(data)["planBodyVerification"]
1529
+ )
1530
+ discard_open_rounds = bool(getattr(args, "discard_open_rounds", False))
1531
+ if discard_open_rounds and args.state is not None and args.result:
1532
+ _restore_queue_from_results(verification, args.result, recorded)
1533
+ assigned = _narrow_dispatch_queue(args, verification, known)
1534
+ rows = _incoming_verdict_rows(args, assigned)
1535
+ missing = sorted(item_id for item_id in rows if item_id not in known)
1536
+ if missing:
1537
+ raise PlanItemContractError(
1538
+ f"the report's planBodyVerification has no row for {missing} — the "
1539
+ f"gate is re-derived from that table, so a verdict with nowhere to "
1540
+ f"land would be scored as if it were never cast"
1541
+ )
1542
+ if args.round_number < 1:
1543
+ raise PlanItemContractError("--round must be 1 or greater")
1544
+ project_root = _probe_project_root(getattr(args, "run_manifest", None))
1545
+ append = bool(getattr(args, "append", False))
1546
+ replaces_critic = append and any(
1547
+ is_critic_worker(row.get("worker", ""))
1548
+ and any(
1549
+ v.get("worker") == row.get("worker") for v in rows.get(item.get("id"), [])
1550
+ )
1551
+ for item in recorded
1552
+ for row in item.get("verdicts", [])
1553
+ )
1554
+ if not append or replaces_critic:
1555
+ _reject_uncompleted_round_loss(
1556
+ recorded,
1557
+ rows,
1558
+ data.get("roundHistory"),
1559
+ args.round_number,
1560
+ target,
1561
+ discard_open_rounds=discard_open_rounds,
1562
+ )
1563
+ writer = _append_item_verdicts if append else _replace_item_verdicts
1564
+ for item in recorded:
1565
+ if isinstance(item, Mapping) and item.get("id") in rows:
1566
+ writer(item, rows[item["id"]], args.round_number, project_root)
1567
+ if requires_plan_repair(verification):
1568
+ verification["gating"] = True
1569
+ _validate_advisory_round(verification, args.round_number)
1570
+ write_json_atomic(target, data)
1571
+ return {"ok": True, "operation": "apply-verdicts", "path": str(target)}
1572
+
1573
+
1574
+ def _stamped_verdicts(
1575
+ incoming: list[dict[str, Any]],
1576
+ round_number: int,
1577
+ project_root: Path | None,
1578
+ ) -> list[dict[str, Any]]:
1579
+ return [
1580
+ {**_judged_row(row, project_root), "round": round_number} for row in incoming
1581
+ ]
1582
+
1583
+
1584
+ def _remember_verified_hash(item: dict[str, Any]) -> None:
1585
+ if item.get("contentHash"):
1586
+ item["verifiedContentHash"] = item["contentHash"]
1587
+
1588
+
1589
+ def _replace_item_verdicts(
1590
+ item: dict[str, Any],
1591
+ incoming: list[dict[str, Any]],
1592
+ round_number: int,
1593
+ project_root: Path | None,
1594
+ ) -> None:
1595
+ item["verdicts"] = _stamped_verdicts(incoming, round_number, project_root)
1596
+ _remember_verified_hash(item)
1597
+
1598
+
1599
+ def _append_item_verdicts(
1600
+ item: dict[str, Any],
1601
+ incoming: list[dict[str, Any]],
1602
+ round_number: int,
1603
+ project_root: Path | None,
1604
+ ) -> None:
1605
+ """분석자 표를 보존하고, 비판 검토자의 후속 판정을 현재 표로 갱신한다."""
1606
+ stamped = _stamped_verdicts(incoming, round_number, project_root)
1607
+ existing = item.get("verdicts")
1608
+ current = existing if isinstance(existing, list) else []
1609
+ seen = {
1610
+ row.get("worker"): row.get("round", 1)
1611
+ for row in current
1612
+ if isinstance(row, Mapping)
1613
+ }
1614
+ refreshed = {
1615
+ row.get("worker")
1616
+ for row in stamped
1617
+ if is_critic_worker(row.get("worker", ""))
1618
+ and row.get("worker") in seen
1619
+ and round_number > seen[row["worker"]]
1620
+ }
1621
+ clash = [
1622
+ row.get("worker")
1623
+ for row in stamped
1624
+ if row.get("worker") in seen and row.get("worker") not in refreshed
1625
+ ]
1626
+ if clash:
1627
+ raise PlanItemContractError(
1628
+ f"plan item {item.get('id')} already has a vote from {clash} — "
1629
+ "the extra vote must come from a worker who has not voted on it. "
1630
+ "--append accepts critic corrections; an analyser re-voting in a later "
1631
+ "round is recorded without --append, after the earlier round is "
1632
+ "closed with complete-round"
1633
+ )
1634
+ item["verdicts"] = [
1635
+ row for row in current if row.get("worker") not in refreshed
1636
+ ] + stamped
1637
+ _remember_verified_hash(item)
1638
+
1639
+
1640
+ def _restore_queue_from_results(
1641
+ verification: Any,
1642
+ raw_results: list[str],
1643
+ recorded: Sequence[Mapping[str, Any]],
1644
+ ) -> None:
1645
+ """재적용하는 라운드의 큐를 그 라운드의 결과 파일이 답한 항목 집합으로 되돌린다.
1646
+
1647
+ `dispatchQueue` 는 값이 하나뿐이라 라운드마다 덮인다 — 라운드 1 이 26개,
1648
+ critic 동수 라운드가 8개, 라운드 3 이 19개였던 run 에서 상태에 남는 것은
1649
+ 19개뿐이다. `apply-verdicts` 와 `complete-round` 는 둘 다 그 큐로 스코프하고,
1650
+ `_queue_for` 는 이미 두 번 전진한 해시로부터의 순수 계산이라 지난 큐를
1651
+ 되살릴 명령이 없었다. 그래서 `--discard-open-rounds` 로 라운드 1 을 다시
1652
+ 적용하면 큐 밖 id 라며 거절됐고, 복구는 상태 파일을 손으로 고쳐야만 됐다
1653
+ (실측 2026-09-09, `fontsninja-v3-site` dev-10627 planning 002).
1654
+
1655
+ 결과 파일의 `### P-*` 블록 집합이 곧 그 라운드의 큐다 — 워커는 배정된 항목
1656
+ 전부에 답해야 통과하고(`_worker_blocks`), 큐 밖 항목에는 답할 수 없었다.
1657
+ 순서는 계획 항목 순서를 따른다. 계획에 없는 id 는 큐에 넣지 않으므로 뒤의
1658
+ `_worker_blocks` 가 그 id 를 이름하며 거절한다.
1659
+ """
1660
+ if not isinstance(verification, dict):
1661
+ return
1662
+ answered: set[str] = set()
1663
+ for raw in raw_results:
1664
+ _worker, path = _split_result_arg(raw)
1665
+ try:
1666
+ answered |= set(parse_verdict_blocks(path.read_text(encoding="utf-8")))
1667
+ except (OSError, UnicodeError) as exc:
1668
+ raise PlanItemContractError(f"cannot read result {path}: {exc}") from exc
1669
+ restored = [
1670
+ str(item["id"])
1671
+ for item in recorded
1672
+ if isinstance(item, Mapping) and item.get("id") in answered
1673
+ ]
1674
+ verification["dispatchQueue"] = restored
1675
+ print(
1676
+ f"apply-verdicts: dispatchQueue restored from the result files "
1677
+ f"({len(restored)} items)",
1678
+ file=sys.stderr,
1679
+ )
1680
+
1681
+
1682
+ def _reject_uncompleted_round_loss(
1683
+ recorded: Sequence[Mapping[str, Any]],
1684
+ rows: Mapping[str, Any],
1685
+ history: object,
1686
+ round_number: int,
1687
+ target: Path,
1688
+ *,
1689
+ discard_open_rounds: bool = False,
1690
+ ) -> None:
1691
+ """이번 라운드가 아직 닫히지 않은 다른 라운드의 표를 지우려 하면 쓰기 전에 거절한다.
1692
+
1693
+ `--append` 없는 apply-verdicts 는 항목의 verdicts 를 통째로 교체한다 — 지난
1694
+ 라운드 표는 `complete-round` 가 `planItems[].rounds` 에 스냅샷으로 남길 때만
1695
+ 살아남는다. 그 스냅샷 없이 교체하면 표는 복구할 수 없이 사라지고, 유일한
1696
+ 가드였던 `_reject_round_gap` 은 complete-round 안에 있어 사라진 뒤에야 말했다.
1697
+ 실측(2026-09-09, `fontsninja-v3-site` dev-10627 planning 002): r1·r2 를 닫지
1698
+ 않고 r3 를 적용해 큐 19건의 r1 표가 없어졌다.
1699
+
1700
+ 이력이 없는 데이터(final-report `--data` 경로)는 대상이 아니다 — 라운드
1701
+ 스냅샷을 갖는 것은 convergence 소유 상태 파일뿐이다.
1702
+
1703
+ 직전 run 에서 이월된 표도 대상이 아니다. 그 행의 라운드 번호는 **그 run 의**
1704
+ 번호라 이번 run 의 이력에는 없고, 그래서 열린 라운드로 읽혔다 — 안내하는
1705
+ `complete-round --round 2` 는 이번 run 에 존재하지도 않는 라운드라 실행할 수
1706
+ 없었다(2026-09-24, jobs implementation-planning 003: seed --prior-state 가
1707
+ 이월한 P-Dep·P-Var 5건이 round 1 적용을 막음). 그 표의 이력은 직전 run 의
1708
+ 상태 파일이 갖고 있다.
1709
+ """
1710
+ if not isinstance(history, list):
1711
+ return
1712
+ closed = {
1713
+ row.get("round")
1714
+ for row in history
1715
+ if isinstance(row, Mapping) and isinstance(row.get("round"), int)
1716
+ }
1717
+ at_risk: dict[int, list[str]] = {}
1718
+ for item in recorded:
1719
+ if not isinstance(item, Mapping) or item.get("id") not in rows:
1720
+ continue
1721
+ for row in item.get("verdicts") or []:
1722
+ if not isinstance(row, Mapping):
1723
+ continue
1724
+ if str(row.get("carriedForwardFromSeq") or "").strip():
1725
+ continue
1726
+ recorded_round = row.get("round")
1727
+ if (
1728
+ isinstance(recorded_round, int)
1729
+ and recorded_round != round_number
1730
+ and recorded_round not in closed
1731
+ ):
1732
+ at_risk.setdefault(recorded_round, []).append(str(item.get("id")))
1733
+ if not at_risk:
1734
+ return
1735
+ detail = "; ".join(
1736
+ f"round {number}: {', '.join(sorted(set(ids)))}"
1737
+ for number, ids in sorted(at_risk.items())
1738
+ )
1739
+ if discard_open_rounds:
1740
+ # 복구 경로 — 잃어버린 라운드를 결과 파일에서 순서대로 다시 적용할 때는
1741
+ # 지금 남은 뒤 라운드 표가 버려야 할 쪽이다. 무엇을 버리는지는 남긴다.
1742
+ print(
1743
+ f"apply-verdicts: discarding open-round verdicts ({detail})",
1744
+ file=sys.stderr,
1745
+ )
1746
+ return
1747
+ commands = " then ".join(
1748
+ f"`okstra plan-items complete-round --state {target} "
1749
+ f"--run-manifest <run-manifest> --round {number}`"
1750
+ for number in sorted(at_risk)
1751
+ )
1752
+ raise PlanItemContractError(
1753
+ f"round {round_number} would replace verdicts of a round that was never "
1754
+ f"completed ({detail}) — replacing these verdicts would lose their history; "
1755
+ "only complete-round keeps a round's votes in planItems[].rounds. Close "
1756
+ f"the earlier round first: run {commands}, then re-run this command. "
1757
+ "If those rows are themselves being re-applied from their result files "
1758
+ "in order, pass --discard-open-rounds. Nothing was written."
1759
+ )
1760
+
1761
+
1762
+ def _round_snapshot(item: Mapping[str, Any], round_number: int) -> dict[str, Any]:
1763
+ votes = {
1764
+ str(row["worker"]): str(row["verdict"])
1765
+ for row in item.get("verdicts", [])
1766
+ if isinstance(row, Mapping)
1767
+ and row.get("round") == round_number
1768
+ and isinstance(row.get("worker"), str)
1769
+ and isinstance(row.get("verdict"), str)
1770
+ }
1771
+ disagrees = sum(value.startswith("DISAGREE") for value in votes.values())
1772
+ if votes and disagrees == 0:
1773
+ classification = "full-consensus"
1774
+ elif disagrees * 2 > len(votes):
1775
+ classification = "majority-disagree"
1776
+ else:
1777
+ classification = "partial-consensus"
1778
+ return {"round": round_number, "votes": votes, "classification": classification}
1779
+
1780
+
1781
+ def _plan_gate_summary(verification: Mapping[str, Any]) -> dict[str, Any]:
1782
+ module = plan_body
1783
+ summary = module.plan_body_gate_summary(
1784
+ {"implementationPlanning": {"planBodyVerification": dict(verification)}}
1785
+ )
1786
+ if not isinstance(summary, Mapping):
1787
+ raise PlanItemContractError("plan-body gate authority returned no gate")
1788
+ return dict(summary)
1789
+
1790
+
1791
+ def _record_self_fixes(
1792
+ args: argparse.Namespace, audit: list[object], verification: dict[str, Any]
1793
+ ) -> None:
1794
+ previous = self_fix_rounds(verification)
1795
+ if (args.self_fix_group or args.self_fix_note) and previous - {args.round_number}:
1796
+ raise PlanItemContractError(
1797
+ "automatic self-fix is limited to one rewrite; resolve remaining "
1798
+ "items through a lead decision or user confirmation"
1799
+ )
1800
+ notes: dict[str, str] = {}
1801
+ known = {item.get("id") for item in audit if isinstance(item, Mapping)}
1802
+ if args.self_fix_group and not args.self_fix_stop_reason:
1803
+ raise PlanItemContractError(
1804
+ "--self-fix-group requires --self-fix-stop-reason — a self-fix "
1805
+ "round has to say why the loop stops, and a defaulted "
1806
+ "`all-resolved` is the one value that later forbids promoting the "
1807
+ "items it left unresolved"
1808
+ )
1809
+ for raw in args.self_fix_note:
1810
+ item_id, separator, filename = raw.partition("=")
1811
+ if not separator or not item_id or not filename:
1812
+ raise PlanItemContractError(
1813
+ "--self-fix-note must be <item-id>=<markdown-file>"
1814
+ )
1815
+ if item_id not in known:
1816
+ raise PlanItemContractError(f"unknown self-fix item `{item_id}`")
1817
+ try:
1818
+ notes[item_id] = Path(filename).read_text(encoding="utf-8").strip()
1819
+ except (OSError, UnicodeError) as exc:
1820
+ raise PlanItemContractError(
1821
+ f"cannot read self-fix note {filename}: {exc}"
1822
+ ) from exc
1823
+ for item in audit:
1824
+ if isinstance(item, dict) and item.get("id") in notes:
1825
+ item["selfFixNote"] = notes[item["id"]]
1826
+ if notes and not args.self_fix_group and not previous:
1827
+ raise PlanItemContractError(
1828
+ "--self-fix-note requires --self-fix-group to account for the rewrite"
1829
+ )
1830
+ groups = []
1831
+ for raw in args.self_fix_group:
1832
+ filename, separator, item_ids = raw.partition("=")
1833
+ if not separator or not filename or not item_ids:
1834
+ raise PlanItemContractError(
1835
+ "--self-fix-group must be <cause-file>=<item-id>[,<item-id>...]"
1836
+ )
1837
+ try:
1838
+ cause = Path(filename).read_text(encoding="utf-8").strip()
1839
+ except (OSError, UnicodeError) as exc:
1840
+ raise PlanItemContractError(
1841
+ f"cannot read self-fix cause {filename}: {exc}"
1842
+ ) from exc
1843
+ ids = [item for item in item_ids.split(",") if item]
1844
+ unknown = sorted(set(ids) - known)
1845
+ if unknown:
1846
+ raise PlanItemContractError(
1847
+ "unknown self-fix item(s): " + ", ".join(unknown)
1848
+ )
1849
+ groups.append(
1850
+ {"round": args.round_number, "causeSummary": cause, "itemIds": ids}
1851
+ )
1852
+ if groups:
1853
+ verification["selfFixGroups"] = groups
1854
+ verification["selfFixRoundsApplied"] = args.round_number
1855
+ if args.self_fix_stop_reason:
1856
+ # 원인 그룹 없이 중단 사유만 기록하는 라운드는 `selfFixRoundsApplied` 를
1857
+ # 건드리지 않는다. `validate-run.py` `_validate_self_fix_grouping` 이
1858
+ # `max(selfFixGroups[].round) == selfFixRoundsApplied` 를 요구하므로,
1859
+ # 그룹 없이 올린 숫자는 통과할 수 있는 값이 없는 상태를 만든다.
1860
+ verification["selfFixStopReason"] = args.self_fix_stop_reason
1861
+
1862
+
1863
+ def _round_inputs(
1864
+ args: argparse.Namespace,
1865
+ ) -> tuple[dict[str, Any], list[dict[str, Any]], list[object], list[object]]:
1866
+ data = _load_json_object(args.state)
1867
+ audit, history = data.get("planItems"), data.get("roundHistory")
1868
+ if not isinstance(audit, list) or not isinstance(history, list):
1869
+ raise PlanItemContractError("state planItems and roundHistory must be arrays")
1870
+ current = _state_plan_body_items(data, args.state)
1871
+ if args.command == "complete-round" and args.items is None:
1872
+ manifest = _load_json_object(args.run_manifest)
1873
+ if manifest.get("planBodyVerificationPath"):
1874
+ prepared = _prepared_items_path(args.run_manifest)
1875
+ if prepared.is_file():
1876
+ voted = {
1877
+ item["id"]
1878
+ for item in current
1879
+ if _round_snapshot(item, args.round_number)["votes"]
1880
+ }
1881
+ # 이후 회차용 준비 파일이 이미 저장된 현재 회차의 표를 제외하면 쓰지 않는다.
1882
+ if voted <= set(_assigned_item_ids(prepared)):
1883
+ args = copy.copy(args)
1884
+ args.items = prepared
1885
+ _narrow_dispatch_queue(
1886
+ args,
1887
+ data["planBodyVerification"],
1888
+ {item.get("id") for item in current},
1889
+ )
1890
+ return data, current, audit, history
1891
+
1892
+
1893
+ def _round_snapshots(
1894
+ current: list[dict[str, Any]], verification: Mapping[str, Any], round_number: int
1895
+ ) -> tuple[dict[str, dict[str, Any]], dict[str, Any]]:
1896
+ queue = verification.get("dispatchQueue")
1897
+ scoped = current
1898
+ if isinstance(queue, list):
1899
+ allowed = {item_id for item_id in queue if isinstance(item_id, str)}
1900
+ scoped = [
1901
+ item
1902
+ for item in current
1903
+ if isinstance(item, Mapping) and item.get("id") in allowed
1904
+ ]
1905
+ snapshots = {
1906
+ str(item.get("id")): _round_snapshot(item, round_number) for item in scoped
1907
+ }
1908
+ if not snapshots:
1909
+ raise PlanItemContractError(
1910
+ f"round {round_number} dispatch queue has no current plan items"
1911
+ )
1912
+ missing = [item_id for item_id, row in snapshots.items() if not row["votes"]]
1913
+ if missing:
1914
+ raise PlanItemContractError(
1915
+ f"round {round_number} has no verdict for dispatched plan items: {', '.join(missing)}. "
1916
+ "Earlier-round verdicts remain recorded; check this round's prepared queue "
1917
+ "before requesting new verdicts."
1918
+ )
1919
+ summary = _plan_gate_summary(verification)
1920
+ classes = {row["id"]: row["stateClassification"] for row in summary["items"]}
1921
+ for item_id, snapshot in snapshots.items():
1922
+ snapshot["classification"] = classes.get(item_id, snapshot["classification"])
1923
+ return snapshots, summary
1924
+
1925
+
1926
+ def _record_audit_round(
1927
+ audit: list[object], snapshots: Mapping[str, dict[str, Any]], round_number: int
1928
+ ) -> None:
1929
+ for item in audit:
1930
+ if isinstance(item, dict) and str(item.get("id")) in snapshots:
1931
+ rounds = item.get("rounds")
1932
+ if not isinstance(rounds, list):
1933
+ raise PlanItemContractError("state plan item rounds must be an array")
1934
+ item["rounds"] = [row for row in rounds if row.get("round") != round_number]
1935
+ item["rounds"].append(snapshots[str(item["id"])])
1936
+
1937
+
1938
+ def _participant_counts(
1939
+ manifest: Mapping[str, Any],
1940
+ snapshots: Mapping[str, dict[str, Any]],
1941
+ items: Sequence[Mapping[str, Any]],
1942
+ ) -> tuple[set[str], int, int]:
1943
+ """이번 라운드의 투표자, run 전체의 분석자 수, 로스터 크기.
1944
+
1945
+ `workers` 는 uniformVerifiers 를 뽑기 위한 이번 라운드 큐의 투표자다.
1946
+ `voting` 은 게이트 산술의 분모이므로 라운드 큐가 아니라 run 전체를 세고,
1947
+ 검증기와 같은 함수(`voting_analyser_keys`)를 쓴다 — 종전에는 이쪽만 라운드
1948
+ 큐를 세서, critic 이 동수만 가른 라운드에서 기록값 0 과 검증기 재계산값이
1949
+ 갈렸다.
1950
+ """
1951
+ workers = {worker for row in snapshots.values() for worker in row["votes"]}
1952
+ voting = voting_analyser_keys(items)
1953
+ assignments = manifest.get("workerAssignments")
1954
+ if not isinstance(assignments, list):
1955
+ raise PlanItemContractError("run manifest workerAssignments must be an array")
1956
+ rostered = sum(
1957
+ 1
1958
+ for row in assignments
1959
+ if isinstance(row, Mapping)
1960
+ and isinstance(row.get("workerId"), str)
1961
+ and row["workerId"] != "report-writer"
1962
+ )
1963
+ return workers, len(voting), rostered
1964
+
1965
+
1966
+ def _reject_round_gap(history: list[Any], round_number: int) -> None:
1967
+ """앞 라운드의 결과가 기록되기 전에 다음 라운드를 닫지 못하게 한다.
1968
+
1969
+ `roundCount` 는 이 인자의 최대값이라 건너뛴 라운드까지 세지만, 이력에는
1970
+ 행이 남지 않는다. 그 불일치는 phase 끝에서 활동 건수 검증이 잡아내는데
1971
+ (`recorded=3` vs `rounds=4`), 그때는 라운드를 다시 닫을 방법이 없어 실행
1972
+ 전체가 막힌다. 빠진 라운드의 판정도 그대로 사라진다 — 다음 라운드는 그것을
1973
+ settle 하러 존재하므로 근거 없는 표결이 된다.
1974
+
1975
+ 실측(2026-08-27, `fontsninja-v3-site` `dev-10341`): 이력이 1·2·4 이고
1976
+ `roundCount` 가 4 였다.
1977
+ """
1978
+ recorded = {
1979
+ row.get("round")
1980
+ for row in history
1981
+ if isinstance(row, Mapping) and isinstance(row.get("round"), int)
1982
+ }
1983
+ missing = [number for number in range(1, round_number) if number not in recorded]
1984
+ if missing:
1985
+ raise PlanItemContractError(
1986
+ f"round {round_number} cannot be completed while round(s) "
1987
+ f"{', '.join(str(number) for number in missing)} have no history "
1988
+ "entry — complete them in order so each round's verdict is recorded"
1989
+ )
1990
+
1991
+
1992
+ def _validate_advisory_round(
1993
+ verification: Mapping[str, Any], round_number: int
1994
+ ) -> None:
1995
+ """분석자 검증 횟수 제한은 비판 검토자의 교정에 적용하지 않는다."""
1996
+ if verification.get("gating") is not False or round_number <= 1:
1997
+ return
1998
+ if any(
1999
+ row.get("round") == round_number and not is_critic_worker(row.get("worker", ""))
2000
+ for item in verification.get("planItems", [])
2001
+ for row in item.get("verdicts", [])
2002
+ ):
2003
+ raise PlanItemContractError(
2004
+ "advisory plan-body gating allows one verification round for analysers; "
2005
+ "critic corrections do not consume that limit"
2006
+ )
2007
+
2008
+
2009
+ def _reject_unbacked_self_fix_stop(
2010
+ verification: Mapping[str, Any],
2011
+ stop_reason: str,
2012
+ ) -> None:
2013
+ """중단 사유만 적는 호출이 검증기가 받지 못할 상태를 쓰지 못하게 한다.
2014
+
2015
+ planner-fixable 과반 반대가 남아 있으면 검증기는 자동 수정 1회 이상과 소진
2016
+ 사유를 함께 요구한다. 수정 기록 없이 `max-rounds-reached` 만 적으면 쓰기는
2017
+ 통과하고 phase 끝 검증에서만 실패한다. 실측(2026-09-24, jobs dev-9065
2018
+ implementation-planning 001): `selfFixRoundsApplied=0` 에
2019
+ `max-rounds-reached`. 판정은 검증기 함수를 그대로 부른다.
2020
+ """
2021
+ failures: list[str] = []
2022
+ plan_body._validate_self_fix_before_clarification(
2023
+ {"implementationPlanning": {"planBodyVerification": dict(verification)}},
2024
+ failures,
2025
+ )
2026
+ if failures:
2027
+ raise PlanItemContractError(
2028
+ f"--self-fix-stop-reason {stop_reason} without --self-fix-group "
2029
+ "records a stop that validate-run rejects: a planner-fixable "
2030
+ "majority DISAGREE remains and no self-fix rewrite is recorded. "
2031
+ "Have report-writer rewrite those items, then pass "
2032
+ "--self-fix-group <cause-file>=<item-id>[,...] with the stop reason "
2033
+ "for the round that rewrote them. Nothing was written. "
2034
+ + " ".join(failures)
2035
+ )
2036
+
2037
+
2038
+ def _complete_round(args: argparse.Namespace) -> dict[str, Any]:
2039
+ if args.round_number < 1:
2040
+ raise PlanItemContractError("--round must be 1 or greater")
2041
+ data, current, audit, history = _round_inputs(args)
2042
+ verification = data["planBodyVerification"]
2043
+ if requires_plan_repair(verification):
2044
+ verification["gating"] = True
2045
+ _validate_advisory_round(verification, args.round_number)
2046
+ if verification.get("gating") is False:
2047
+ if args.self_fix_group or args.self_fix_note or args.self_fix_stop_reason:
2048
+ raise PlanItemContractError(
2049
+ "advisory plan-body gating forbids the self-fix loop"
2050
+ )
2051
+ _record_self_fixes(args, audit, verification)
2052
+ _reject_round_gap(history, args.round_number)
2053
+ snapshots, summary = _round_snapshots(current, verification, args.round_number)
2054
+ _record_audit_round(audit, snapshots, args.round_number)
2055
+ gate = str(summary["recomputed"])
2056
+ manifest = _load_json_object(args.run_manifest)
2057
+ workers, voting, rostered = _participant_counts(manifest, snapshots, current)
2058
+ # critic-tie 는 큐 일부에만 표를 남긴다. 합집합 워커를 모든 행에
2059
+ # 강제하면 표 없는 행에서 KeyError 가 난다. 모든 행에 같은 판정이
2060
+ # 있을 때만 uniform 이다.
2061
+ uniform = []
2062
+ for worker in workers:
2063
+ values = [
2064
+ row["votes"][worker] for row in snapshots.values() if worker in row["votes"]
2065
+ ]
2066
+ if len(values) != len(snapshots) or len(set(values)) != 1:
2067
+ continue
2068
+ uniform.append(
2069
+ {"worker": worker, "verdict": values[0], "itemCount": len(values)}
2070
+ )
2071
+ verification.update(
2072
+ {
2073
+ "roundCount": max(
2074
+ int(verification.get("roundCount", 0)), args.round_number
2075
+ ),
2076
+ "gateResult": gate,
2077
+ "gateBlockedBy": summary["blockedBy"],
2078
+ # 무엇이 막았는지는 `gateBlockedBy` 가, 무엇이 막지 않았고
2079
+ # 왜인지는 이 쪽이 적는다. 둘 다 같은 계산에서 나온다.
2080
+ "setAside": summary["setAside"],
2081
+ "participatingAnalysers": {"rostered": rostered, "voting": voting},
2082
+ "uniformVerifiers": uniform,
2083
+ }
2084
+ )
2085
+ if args.self_fix_stop_reason and not args.self_fix_group:
2086
+ _reject_unbacked_self_fix_stop(verification, args.self_fix_stop_reason)
2087
+ if args.self_fix_group:
2088
+ data["selfFixRoundsApplied"] = args.round_number
2089
+ history[:] = [row for row in history if row.get("round") != args.round_number]
2090
+ history.append(
2091
+ {
2092
+ "round": args.round_number,
2093
+ "completedAt": datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ"),
2094
+ "gateResult": gate,
2095
+ "gateBlockedBy": verification["gateBlockedBy"],
2096
+ }
2097
+ )
2098
+ write_json_atomic(args.state, data)
2099
+ return {
2100
+ "ok": True,
2101
+ "operation": "complete-round",
2102
+ "path": str(args.state),
2103
+ "gateResult": gate,
2104
+ "nextDispatch": _state_next_dispatch(args).as_dict(),
2105
+ }
2106
+
2107
+
2108
+ def _prepared_payloads(run_manifest: Path | None) -> dict[str, dict[str, Any]]:
2109
+ if run_manifest is None:
2110
+ return {}
2111
+ try:
2112
+ envelope = _load_json_object(
2113
+ _prepared_items_path(run_manifest, require_regular=True)
2114
+ )
2115
+ except PlanItemContractError:
2116
+ return {}
2117
+ items = envelope.get("items")
2118
+ if not isinstance(items, list):
2119
+ return {}
2120
+ return {
2121
+ str(item["id"]): item
2122
+ for item in items
2123
+ if isinstance(item, Mapping) and isinstance(item.get("id"), str)
2124
+ }
2125
+
2126
+
2127
+ def _rostered_critic(run_manifest: Path | None) -> bool:
2128
+ """매니페스트 없이 부르면 종전대로 critic 이 있다고 본다.
2129
+
2130
+ 없다고 단정하면 critic 을 둔 run 이 가를 수 있는 동수까지 사용자에게
2131
+ 넘긴다 — 그쪽이 더 나쁜 오답이다. 리드 계약은 `next-dispatch` 를
2132
+ `--run-manifest` 와 함께 부르도록 못박고 있다
2133
+ (`prompts/lead/plan-body-verification.md` §"Round protocol" step 6).
2134
+ """
2135
+ if run_manifest is None:
2136
+ return True
2137
+ return critic_is_rostered(_load_json_object(run_manifest))
2138
+
2139
+
2140
+ def _state_next_dispatch(args: argparse.Namespace) -> NextDispatch:
2141
+ data, current, _audit, _history = _round_inputs(args)
2142
+ run_manifest = getattr(args, "run_manifest", None)
2143
+ payloads = _prepared_payloads(run_manifest)
2144
+ # 실제 목록에서 제외할 항목을 다음 비평 검증으로 요구하면 빈 큐에서 멈춘다.
2145
+ allowed = set(
2146
+ dispatch_item_ids(
2147
+ [payloads.get(item["id"], item) for item in current],
2148
+ data["planBodyVerification"].get("stageLedger"),
2149
+ )
2150
+ )
2151
+ current = [item for item in current if item["id"] in allowed]
2152
+ return next_dispatch(
2153
+ current,
2154
+ payloads,
2155
+ critic_rostered=_rostered_critic(run_manifest),
2156
+ decision_items=(
2157
+ _plan_gate_summary(data["planBodyVerification"])["items"]
2158
+ if self_fix_rounds(data["planBodyVerification"])
2159
+ else ()
2160
+ ),
2161
+ )
2162
+
2163
+
2164
+ def _resolve_dissent(args: argparse.Namespace) -> dict[str, Any]:
2165
+ data, current, _audit, history = _round_inputs(args)
2166
+ verification = data["planBodyVerification"]
2167
+ item = next((row for row in current if row.get("id") == args.item), None)
2168
+ if item is None:
2169
+ raise PlanItemContractError(f"unknown plan item `{args.item}`")
2170
+ closed = {row.get("round") for row in history if isinstance(row, Mapping)}
2171
+ if any(row.get("round") not in closed for row in item.get("verdicts", [])):
2172
+ raise PlanItemContractError(
2173
+ "complete-round must record every verdict before a lead decision"
2174
+ )
2175
+ summary = _plan_gate_summary(verification)
2176
+ authority = next(row for row in summary["items"] if row["id"] == args.item)
2177
+ if authority["decisionAuthority"] != "lead":
2178
+ raise PlanItemContractError(
2179
+ f"`{args.item}` is not a lead-owned judgement after self-fix; "
2180
+ "use user confirmation for unresolved facts, requirements, risks or preferences"
2181
+ )
2182
+ try:
2183
+ decision = args.decision_file.read_text(encoding="utf-8").strip()
2184
+ except (OSError, UnicodeError) as exc:
2185
+ raise PlanItemContractError(f"cannot read lead decision: {exc}") from exc
2186
+ if not decision:
2187
+ raise PlanItemContractError(
2188
+ "lead decision must state the decision, authority and cited evidence"
2189
+ )
2190
+ item["leadDecision"] = {
2191
+ "basisHash": lead_decision_basis(item),
2192
+ "decision": decision,
2193
+ }
2194
+ entry = {"planItem": args.item, "workerRole": "lead", "body": decision}
2195
+ dissent = verification.setdefault("dissentLog", [])
2196
+ if entry not in dissent:
2197
+ dissent.append(entry)
2198
+ summary = _plan_gate_summary(verification)
2199
+ verification.update(
2200
+ {
2201
+ "gateResult": summary["recomputed"],
2202
+ "gateBlockedBy": summary["blockedBy"],
2203
+ "setAside": summary["setAside"],
2204
+ }
2205
+ )
2206
+ write_json_atomic(args.state, data)
2207
+ return {
2208
+ "ok": True,
2209
+ "operation": "resolve-dissent",
2210
+ "itemId": args.item,
2211
+ "gateResult": summary["recomputed"],
2212
+ }
2213
+
2214
+
2215
+ def _next_dispatch(args: argparse.Namespace) -> dict[str, Any]:
2216
+ decision = _state_next_dispatch(args)
2217
+ return {"ok": True, "operation": "next-dispatch", **decision.as_dict()}
2218
+
2219
+
2220
+ def _correction_prompt(args: argparse.Namespace) -> str:
2221
+ decision = _state_next_dispatch(args)
2222
+ if decision.kind != "worker-correction" or args.worker not in decision.workers:
2223
+ raise PlanItemContractError(
2224
+ f"worker `{args.worker}` is not a blanket-UNVERIFIABLE correction "
2225
+ "target — do not open a queue round"
2226
+ )
2227
+ return correction_prompt_text(_prompt(args))
2228
+
2229
+
2230
+ def _narrow_dispatch_queue(
2231
+ args: argparse.Namespace,
2232
+ verification: dict[str, Any],
2233
+ known: set[object],
2234
+ ) -> set[object]:
2235
+ """이 라운드가 실제로 배정한 항목들.
2236
+
2237
+ tie 라운드는 큐의 일부(7항목)만 critic 에게 보낸다. 그런데 `--result` 는
2238
+ 지금까지 state 에 남은 라운드 큐(44항목) 전체를 배정으로 보고 답 없는
2239
+ 37항목을 미응답으로 거절했다 — 문서가 "model-facing" 이라고 적은 형식이
2240
+ tie 라운드에서는 쓸 수 없고, 우회로가 헬프 스스로 historical 이라 적은
2241
+ `--verdicts` 뿐이었다(실측 2026-09-10, fontsninja-v3-site dev-10628-3).
2242
+ 배정 범위는 판정 저장과 완료 기록이 함께 써야 한다. 지역 변수만 좁히면
2243
+ 저장은 성공해도 완료가 과거 큐 전체에서 새 라운드의 표를 요구한다.
2244
+ 상태의 큐만 좁히고 기존 표와 라운드 이력은 보존한다.
2245
+ """
2246
+ queue = verification.get("dispatchQueue")
2247
+ assigned = (
2248
+ {item_id for item_id in queue if isinstance(item_id, str)}
2249
+ if isinstance(queue, list)
2250
+ else known
2251
+ )
2252
+ items_path = getattr(args, "items", None)
2253
+ if items_path is None:
2254
+ return assigned
2255
+ narrowed = {item_id for item_id in _assigned_item_ids(items_path)}
2256
+ if not narrowed:
2257
+ raise PlanItemContractError(f"items artifact dispatches nothing: {items_path}")
2258
+ unknown = sorted(narrowed - {i for i in assigned if isinstance(i, str)})
2259
+ if unknown:
2260
+ raise PlanItemContractError(
2261
+ f"items artifact dispatches {unknown}, which this round's persisted "
2262
+ f"queue does not contain — pass the artifact this round dispatched, "
2263
+ f"not another round's"
2264
+ )
2265
+ verification["dispatchQueue"] = [
2266
+ item_id
2267
+ for item_id in (queue if isinstance(queue, list) else sorted(assigned))
2268
+ if item_id in narrowed
2269
+ ]
2270
+ return narrowed
2271
+
2272
+
2273
+ def _incoming_verdict_rows(
2274
+ args: argparse.Namespace,
2275
+ known: set[object],
2276
+ ) -> dict[str, list[dict[str, Any]]]:
2277
+ if args.verdicts is not None:
2278
+ incoming = _load_json_object(args.verdicts).get("planItems")
2279
+ if not isinstance(incoming, list):
2280
+ raise PlanItemContractError("verdicts envelope has no `planItems` array")
2281
+ return {
2282
+ item["id"]: item.get("verdicts", [])
2283
+ for item in incoming
2284
+ if isinstance(item, Mapping) and isinstance(item.get("id"), str)
2285
+ }
2286
+ assigned = {item_id for item_id in known if isinstance(item_id, str)}
2287
+ collected = _worker_blocks(args.result, assigned)
2288
+ return {
2289
+ item_id: [_verdict_row(worker, blocks[item_id]) for worker, blocks in collected]
2290
+ for item_id in assigned
2291
+ }
2292
+
2293
+
2294
+ _HANDLERS = {
2295
+ "extract": _extract,
2296
+ "validate": _validate,
2297
+ "prepare": _prepare,
2298
+ "prompt": _prompt,
2299
+ "validate-prepared": _validate_prepared,
2300
+ "collect-verdicts": _collect_verdicts,
2301
+ "derivations": _derivations,
2302
+ "seed": _seed,
2303
+ "apply-verdicts": _apply_verdicts,
2304
+ "complete-round": _complete_round,
2305
+ "next-dispatch": _next_dispatch,
2306
+ "resolve-dissent": _resolve_dissent,
2307
+ "correction-prompt": _correction_prompt,
2308
+ }
2309
+
2310
+
2311
+ def main(argv: list[str] | None = None) -> int:
2312
+ args = _parser().parse_args(argv)
2313
+ try:
2314
+ result = _HANDLERS[args.command](args)
2315
+ except (PlanItemContractError, VerdictBlockError, OSError, ValueError) as exc:
2316
+ print(f"plan-items: {exc}", file=sys.stderr)
2317
+ manifest = getattr(args, "run_manifest", None)
2318
+ if manifest:
2319
+ logged = record_runtime_failure(
2320
+ Path(manifest),
2321
+ command=f"plan-items {args.command}",
2322
+ exit_code=2,
2323
+ detail=str(exc),
2324
+ )
2325
+ if not logged["ok"]:
2326
+ print(f"error-log: {logged['reason']}", file=sys.stderr)
2327
+ return 2
2328
+ if isinstance(result, str):
2329
+ print(result, end="")
2330
+ elif args.command in {"prepare", "validate-prepared"}:
2331
+ rows = (
2332
+ "Plan items\n"
2333
+ + line("Status", "ready")
2334
+ + line("Operation", result["operation"])
2335
+ )
2336
+ if "gating" in result:
2337
+ rows += line("Gating", result["gating"])
2338
+ print(rows, end="")
2339
+ else:
2340
+ print(json.dumps(result, ensure_ascii=False, indent=2))
2341
+ return 0
2342
+
2343
+
2344
+ if __name__ == "__main__":
2345
+ raise SystemExit(main())