okstra 0.186.7 → 0.187.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 (541) hide show
  1. package/README.md +1 -1
  2. package/dist/cli-registry.d.mts +88 -1
  3. package/dist/cli-registry.mjs +68 -111
  4. package/dist/cli-registry.mjs.map +1 -1
  5. package/dist/commands/execute/render-bundle.mjs +0 -1
  6. package/dist/commands/execute/render-bundle.mjs.map +1 -1
  7. package/dist/commands/execute/run.mjs +8 -3
  8. package/dist/commands/execute/run.mjs.map +1 -1
  9. package/dist/commands/lifecycle/check-project.mjs +1 -14
  10. package/dist/commands/lifecycle/check-project.mjs.map +1 -1
  11. package/dist/commands/lifecycle/config.mjs +38 -40
  12. package/dist/commands/lifecycle/config.mjs.map +1 -1
  13. package/dist/commands/lifecycle/doctor.d.mts +22 -7
  14. package/dist/commands/lifecycle/doctor.mjs +77 -49
  15. package/dist/commands/lifecycle/doctor.mjs.map +1 -1
  16. package/dist/commands/lifecycle/install.d.mts +12 -10
  17. package/dist/commands/lifecycle/install.mjs +104 -39
  18. package/dist/commands/lifecycle/install.mjs.map +1 -1
  19. package/dist/commands/lifecycle/paths.mjs +8 -3
  20. package/dist/commands/lifecycle/paths.mjs.map +1 -1
  21. package/dist/commands/lifecycle/preflight.mjs +2 -1
  22. package/dist/commands/lifecycle/preflight.mjs.map +1 -1
  23. package/dist/commands/lifecycle/setup.mjs +22 -36
  24. package/dist/commands/lifecycle/setup.mjs.map +1 -1
  25. package/dist/commands/lifecycle/uninstall.d.mts +4 -2
  26. package/dist/commands/lifecycle/uninstall.mjs +59 -15
  27. package/dist/commands/lifecycle/uninstall.mjs.map +1 -1
  28. package/dist/commands/memory/memory.mjs +7 -1
  29. package/dist/commands/memory/memory.mjs.map +1 -1
  30. package/dist/lib/helper-scripts.d.mts +1 -1
  31. package/dist/lib/helper-scripts.mjs +10 -18
  32. package/dist/lib/helper-scripts.mjs.map +1 -1
  33. package/dist/lib/host-config.d.mts +72 -0
  34. package/dist/lib/host-config.mjs +404 -0
  35. package/dist/lib/host-config.mjs.map +1 -0
  36. package/dist/lib/host-registry-client.d.mts +2 -2
  37. package/dist/lib/host-registry-client.mjs +0 -3
  38. package/dist/lib/host-registry-client.mjs.map +1 -1
  39. package/dist/lib/install-assets.d.mts +1 -0
  40. package/dist/lib/install-assets.mjs +4 -0
  41. package/dist/lib/install-assets.mjs.map +1 -1
  42. package/dist/lib/proc.d.mts +2 -0
  43. package/dist/lib/proc.mjs +12 -0
  44. package/dist/lib/proc.mjs.map +1 -1
  45. package/dist/lib/python-command.d.mts +2 -0
  46. package/dist/lib/python-command.mjs +52 -0
  47. package/dist/lib/python-command.mjs.map +1 -0
  48. package/dist/lib/python-helper.d.mts +2 -5
  49. package/dist/lib/python-helper.mjs +3 -52
  50. package/dist/lib/python-helper.mjs.map +1 -1
  51. package/dist/lib/runtime-payload.d.mts +23 -0
  52. package/dist/lib/runtime-payload.mjs +57 -0
  53. package/dist/lib/runtime-payload.mjs.map +1 -0
  54. package/dist/lib/types.d.mts +24 -13
  55. package/docs/architecture/storage-model.md +21 -5
  56. package/docs/architecture.md +37 -36
  57. package/docs/cli.md +55 -76
  58. package/docs/coding-rules.md +295 -0
  59. package/docs/container.md +10 -36
  60. package/docs/contributor-change-matrix.md +1 -1
  61. package/docs/for-ai/skills/okstra-code-review.md +0 -1
  62. package/docs/for-ai/skills/okstra-container-build.md +8 -41
  63. package/docs/for-ai/skills/okstra-inspect.md +4 -4
  64. package/docs/for-ai/skills/okstra-manager.md +2 -2
  65. package/docs/for-ai/skills/okstra-rollup.md +0 -1
  66. package/docs/for-ai/skills/okstra-run.md +3 -3
  67. package/docs/for-ai/skills/okstra-user-response.md +0 -1
  68. package/docs/performance-improvement-plan-v2.md +1 -1
  69. package/docs/project-structure-overview.md +62 -62
  70. package/docs/task-process/README.md +3 -3
  71. package/docs/task-process/common-flow.md +1 -1
  72. package/docs/task-process/error-analysis.md +3 -3
  73. package/docs/task-process/implementation-planning.md +3 -1
  74. package/docs/task-process/release-handoff.md +1 -1
  75. package/package.json +3 -2
  76. package/runtime/BUILD.json +2 -2
  77. package/runtime/agents/workers/claude-worker.md +6 -11
  78. package/runtime/agents/workers/report-writer-worker.md +1 -1
  79. package/runtime/bin/lib/okstra/cli.sh +4 -0
  80. package/runtime/bin/lib/okstra/globals.sh +2 -0
  81. package/runtime/bin/lib/okstra/interactive.sh +26 -173
  82. package/runtime/bin/lib/okstra/project-resolver.sh +23 -61
  83. package/runtime/bin/lib/okstra/usage.sh +3 -3
  84. package/runtime/bin/okstra-compact-reminder.sh +1 -5
  85. package/runtime/bin/okstra-error-log.py +19 -2
  86. package/runtime/bin/okstra-import-check.py +26 -0
  87. package/runtime/bin/okstra-inject-report-index.py +5 -4
  88. package/runtime/bin/okstra-provider-exec.py +22 -17
  89. package/runtime/bin/okstra-render-final-report.py +16 -6
  90. package/runtime/bin/okstra-render-report-views.py +25 -18
  91. package/runtime/bin/okstra-report-translate.py +30 -18
  92. package/runtime/bin/okstra-spawn-followups.py +4 -0
  93. package/runtime/bin/okstra-token-usage.py +3 -0
  94. package/runtime/bin/okstra.sh +4 -2
  95. package/runtime/bin/okstra_bootstrap.py +58 -0
  96. package/runtime/prompts/coding-preflight/overview.md +1 -1
  97. package/runtime/prompts/duties/planning-worker.md +1 -1
  98. package/runtime/prompts/launch.template.md +35 -9
  99. package/runtime/prompts/lead/convergence.md +96 -87
  100. package/runtime/prompts/lead/okstra-lead-contract.md +63 -12
  101. package/runtime/prompts/lead/plan-body-verification.md +65 -46
  102. package/runtime/prompts/lead/report-writer.md +34 -4
  103. package/runtime/prompts/lead/team-contract.md +4 -1
  104. package/runtime/prompts/profiles/_clarification-recommendation.md +2 -1
  105. package/runtime/prompts/profiles/_coding-conventions-preflight.md +1 -1
  106. package/runtime/prompts/profiles/_common-contract.md +3 -3
  107. package/runtime/prompts/profiles/_coverage-critic.md +1 -1
  108. package/runtime/prompts/profiles/_implementation-deliverable.md +2 -2
  109. package/runtime/prompts/profiles/_implementation-executor.md +3 -1
  110. package/runtime/prompts/profiles/_implementation-verifier.md +16 -2
  111. package/runtime/prompts/profiles/error-analysis.md +4 -3
  112. package/runtime/prompts/profiles/final-verification.md +2 -2
  113. package/runtime/prompts/profiles/implementation-planning.md +22 -17
  114. package/runtime/prompts/profiles/implementation.md +3 -2
  115. package/runtime/prompts/profiles/requirements-discovery.md +2 -1
  116. package/runtime/prompts/wizard/prompts.ko.json +29 -8
  117. package/runtime/python/okstra_ctl/__init__.py +6 -27
  118. package/runtime/python/okstra_ctl/adapters/hosts/claude-code/relay.md +4 -5
  119. package/runtime/python/okstra_ctl/adapters/hosts/codex/adapter.py +21 -2
  120. package/runtime/python/okstra_ctl/adapters/hosts/external/adapter.py +3 -12
  121. package/runtime/python/okstra_ctl/adapters/providers/antigravity/adapter.py +40 -22
  122. package/runtime/python/okstra_ctl/adapters/providers/claude/adapter.py +14 -21
  123. package/runtime/python/okstra_ctl/adapters/providers/codex/adapter.py +37 -20
  124. package/runtime/python/okstra_ctl/adapters/providers/grok/adapter.py +86 -14
  125. package/runtime/python/okstra_ctl/adapters/providers/kimi/adapter.py +12 -12
  126. package/runtime/python/okstra_ctl/adapters/runtime/cmux.py +4 -7
  127. package/runtime/python/okstra_ctl/agent/__init__.py +1 -0
  128. package/runtime/python/okstra_ctl/{agent_activity.py → agent/activity.py} +12 -1
  129. package/runtime/python/okstra_ctl/{agent_invocation.py → agent/invocation.py} +34 -5
  130. package/runtime/python/okstra_ctl/agent/prompt_cli/__init__.py +19 -0
  131. package/runtime/python/okstra_ctl/agent/prompt_cli/__main__.py +11 -0
  132. package/runtime/python/okstra_ctl/agent/prompt_cli/cli.py +215 -0
  133. package/runtime/python/okstra_ctl/agent/prompt_cli/dynamic_verifier.py +155 -0
  134. package/runtime/python/okstra_ctl/agent/prompt_cli/emit.py +66 -0
  135. package/runtime/python/okstra_ctl/agent/prompt_cli/inputs.py +138 -0
  136. package/runtime/python/okstra_ctl/agent/prompt_cli/materialize.py +408 -0
  137. package/runtime/python/okstra_ctl/agent/prompt_cli/results.py +197 -0
  138. package/runtime/python/okstra_ctl/agent/prompt_cli/run_identity.py +107 -0
  139. package/runtime/python/okstra_ctl/analysis_packet.py +47 -4
  140. package/runtime/python/okstra_ctl/approval_decisions.py +260 -8
  141. package/runtime/python/okstra_ctl/assignment_resolver.py +0 -12
  142. package/runtime/python/okstra_ctl/attempt_evidence.py +12 -24
  143. package/runtime/python/okstra_ctl/blocking_checks.py +251 -0
  144. package/runtime/python/okstra_ctl/brief_frontmatter.py +0 -7
  145. package/runtime/python/okstra_ctl/clarification_items/__init__.py +102 -0
  146. package/runtime/python/okstra_ctl/clarification_items/carry.py +224 -0
  147. package/runtime/python/okstra_ctl/clarification_items/dispositions.py +135 -0
  148. package/runtime/python/okstra_ctl/clarification_items/parsing.py +253 -0
  149. package/runtime/python/okstra_ctl/clarification_items/rows.py +115 -0
  150. package/runtime/python/okstra_ctl/clarification_items/scan.py +215 -0
  151. package/runtime/python/okstra_ctl/clarification_items/sidecars.py +211 -0
  152. package/runtime/python/okstra_ctl/cmux.py +38 -31
  153. package/runtime/python/okstra_ctl/code_review_target.py +163 -1
  154. package/runtime/python/okstra_ctl/conformance.py +23 -0
  155. package/runtime/python/okstra_ctl/container.py +38 -421
  156. package/runtime/python/okstra_ctl/context_cost.py +16 -2
  157. package/runtime/python/okstra_ctl/contract_graph_cli.py +7 -0
  158. package/runtime/python/okstra_ctl/convergence.py +101 -15
  159. package/runtime/python/okstra_ctl/convergence_critic_prompt.py +345 -0
  160. package/runtime/python/okstra_ctl/convergence_engine.py +382 -70
  161. package/runtime/python/okstra_ctl/convergence_store.py +5 -29
  162. package/runtime/python/okstra_ctl/design_prep.py +108 -12
  163. package/runtime/python/okstra_ctl/design_snapshot.py +11 -1
  164. package/runtime/python/okstra_ctl/design_surfaces.py +29 -2
  165. package/runtime/python/okstra_ctl/dispatch_core.py +102 -25
  166. package/runtime/python/okstra_ctl/dispatch_state.py +268 -39
  167. package/runtime/python/okstra_ctl/doctor_cli.py +48 -0
  168. package/runtime/python/okstra_ctl/domain/worker_exec.py +10 -5
  169. package/runtime/python/okstra_ctl/domain/worker_presentation.py +21 -1
  170. package/runtime/python/okstra_ctl/domain/worker_stream.py +52 -21
  171. package/runtime/python/okstra_ctl/domain/write_policy.py +219 -0
  172. package/runtime/python/okstra_ctl/entrypoints/hosts.py +9 -2
  173. package/runtime/python/okstra_ctl/error_log_core.py +1 -1
  174. package/runtime/python/okstra_ctl/error_report.py +14 -0
  175. package/runtime/python/okstra_ctl/error_zip.py +18 -2
  176. package/runtime/python/okstra_ctl/execution_identity.py +100 -7
  177. package/runtime/python/okstra_ctl/execution_manifest.py +67 -20
  178. package/runtime/python/okstra_ctl/execution_mutation_audit.py +90 -8
  179. package/runtime/python/okstra_ctl/final_report_paths.py +17 -0
  180. package/runtime/python/okstra_ctl/final_report_schema.py +90 -16
  181. package/runtime/python/okstra_ctl/git_reconcile.py +22 -2
  182. package/runtime/python/okstra_ctl/handoff.py +24 -1
  183. package/runtime/python/okstra_ctl/ids.py +6 -16
  184. package/runtime/python/okstra_ctl/implementation_direction.py +142 -33
  185. package/runtime/python/okstra_ctl/implementation_options.py +25 -11
  186. package/runtime/python/okstra_ctl/implementation_outcome.py +5 -2
  187. package/runtime/python/okstra_ctl/improvement_lenses.py +0 -14
  188. package/runtime/python/okstra_ctl/incremental_carry.py +69 -8
  189. package/runtime/python/okstra_ctl/incremental_scope.py +173 -12
  190. package/runtime/python/okstra_ctl/index.py +2 -2
  191. package/runtime/python/okstra_ctl/initial_prompt_materialization.py +48 -8
  192. package/runtime/python/okstra_ctl/interactive_cli.py +223 -0
  193. package/runtime/python/okstra_ctl/json_boundary.py +10 -0
  194. package/runtime/python/okstra_ctl/listing.py +0 -91
  195. package/runtime/python/okstra_ctl/locks.py +5 -19
  196. package/runtime/python/okstra_ctl/log_report.py +14 -0
  197. package/runtime/python/okstra_ctl/manager_cli.py +22 -3
  198. package/runtime/python/okstra_ctl/manager_launch.py +4 -8
  199. package/runtime/python/okstra_ctl/material.py +2 -2
  200. package/runtime/python/okstra_ctl/migrate.py +23 -1
  201. package/runtime/python/okstra_ctl/model_cli.py +19 -5
  202. package/runtime/python/okstra_ctl/model_discovery.py +46 -47
  203. package/runtime/python/okstra_ctl/model_io/__init__.py +1 -0
  204. package/runtime/python/okstra_ctl/model_io/lines.py +163 -0
  205. package/runtime/python/okstra_ctl/model_io/references.py +370 -0
  206. package/runtime/python/okstra_ctl/model_io/renderers.py +498 -0
  207. package/runtime/python/okstra_ctl/model_io_cli.py +30 -945
  208. package/runtime/python/okstra_ctl/model_pool.py +10 -6
  209. package/runtime/python/okstra_ctl/models.py +8 -8
  210. package/runtime/python/okstra_ctl/mutation_recovery.py +5 -63
  211. package/runtime/python/okstra_ctl/next_phase.py +121 -69
  212. package/runtime/python/okstra_ctl/pane_reclaim.py +23 -0
  213. package/runtime/python/okstra_ctl/pane_title.py +11 -8
  214. package/runtime/python/okstra_ctl/path_hints.py +36 -11
  215. package/runtime/python/okstra_ctl/paths.py +76 -8
  216. package/runtime/python/okstra_ctl/plan_items.py +132 -16
  217. package/runtime/python/okstra_ctl/plan_items_cli.py +486 -36
  218. package/runtime/python/okstra_ctl/plan_run_root.py +1 -1
  219. package/runtime/python/okstra_ctl/plan_validate_cli.py +51 -0
  220. package/runtime/python/okstra_ctl/plan_verify_cli.py +80 -0
  221. package/runtime/python/okstra_ctl/prepare_error.py +20 -0
  222. package/runtime/python/okstra_ctl/prior_planning.py +157 -0
  223. package/runtime/python/okstra_ctl/profile_show.py +18 -1
  224. package/runtime/python/okstra_ctl/project_setup_cli.py +140 -0
  225. package/runtime/python/okstra_ctl/recap.py +22 -1
  226. package/runtime/python/okstra_ctl/reconcile.py +84 -0
  227. package/runtime/python/okstra_ctl/registry/host_registry.py +18 -1
  228. package/runtime/python/okstra_ctl/render.py +98 -32
  229. package/runtime/python/okstra_ctl/render_final_report.py +3 -7
  230. package/runtime/python/okstra_ctl/report_assembly.py +297 -7
  231. package/runtime/python/okstra_ctl/report_contract.py +30 -7
  232. package/runtime/python/okstra_ctl/report_finalize.py +206 -15
  233. package/runtime/python/okstra_ctl/report_html/common.py +2 -2
  234. package/runtime/python/okstra_ctl/report_html/render.py +8 -12
  235. package/runtime/python/okstra_ctl/report_language.py +8 -4
  236. package/runtime/python/okstra_ctl/report_narrative.py +90 -3
  237. package/runtime/python/okstra_ctl/report_projections.py +8 -2
  238. package/runtime/python/okstra_ctl/report_synthesis_packet.py +258 -1
  239. package/runtime/python/okstra_ctl/report_translation.py +2 -35
  240. package/runtime/python/okstra_ctl/report_views.py +4 -22
  241. package/runtime/python/okstra_ctl/resolve_task_key.py +13 -0
  242. package/runtime/python/okstra_ctl/rollup.py +13 -0
  243. package/runtime/python/okstra_ctl/run.py +639 -138
  244. package/runtime/python/okstra_ctl/run_audit.py +13 -0
  245. package/runtime/python/okstra_ctl/run_index_row.py +0 -12
  246. package/runtime/python/okstra_ctl/seeding.py +21 -11
  247. package/runtime/python/okstra_ctl/sequence.py +1 -1
  248. package/runtime/python/okstra_ctl/session.py +64 -14
  249. package/runtime/python/okstra_ctl/set_work_status.py +18 -0
  250. package/runtime/python/okstra_ctl/stage_integrate.py +15 -1
  251. package/runtime/python/okstra_ctl/stage_ledger.py +1 -1
  252. package/runtime/python/okstra_ctl/stage_map.py +57 -11
  253. package/runtime/python/okstra_ctl/stage_map_cli.py +83 -0
  254. package/runtime/python/okstra_ctl/stage_map_view.py +65 -0
  255. package/runtime/python/okstra_ctl/stage_targets.py +3 -12
  256. package/runtime/python/okstra_ctl/task_list_cli.py +138 -0
  257. package/runtime/python/okstra_ctl/task_show_cli.py +66 -0
  258. package/runtime/python/okstra_ctl/task_target.py +4 -16
  259. package/runtime/python/okstra_ctl/team.py +49 -2
  260. package/runtime/python/okstra_ctl/time_report.py +17 -2
  261. package/runtime/python/okstra_ctl/usage_report.py +11 -0
  262. package/runtime/python/okstra_ctl/user_response.py +55 -218
  263. package/runtime/python/okstra_ctl/user_response_values.py +242 -0
  264. package/runtime/python/okstra_ctl/validation_contract.py +3 -0
  265. package/runtime/python/okstra_ctl/verification_target.py +68 -0
  266. package/runtime/python/okstra_ctl/wizard.py +457 -92
  267. package/runtime/python/okstra_ctl/worker_artifacts.py +75 -16
  268. package/runtime/python/okstra_ctl/worker_audit_check.py +22 -0
  269. package/runtime/python/okstra_ctl/worker_dispatch.py +21 -0
  270. package/runtime/python/okstra_ctl/worker_liveness.py +33 -0
  271. package/runtime/python/okstra_ctl/worker_prompt_body.py +15 -2
  272. package/runtime/python/okstra_ctl/worker_prompt_contract.py +98 -7
  273. package/runtime/python/okstra_ctl/worker_prompt_headers.py +24 -2
  274. package/runtime/python/okstra_ctl/worker_prompt_policy.py +18 -3
  275. package/runtime/python/okstra_ctl/worker_request.py +2 -1
  276. package/runtime/python/okstra_ctl/worker_runner.py +16 -9
  277. package/runtime/python/okstra_ctl/worker_state.py +16 -1
  278. package/runtime/python/okstra_ctl/workflow.py +18 -1
  279. package/runtime/python/okstra_ctl/worktree/__init__.py +92 -0
  280. package/runtime/python/okstra_ctl/worktree/cleanliness.py +89 -0
  281. package/runtime/python/okstra_ctl/worktree/decisions.py +127 -0
  282. package/runtime/python/okstra_ctl/worktree/git_ops.py +165 -0
  283. package/runtime/python/okstra_ctl/worktree/linking.py +249 -0
  284. package/runtime/python/okstra_ctl/worktree/naming.py +91 -0
  285. package/runtime/python/okstra_ctl/worktree/provision.py +385 -0
  286. package/runtime/python/okstra_ctl/worktree/sync_config.py +181 -0
  287. package/runtime/python/okstra_ctl/worktree_cli.py +75 -0
  288. package/runtime/python/okstra_ctl/worktree_lookup_cli.py +39 -0
  289. package/runtime/python/okstra_ctl/worktree_registry.py +7 -0
  290. package/runtime/python/okstra_ctl/worktree_status_cli.py +53 -0
  291. package/runtime/python/okstra_ctl/write_policy.py +90 -213
  292. package/runtime/python/okstra_project/__init__.py +0 -4
  293. package/runtime/python/okstra_project/dirs.py +2 -2
  294. package/runtime/python/okstra_project/phase_pointer.py +84 -0
  295. package/runtime/python/okstra_project/slug.py +24 -0
  296. package/runtime/python/okstra_project/state.py +22 -230
  297. package/runtime/python/okstra_token_usage/claude.py +9 -1
  298. package/runtime/python/okstra_token_usage/cli.py +3 -1
  299. package/runtime/python/okstra_token_usage/collect.py +71 -10
  300. package/runtime/python/okstra_token_usage/cursor.py +2 -0
  301. package/runtime/python/okstra_token_usage/grok.py +1 -5
  302. package/runtime/python/okstra_token_usage/paths.py +26 -0
  303. package/runtime/python/okstra_token_usage/pricing.py +19 -7
  304. package/runtime/schemas/convergence-critic-results-v1.0.schema.json +5 -0
  305. package/runtime/schemas/execution-manifest-v2.schema.json +10 -10
  306. package/runtime/schemas/final-report-v2.0.schema.json +9 -26
  307. package/runtime/schemas/final-report-v3.0.schema.json +173 -41
  308. package/runtime/schemas/report-narrative-v3.0.schema.json +3 -2
  309. package/runtime/skills/okstra-brief-gen/SKILL.md +35 -2
  310. package/runtime/skills/okstra-container-build/SKILL.md +16 -47
  311. package/runtime/skills/okstra-inspect/facets/history.md +1 -1
  312. package/runtime/skills/okstra-inspect/facets/status.md +7 -6
  313. package/runtime/skills/okstra-pr-gen/SKILL.md +1 -1
  314. package/runtime/skills/okstra-run/SKILL.md +7 -5
  315. package/runtime/templates/report-writer-prompt-preamble.md +1 -1
  316. package/runtime/templates/reports/brief.template.md +6 -2
  317. package/runtime/templates/reports/error-analysis-input.template.md +2 -0
  318. package/runtime/templates/reports/final-verification-input.template.md +2 -0
  319. package/runtime/templates/reports/implementation-input.template.md +7 -1
  320. package/runtime/templates/reports/implementation-planning-input.template.md +4 -0
  321. package/runtime/templates/reports/quick-input.template.md +2 -0
  322. package/runtime/templates/reports/release-handoff-input.template.md +6 -1
  323. package/runtime/templates/reports/report.js +20 -3
  324. package/runtime/templates/reports/schedule.template.md +2 -0
  325. package/runtime/templates/reports/settings.template.json +0 -10
  326. package/runtime/templates/reports/task-brief.template.md +3 -0
  327. package/runtime/templates/reports/user-response.template.md +7 -3
  328. package/runtime/validators/checks/fixtures-01.py +57 -0
  329. package/runtime/validators/checks/fixtures-02.py +565 -0
  330. package/runtime/validators/checks/runners-01.py +108 -0
  331. package/runtime/validators/checks/validate-assets-01.py +60 -0
  332. package/runtime/validators/checks/validate-prompt-metadata-01.py +261 -0
  333. package/runtime/validators/checks/validate-tasks-01.py +46 -0
  334. package/runtime/validators/checks/validate-tasks-02.py +85 -0
  335. package/runtime/validators/checks/validate-tasks-03.py +61 -0
  336. package/runtime/validators/checks/validate-tasks-04.py +118 -0
  337. package/runtime/validators/forbidden_actions.py +73 -3
  338. package/runtime/validators/lib/common.sh +5 -0
  339. package/runtime/validators/lib/fixtures.sh +7 -591
  340. package/runtime/validators/lib/paths.sh +13 -0
  341. package/runtime/validators/lib/runners.sh +6 -104
  342. package/runtime/validators/lib/summary.sh +1 -1
  343. package/runtime/validators/lib/validate-assets.sh +6 -56
  344. package/runtime/validators/lib/validate-prompt-metadata.sh +6 -257
  345. package/runtime/validators/lib/validate-tasks.sh +9 -294
  346. package/runtime/validators/validate-implementation-plan-stages.py +4 -4
  347. package/runtime/validators/validate-run.py +1369 -2654
  348. package/runtime/validators/validate-workflow.sh +56 -16
  349. package/runtime/validators/validate_improvement_report.py +2 -1
  350. package/runtime/validators/validate_session_conformance.py +295 -49
  351. package/dist/commands/execute/agent-prompt.d.mts +0 -1
  352. package/dist/commands/execute/agent-prompt.mjs +0 -24
  353. package/dist/commands/execute/agent-prompt.mjs.map +0 -1
  354. package/dist/commands/execute/codex-dispatch.d.mts +0 -3
  355. package/dist/commands/execute/codex-dispatch.mjs +0 -6
  356. package/dist/commands/execute/codex-dispatch.mjs.map +0 -1
  357. package/dist/commands/execute/codex-run.d.mts +0 -3
  358. package/dist/commands/execute/codex-run.mjs +0 -62
  359. package/dist/commands/execute/codex-run.mjs.map +0 -1
  360. package/dist/commands/execute/convergence.d.mts +0 -1
  361. package/dist/commands/execute/convergence.mjs +0 -38
  362. package/dist/commands/execute/convergence.mjs.map +0 -1
  363. package/dist/commands/execute/error-log.d.mts +0 -1
  364. package/dist/commands/execute/error-log.mjs +0 -18
  365. package/dist/commands/execute/error-log.mjs.map +0 -1
  366. package/dist/commands/execute/git-reconcile.d.mts +0 -1
  367. package/dist/commands/execute/git-reconcile.mjs +0 -30
  368. package/dist/commands/execute/git-reconcile.mjs.map +0 -1
  369. package/dist/commands/execute/handoff.d.mts +0 -1
  370. package/dist/commands/execute/handoff.mjs +0 -31
  371. package/dist/commands/execute/handoff.mjs.map +0 -1
  372. package/dist/commands/execute/incremental-carry.d.mts +0 -1
  373. package/dist/commands/execute/incremental-carry.mjs +0 -20
  374. package/dist/commands/execute/incremental-carry.mjs.map +0 -1
  375. package/dist/commands/execute/incremental-scope.d.mts +0 -1
  376. package/dist/commands/execute/incremental-scope.mjs +0 -29
  377. package/dist/commands/execute/incremental-scope.mjs.map +0 -1
  378. package/dist/commands/execute/integrate-stages.d.mts +0 -1
  379. package/dist/commands/execute/integrate-stages.mjs +0 -24
  380. package/dist/commands/execute/integrate-stages.mjs.map +0 -1
  381. package/dist/commands/execute/pane-title.d.mts +0 -1
  382. package/dist/commands/execute/pane-title.mjs +0 -20
  383. package/dist/commands/execute/pane-title.mjs.map +0 -1
  384. package/dist/commands/execute/plan-items.d.mts +0 -1
  385. package/dist/commands/execute/plan-items.mjs +0 -9
  386. package/dist/commands/execute/plan-items.mjs.map +0 -1
  387. package/dist/commands/execute/plan-validate.d.mts +0 -1
  388. package/dist/commands/execute/plan-validate.mjs +0 -68
  389. package/dist/commands/execute/plan-validate.mjs.map +0 -1
  390. package/dist/commands/execute/plan-verify.d.mts +0 -1
  391. package/dist/commands/execute/plan-verify.mjs +0 -43
  392. package/dist/commands/execute/plan-verify.mjs.map +0 -1
  393. package/dist/commands/execute/spawn-followups.d.mts +0 -1
  394. package/dist/commands/execute/spawn-followups.mjs +0 -22
  395. package/dist/commands/execute/spawn-followups.mjs.map +0 -1
  396. package/dist/commands/execute/team.d.mts +0 -3
  397. package/dist/commands/execute/team.mjs +0 -66
  398. package/dist/commands/execute/team.mjs.map +0 -1
  399. package/dist/commands/execute/token-usage.d.mts +0 -1
  400. package/dist/commands/execute/token-usage.mjs +0 -19
  401. package/dist/commands/execute/token-usage.mjs.map +0 -1
  402. package/dist/commands/execute/worker-audit-check.d.mts +0 -1
  403. package/dist/commands/execute/worker-audit-check.mjs +0 -34
  404. package/dist/commands/execute/worker-audit-check.mjs.map +0 -1
  405. package/dist/commands/execute/worker-dispatch.d.mts +0 -7
  406. package/dist/commands/execute/worker-dispatch.mjs +0 -64
  407. package/dist/commands/execute/worker-dispatch.mjs.map +0 -1
  408. package/dist/commands/execute/worker-state.d.mts +0 -1
  409. package/dist/commands/execute/worker-state.mjs +0 -28
  410. package/dist/commands/execute/worker-state.mjs.map +0 -1
  411. package/dist/commands/execute/worktree-lookup.d.mts +0 -1
  412. package/dist/commands/execute/worktree-lookup.mjs +0 -92
  413. package/dist/commands/execute/worktree-lookup.mjs.map +0 -1
  414. package/dist/commands/execute/worktree-status.d.mts +0 -1
  415. package/dist/commands/execute/worktree-status.mjs +0 -121
  416. package/dist/commands/execute/worktree-status.mjs.map +0 -1
  417. package/dist/commands/inspect/code-review.d.mts +0 -1
  418. package/dist/commands/inspect/code-review.mjs +0 -32
  419. package/dist/commands/inspect/code-review.mjs.map +0 -1
  420. package/dist/commands/inspect/container.d.mts +0 -1
  421. package/dist/commands/inspect/container.mjs +0 -25
  422. package/dist/commands/inspect/container.mjs.map +0 -1
  423. package/dist/commands/inspect/context-cost.d.mts +0 -1
  424. package/dist/commands/inspect/context-cost.mjs +0 -25
  425. package/dist/commands/inspect/context-cost.mjs.map +0 -1
  426. package/dist/commands/inspect/design-prep.d.mts +0 -1
  427. package/dist/commands/inspect/design-prep.mjs +0 -22
  428. package/dist/commands/inspect/design-prep.mjs.map +0 -1
  429. package/dist/commands/inspect/error-report.d.mts +0 -1
  430. package/dist/commands/inspect/error-report.mjs +0 -25
  431. package/dist/commands/inspect/error-report.mjs.map +0 -1
  432. package/dist/commands/inspect/error-zip.d.mts +0 -1
  433. package/dist/commands/inspect/error-zip.mjs +0 -24
  434. package/dist/commands/inspect/error-zip.mjs.map +0 -1
  435. package/dist/commands/inspect/log-report.d.mts +0 -1
  436. package/dist/commands/inspect/log-report.mjs +0 -26
  437. package/dist/commands/inspect/log-report.mjs.map +0 -1
  438. package/dist/commands/inspect/model-io.d.mts +0 -1
  439. package/dist/commands/inspect/model-io.mjs +0 -25
  440. package/dist/commands/inspect/model-io.mjs.map +0 -1
  441. package/dist/commands/inspect/profile-show.d.mts +0 -1
  442. package/dist/commands/inspect/profile-show.mjs +0 -28
  443. package/dist/commands/inspect/profile-show.mjs.map +0 -1
  444. package/dist/commands/inspect/recap.d.mts +0 -1
  445. package/dist/commands/inspect/recap.mjs +0 -30
  446. package/dist/commands/inspect/recap.mjs.map +0 -1
  447. package/dist/commands/inspect/resolve-task-key.d.mts +0 -1
  448. package/dist/commands/inspect/resolve-task-key.mjs +0 -24
  449. package/dist/commands/inspect/resolve-task-key.mjs.map +0 -1
  450. package/dist/commands/inspect/rollup.d.mts +0 -1
  451. package/dist/commands/inspect/rollup.mjs +0 -25
  452. package/dist/commands/inspect/rollup.mjs.map +0 -1
  453. package/dist/commands/inspect/run-audit.d.mts +0 -1
  454. package/dist/commands/inspect/run-audit.mjs +0 -25
  455. package/dist/commands/inspect/run-audit.mjs.map +0 -1
  456. package/dist/commands/inspect/set-work-status.d.mts +0 -1
  457. package/dist/commands/inspect/set-work-status.mjs +0 -29
  458. package/dist/commands/inspect/set-work-status.mjs.map +0 -1
  459. package/dist/commands/inspect/stage-map.d.mts +0 -1
  460. package/dist/commands/inspect/stage-map.mjs +0 -131
  461. package/dist/commands/inspect/stage-map.mjs.map +0 -1
  462. package/dist/commands/inspect/task-list.d.mts +0 -1
  463. package/dist/commands/inspect/task-list.mjs +0 -149
  464. package/dist/commands/inspect/task-list.mjs.map +0 -1
  465. package/dist/commands/inspect/task-show.d.mts +0 -1
  466. package/dist/commands/inspect/task-show.mjs +0 -108
  467. package/dist/commands/inspect/task-show.mjs.map +0 -1
  468. package/dist/commands/inspect/time-report.d.mts +0 -1
  469. package/dist/commands/inspect/time-report.mjs +0 -24
  470. package/dist/commands/inspect/time-report.mjs.map +0 -1
  471. package/dist/commands/inspect/usage-report.d.mts +0 -1
  472. package/dist/commands/inspect/usage-report.mjs +0 -23
  473. package/dist/commands/inspect/usage-report.mjs.map +0 -1
  474. package/dist/commands/inspect/user-response.d.mts +0 -1
  475. package/dist/commands/inspect/user-response.mjs +0 -35
  476. package/dist/commands/inspect/user-response.mjs.map +0 -1
  477. package/dist/commands/inspect/worker-liveness.d.mts +0 -1
  478. package/dist/commands/inspect/worker-liveness.mjs +0 -45
  479. package/dist/commands/inspect/worker-liveness.mjs.map +0 -1
  480. package/dist/commands/lifecycle/contract-check.d.mts +0 -1
  481. package/dist/commands/lifecycle/contract-check.mjs +0 -18
  482. package/dist/commands/lifecycle/contract-check.mjs.map +0 -1
  483. package/dist/commands/lifecycle/migrate.d.mts +0 -1
  484. package/dist/commands/lifecycle/migrate.mjs +0 -30
  485. package/dist/commands/lifecycle/migrate.mjs.map +0 -1
  486. package/dist/commands/lifecycle/model.d.mts +0 -1
  487. package/dist/commands/lifecycle/model.mjs +0 -22
  488. package/dist/commands/lifecycle/model.mjs.map +0 -1
  489. package/dist/commands/manager.d.mts +0 -3
  490. package/dist/commands/manager.mjs +0 -50
  491. package/dist/commands/manager.mjs.map +0 -1
  492. package/dist/commands/report/agent-activity.d.mts +0 -1
  493. package/dist/commands/report/agent-activity.mjs +0 -20
  494. package/dist/commands/report/agent-activity.mjs.map +0 -1
  495. package/dist/commands/report/approval-decision.d.mts +0 -1
  496. package/dist/commands/report/approval-decision.mjs +0 -21
  497. package/dist/commands/report/approval-decision.mjs.map +0 -1
  498. package/dist/commands/report/design-snapshot.d.mts +0 -1
  499. package/dist/commands/report/design-snapshot.mjs +0 -19
  500. package/dist/commands/report/design-snapshot.mjs.map +0 -1
  501. package/dist/commands/report/finalize.d.mts +0 -4
  502. package/dist/commands/report/finalize.mjs +0 -64
  503. package/dist/commands/report/finalize.mjs.map +0 -1
  504. package/dist/commands/report/inject-report-index.d.mts +0 -1
  505. package/dist/commands/report/inject-report-index.mjs +0 -21
  506. package/dist/commands/report/inject-report-index.mjs.map +0 -1
  507. package/dist/commands/report/render-final-report.d.mts +0 -1
  508. package/dist/commands/report/render-final-report.mjs +0 -23
  509. package/dist/commands/report/render-final-report.mjs.map +0 -1
  510. package/dist/commands/report/render-views.d.mts +0 -1
  511. package/dist/commands/report/render-views.mjs +0 -27
  512. package/dist/commands/report/render-views.mjs.map +0 -1
  513. package/dist/commands/report/translate.d.mts +0 -1
  514. package/dist/commands/report/translate.mjs +0 -33
  515. package/dist/commands/report/translate.mjs.map +0 -1
  516. package/runtime/bin/lib/okstra/tmux-pane.sh +0 -40
  517. package/runtime/bin/lib/okstra-ctl/cmd-batch.sh +0 -59
  518. package/runtime/bin/lib/okstra-ctl/cmd-list.sh +0 -35
  519. package/runtime/bin/lib/okstra-ctl/cmd-open.sh +0 -36
  520. package/runtime/bin/lib/okstra-ctl/cmd-projects.sh +0 -26
  521. package/runtime/bin/lib/okstra-ctl/cmd-reconcile.sh +0 -29
  522. package/runtime/bin/lib/okstra-ctl/cmd-reindex.sh +0 -38
  523. package/runtime/bin/lib/okstra-ctl/cmd-rerun.sh +0 -345
  524. package/runtime/bin/lib/okstra-ctl/cmd-show.sh +0 -27
  525. package/runtime/bin/lib/okstra-ctl/cmd-tail.sh +0 -92
  526. package/runtime/bin/lib/okstra-ctl/main.sh +0 -41
  527. package/runtime/bin/lib/okstra-ctl/prepare.sh +0 -31
  528. package/runtime/bin/lib/okstra-ctl/usage.sh +0 -23
  529. package/runtime/bin/okstra-central.sh +0 -152
  530. package/runtime/bin/okstra-incremental-carry.py +0 -10
  531. package/runtime/bin/okstra-incremental-scope.py +0 -10
  532. package/runtime/bin/okstra-team-reconcile.sh +0 -36
  533. package/runtime/python/okstra_ctl/agent_prompt_cli.py +0 -1156
  534. package/runtime/python/okstra_ctl/batch.py +0 -60
  535. package/runtime/python/okstra_ctl/clarification_items.py +0 -1050
  536. package/runtime/python/okstra_ctl/container_registry.py +0 -72
  537. package/runtime/python/okstra_ctl/improvement_assignment.py +0 -61
  538. package/runtime/python/okstra_ctl/resolver.py +0 -54
  539. package/runtime/python/okstra_ctl/team_reconcile.py +0 -275
  540. package/runtime/python/okstra_ctl/tmux.py +0 -134
  541. package/runtime/python/okstra_ctl/worktree.py +0 -1099
@@ -12,7 +12,7 @@ from .execution_identity import ExecutionManifest
12
12
  WORKING_SCHEMA_VERSION = "1.0"
13
13
  V2_WORKING_SCHEMA_VERSION = "2.0"
14
14
  EXECUTION_IDENTITY_VERSION = 2
15
- FINAL_SCHEMA_VERSION = "1.3"
15
+ FINAL_SCHEMA_VERSION = "1.4"
16
16
  _AUDIENCES = {"analysis", "lead", "report-writer"}
17
17
  # 발견을 낼 수 있는 audience. 리드는 계약상 Phase 6 자체 리뷰를 수행하므로
18
18
  # 출처가 될 수 있지만, 교차검증 표는 아니다 — 합의 수는 아래에서 분석 워커만
@@ -25,17 +25,29 @@ _AUDIENCE_SOURCE_ROLES = {
25
25
  "report-writer": frozenset({"report-writer"}),
26
26
  }
27
27
  _VERIFICATION_MODES = {"lightweight", "full-reanalysis"}
28
+ # `unverified` 가 없던 시절의 산출물. `reuse-final` 이 그대로 읽으므로 그
29
+ # 어휘로 검증한다 — 없던 키를 요구하면 이미 디스크에 있는 리포트가 무효가 된다.
30
+ _HISTORICAL_FINAL_VERSIONS = frozenset({"1.0", "1.1", "1.2", "1.3"})
28
31
  _CLASSIFICATION_COUNT_KEYS = {
29
32
  "full-consensus": "fullConsensus",
30
33
  "partial-consensus": "partialConsensus",
31
34
  "contested": "contested",
32
35
  "worker-unique": "workerUnique",
36
+ # 아무도 판정하지 않은 finding. `contested` 와 정반대 상태다 — 하나는
37
+ # 동료가 보고 반박한 것이고 이것은 동료가 못 본 것이다. 같은 칸에
38
+ # 세면 워커가 다투지 않은 run 이 다툰 것으로 읽힌다.
39
+ "unverified": "unverified",
33
40
  }
34
41
  _VERDICTS = {"agree", "disagree", "supplement", "verification-error"}
35
42
  _INPUT_VERDICTS = _VERDICTS | {"unverifiable"}
36
43
  _DISAGREE_BASES = {"counter-evidence", "burden-not-met"}
37
44
  _DISPATCH_STATUSES = {"completed", "timeout", "error", "not-run"}
38
- _CRITIC_CLASSIFICATIONS = set(_CLASSIFICATION_COUNT_KEYS) | {"unverified"}
45
+ _CRITIC_CLASSIFICATIONS = set(_CLASSIFICATION_COUNT_KEYS)
46
+ # gap 전용 분류 하나. critic 이 스스로 "이건 F-NNN 과 같은 것" 이라고 선언한 gap
47
+ # 이며, 검증 라운드에 들어가지 않으므로 표가 없다. finding 분류에 넣으면
48
+ # `finalClassificationCounts` 에 대응하는 칸이 없는 값이 생긴다.
49
+ _CRITIC_DUPLICATE = "duplicate"
50
+ _CRITIC_GAP_CLASSIFICATIONS = _CRITIC_CLASSIFICATIONS | {_CRITIC_DUPLICATE}
39
51
  _FINAL_V13_KEYS = {
40
52
  "schemaVersion",
41
53
  "taskKey",
@@ -49,6 +61,16 @@ _FINAL_V13_KEYS = {
49
61
  "criticVerification",
50
62
  "unverifiedGaps",
51
63
  }
64
+ # 게이트가 닫힌 계획에 실리는 문구. 계획의 `round` 는 산출물 이름
65
+ # (`convergence-round-<N>-plan-...`)의 순번이라 finalize 계획에도 값이 있고,
66
+ # 리드가 그것을 "다음 디스패치 라운드"로 읽어 워커를 띄우면 `apply_round_results`
67
+ # 에서 막힌다. 계획 자체가 다음 명령을 말하게 해 그 오독을 없앤다.
68
+ FINALIZE_PLAN_NOTE = (
69
+ "the round gate closed: this plan is a finalize instruction, not a "
70
+ "dispatch round. Dispatch no reverify worker; run "
71
+ "`okstra convergence apply-critic-gaps` when the coverage critic is "
72
+ "enabled, then `okstra convergence finalize`."
73
+ )
52
74
 
53
75
 
54
76
  class ConvergenceContractError(ValueError):
@@ -118,7 +140,6 @@ def seed_working_state(
118
140
  source.get("groups"),
119
141
  analysis_workers,
120
142
  finding_sources,
121
- adversarial=config["adversarial"],
122
143
  )
123
144
  state["findings"] = findings
124
145
  state["queueFindingIds"] = queue
@@ -159,14 +180,20 @@ def plan_next_round(state: Mapping[str, Any]) -> dict[str, Any]:
159
180
  reason = "queue-empty"
160
181
  elif len(history) >= effective_max:
161
182
  reason = "max-rounds-reached"
183
+ # `round` 는 계획 산출물 이름의 `<N>` 이다. finalize 계획에도 이름이 필요해
184
+ # 값을 그대로 남기되, `dispatchable` 로 이 계획이 디스패치용인지 아닌지를
185
+ # 한 필드에서 읽히게 한다 — 두 액션 모두에 실어야 `dispatchable` 부재를
186
+ # false 로 오독하는 소비자가 생기지 않는다.
162
187
  round_number = len(history) + 1
163
188
  if reason is not None:
164
189
  return {
165
190
  "schemaVersion": WORKING_SCHEMA_VERSION,
166
191
  "action": "finalize",
167
192
  "round": round_number,
193
+ "dispatchable": False,
168
194
  "inputQueueSize": len(queue),
169
195
  "reason": reason,
196
+ "note": FINALIZE_PLAN_NOTE,
170
197
  "dispatches": [],
171
198
  "skippedWorkers": [],
172
199
  }
@@ -180,15 +207,11 @@ def plan_next_round(state: Mapping[str, Any]) -> dict[str, Any]:
180
207
  if not isinstance(worker, Mapping) or worker.get("audience") != "analysis":
181
208
  continue
182
209
  worker_id = _required_string(worker, "workerId", "working state worker")
183
- worker_participant_ref = _worker_participant_ref(worker)
184
210
  eligible = [
185
211
  finding_id
186
212
  for finding_id in queue
187
213
  if _worker_is_independent_from_finding(
188
- worker_id,
189
- worker_participant_ref,
190
- findings[finding_id],
191
- workers,
214
+ worker, findings[finding_id], workers
192
215
  )
193
216
  ]
194
217
  if eligible:
@@ -209,6 +232,7 @@ def plan_next_round(state: Mapping[str, Any]) -> dict[str, Any]:
209
232
  "schemaVersion": WORKING_SCHEMA_VERSION,
210
233
  "action": "dispatch",
211
234
  "round": round_number,
235
+ "dispatchable": True,
212
236
  "inputQueueSize": len(queue),
213
237
  "dispatches": dispatches,
214
238
  "skippedWorkers": skipped,
@@ -221,14 +245,28 @@ def _worker_participant_ref(worker: Mapping[str, Any]) -> str | None:
221
245
 
222
246
 
223
247
  def _worker_is_independent_from_finding(
224
- worker_id: str,
225
- participant_ref: str | None,
248
+ worker: Mapping[str, Any],
226
249
  finding: Mapping[str, Any],
227
250
  workers: list[Any],
228
251
  ) -> bool:
252
+ """이 워커가 그 finding 의 원저자가 아닌가.
253
+
254
+ 비교 기준은 role execution 이다 — participant(공급자+모델 세션)가 아니다.
255
+ ADR-0017 이 정하듯 공급자와 모델은 역할의 정체성이 아니고, 같은 모델이 두
256
+ 역할에 배정되면 두 개의 독립된 워커가 된다. 저자와 검증자는 서로 다른 역할
257
+ 계약·다른 duty·다른 세션이므로, 모델 이름이 같다는 이유로 검증에서 빼면
258
+ 패널이 근거 없이 줄어든다 — 같은 doctrine 이 critic gap 에 대해서는 이미
259
+ `prompts/lead/convergence.md` §"Critic gaps" 에 적혀 있다.
260
+
261
+ `sourceRoleExecutionRef` 는 `participantRef` + `role` 로 유일하고
262
+ (`execution_manifest._validate_roles`), 워커마다 하나씩 검증된다
263
+ (`_validate_v2_workers` 의 duplicate 검사). v1 상태에는 그 필드가 없으므로
264
+ 그때만 workerId 로 떨어진다.
265
+ """
229
266
  origin_worker = finding.get("originWorker")
230
- if participant_ref is None:
231
- return origin_worker != worker_id
267
+ source_ref = worker.get("sourceRoleExecutionRef")
268
+ if not isinstance(source_ref, str) or not source_ref:
269
+ return origin_worker != worker.get("workerId")
232
270
  origin = next(
233
271
  (
234
272
  row
@@ -237,10 +275,10 @@ def _worker_is_independent_from_finding(
237
275
  ),
238
276
  None,
239
277
  )
240
- origin_participant_ref = (
241
- _worker_participant_ref(origin) if origin is not None else None
278
+ origin_source_ref = (
279
+ origin.get("sourceRoleExecutionRef") if isinstance(origin, Mapping) else None
242
280
  )
243
- return origin_participant_ref != participant_ref
281
+ return origin_source_ref != source_ref
244
282
 
245
283
 
246
284
  def _worker_execution_refs(worker: Mapping[str, Any]) -> dict[str, str]:
@@ -292,11 +330,15 @@ def classify_adversarial_round(
292
330
  if len(disagrees) == len(usable):
293
331
  return "worker-unique"
294
332
  if any(vote["disagreeBasis"] == "counter-evidence" for vote in disagrees):
295
- return None
333
+ # 증거 기반 반증은 영구적이다 — 뒤 라운드의 동의가 그것을 지우지
334
+ # 못한다. 그러면 이 finding 을 다음 라운드로 넘겨도 검증자는 새 정보
335
+ # 없이 같은 표를 다시 던지고, 결과는 어차피 `contested` 다. 그 자리에서
336
+ # 종결해 재디스패치 비용을 0 으로 만든다.
337
+ return "contested"
296
338
  burden_count = sum(
297
339
  vote["disagreeBasis"] == "burden-not-met" for vote in disagrees
298
340
  )
299
- if burden_count > len(usable) / 2:
341
+ if burden_count * 2 >= len(usable):
300
342
  return None
301
343
  return "partial-consensus"
302
344
 
@@ -315,7 +357,41 @@ def _round_has_counter_evidence(
315
357
  def _classify_adversarial_history(
316
358
  rounds: list[Mapping[str, Any]],
317
359
  ) -> str | None:
318
- """Classify adversarial rounds without erasing earlier counter-evidence."""
360
+ """적대적 라운드 기록을 읽어 확정된 분류를 돌려준다.
361
+
362
+ 분류를 낸 첫 라운드에서 멈춘다. `counter-evidence` 반증이 만드는
363
+ `contested` 도 그 라운드에서 종결이다 — 반증이 영구적이므로 다음 라운드는
364
+ 같은 표를 다시 받을 뿐이다.
365
+
366
+ 예외는 구 엔진이 남긴 상태 하나다. 구 엔진은 counter-evidence 를 마지막
367
+ 라운드까지 끌고 갔고, 그 뒤로는 만장일치 반대(`worker-unique`)만 인정했다.
368
+ 종결 라운드 뒤에도 이 finding 의 라운드 기록이 이어진다는 사실이 그 상태의
369
+ 지문이다 — 현 엔진에서는 거기서 큐를 떠나므로 뒤 라운드가 생기지 않는다.
370
+ 그때만 구 규칙으로 이어 읽어, 이미 기록된 세션을 소급해 실패시키지 않는다.
371
+ """
372
+ rows = [
373
+ row
374
+ for row in rounds
375
+ if isinstance(row, Mapping) and isinstance(row.get("votes"), Mapping)
376
+ ]
377
+ for index, row in enumerate(rows):
378
+ classification = classify_adversarial_round(row["votes"])
379
+ if classification is None:
380
+ continue
381
+ if classification == "contested" and index < len(rows) - 1:
382
+ return _legacy_adversarial_history(rows)
383
+ return classification
384
+ return None
385
+
386
+
387
+ def _legacy_adversarial_history(
388
+ rounds: list[Mapping[str, Any]],
389
+ ) -> str | None:
390
+ """구 엔진의 적대적 분류 — 역호환 재생 전용.
391
+
392
+ counter-evidence 를 한 번 본 뒤로는 만장일치 반대만 분류를 확정했고, 그
393
+ 외에는 계속 다음 라운드로 넘겨 finalize 가 `contested` 를 찍게 했다.
394
+ """
319
395
  counter_evidence_seen = False
320
396
  for row in rounds:
321
397
  votes = row.get("votes") if isinstance(row, Mapping) else None
@@ -342,7 +418,18 @@ def apply_round_results(
342
418
  expected_plan = plan_next_round(current)
343
419
  supplied_plan = _object(plan, "round plan")
344
420
  if expected_plan.get("action") != "dispatch":
345
- raise ConvergenceContractError("working state has no dispatch round to apply")
421
+ # 예전 문구("no dispatch round to apply")는 상태를 말하지 않아서, 리드가
422
+ # finalize 계획의 `round` 를 디스패치 라운드로 읽고 워커를 띄운 뒤
423
+ # 여기서 막히면 무엇을 해야 하는지 알 수 없었다. 닫힌 사유와 다음 명령을
424
+ # 함께 낸다.
425
+ raise ConvergenceContractError(
426
+ "the round gate closed after round "
427
+ f"{len(current.get('roundHistory') or [])} "
428
+ f"(reason: {expected_plan.get('reason')}); the current plan is a "
429
+ "finalize instruction, not a dispatch round, and its `round` "
430
+ f"{expected_plan.get('round')} is only the artifact-name ordinal. "
431
+ "Dispatch nothing and run `okstra convergence finalize`."
432
+ )
346
433
  if supplied_plan != expected_plan:
347
434
  raise ConvergenceContractError("round plan does not match current working state")
348
435
  payload = _object(results, "round results")
@@ -469,6 +556,9 @@ def apply_critic_gap_results(
469
556
  gap["classification"] in {"contested", "worker-unique"}
470
557
  for gap in ledger_gaps
471
558
  )
559
+ duplicate = sum(
560
+ gap["classification"] == _CRITIC_DUPLICATE for gap in ledger_gaps
561
+ )
472
562
  updated["config"]["critic"] = {
473
563
  "provider": provider,
474
564
  "modelExecutionValue": model,
@@ -476,6 +566,9 @@ def apply_critic_gap_results(
476
566
  "gapsMerged": merged,
477
567
  "gapsRejected": rejected,
478
568
  "gapsUnverified": len(unverified),
569
+ # 선언된 중복은 제안됐으나 검증하지 않은 gap 이다. 넷 중 어느 칸에 넣어도
570
+ # 거짓이 된다 — 병합도 기각도 아니고, 판정을 못 받은 것도 아니다.
571
+ "gapsDuplicate": duplicate,
479
572
  }
480
573
  updated["unverifiedGaps"] = unverified
481
574
  updated_errors = validate_working_state(updated)
@@ -499,6 +592,9 @@ def finalize_working_state(state: Mapping[str, Any]) -> dict[str, Any]:
499
592
  for finding in findings:
500
593
  if finding["findingId"] not in queue:
501
594
  continue
595
+ if _received_no_verdict(finding):
596
+ finding["classification"] = "unverified"
597
+ continue
502
598
  finding["classification"] = (
503
599
  "contested"
504
600
  if adversarial
@@ -688,7 +784,7 @@ def validate_final_state(state: Mapping[str, Any]) -> list[str]:
688
784
  if not isinstance(state, Mapping):
689
785
  return ["final state must be an object"]
690
786
  version = state.get("schemaVersion")
691
- if version not in {"1.0", "1.1", "1.2", FINAL_SCHEMA_VERSION}:
787
+ if version not in _HISTORICAL_FINAL_VERSIONS | {FINAL_SCHEMA_VERSION}:
692
788
  errors.append("unsupported final schemaVersion")
693
789
  config = state.get("config")
694
790
  if not isinstance(config, Mapping):
@@ -707,6 +803,10 @@ def validate_final_state(state: Mapping[str, Any]) -> list[str]:
707
803
  history = []
708
804
  errors.extend(_validate_round_history(history))
709
805
  adversarial = bool(config.get("adversarial"))
806
+ historical = version in _HISTORICAL_FINAL_VERSIONS
807
+ allowed_classifications = set(_CLASSIFICATION_COUNT_KEYS)
808
+ if historical:
809
+ allowed_classifications.discard("unverified")
710
810
  seen_ids: set[str] = set()
711
811
  for finding in findings:
712
812
  if not isinstance(finding, Mapping):
@@ -720,16 +820,26 @@ def validate_final_state(state: Mapping[str, Any]) -> list[str]:
720
820
  else:
721
821
  seen_ids.add(finding_id)
722
822
  classification = finding.get("classification")
723
- if classification not in _CLASSIFICATION_COUNT_KEYS:
823
+ if classification not in allowed_classifications:
724
824
  errors.append(f"finding {finding_id} has invalid classification")
725
825
  errors.extend(_validate_finding_ledger(finding, len(history), adversarial))
726
826
  rounds = finding.get("rounds") if isinstance(finding.get("rounds"), list) else []
727
- if classification == "contested" and rounds:
827
+ # `unverified`, 그리고 마지막 라운드까지 판정을 못 받아 finalize 가 찍은
828
+ # `contested` 는 둘 다 "마지막 라운드까지 큐에 남아 있었다"는 뜻이다.
829
+ # 라운드 안에서 확정된 `contested`(증거 기반 반증)는 다르다 — 그때 큐를
830
+ # 떠나므로 뒤 라운드 기록이 없는 것이 정상이다.
831
+ settled_in_round = (
832
+ adversarial and _round_settled_classification(rounds) is not None
833
+ )
834
+ unresolved = classification in {"contested", "unverified"}
835
+ if unresolved and rounds and not settled_in_round:
728
836
  if rounds[-1].get("round") != len(history):
729
837
  errors.append(f"finding {finding_id} contested before the final round")
730
- if classification != "contested":
838
+ if not unresolved:
731
839
  errors.extend(_validate_no_reappearance_after_resolution(finding, adversarial))
732
- expected = _expected_final_classification(finding, adversarial)
840
+ expected = _expected_final_classification(
841
+ finding, adversarial, historical=historical
842
+ )
733
843
  if expected is not None and classification != expected:
734
844
  errors.append(
735
845
  f"finding {finding_id} classification {classification} "
@@ -744,6 +854,8 @@ def validate_final_state(state: Mapping[str, Any]) -> list[str]:
744
854
  errors.append("finalClassificationCounts must be an object")
745
855
  else:
746
856
  actual = classification_counts_from_findings(findings)
857
+ if historical and "unverified" not in declared:
858
+ actual.pop("unverified")
747
859
  if dict(declared) != actual:
748
860
  errors.append(
749
861
  f"finalClassificationCounts {dict(declared)} does not match findings {actual}"
@@ -784,22 +896,34 @@ def _parse_critic_gap_results(
784
896
  )
785
897
  provider = _required_string(payload, "provider", "critic gap results")
786
898
  model = _required_string(payload, "modelExecutionValue", "critic gap results")
787
- roster = {
899
+ roster_order = [
788
900
  worker["workerId"]
789
901
  for worker in state.get("workers", [])
790
902
  if isinstance(worker, Mapping) and worker.get("audience") == "analysis"
791
- }
903
+ ]
904
+ roster = set(roster_order)
792
905
  dispatches, completed = _parse_critic_dispatches(
793
- payload.get("dispatches"), roster, provider
906
+ payload.get("dispatches"), roster
907
+ )
908
+ known_findings = {
909
+ finding["findingId"]
910
+ for finding in state.get("findings", [])
911
+ if isinstance(finding, Mapping) and _nonempty_string(finding.get("findingId"))
912
+ }
913
+ gaps = _parse_critic_gaps(
914
+ payload.get("gaps"), roster, completed, known_findings
915
+ )
916
+ coverage_errors = _critic_gap_coverage_errors(
917
+ roster_order, gaps, {row["worker"] for row in dispatches}
794
918
  )
795
- gaps = _parse_critic_gaps(payload.get("gaps"), roster, provider, completed)
919
+ if coverage_errors:
920
+ raise ConvergenceContractError("; ".join(coverage_errors))
796
921
  return provider, model, dispatches, gaps
797
922
 
798
923
 
799
924
  def _parse_critic_dispatches(
800
925
  value: Any,
801
926
  roster: set[str],
802
- provider: str,
803
927
  ) -> tuple[list[dict[str, Any]], set[str]]:
804
928
  if not isinstance(value, list):
805
929
  raise ConvergenceContractError("critic gap results dispatches must be an array")
@@ -824,22 +948,64 @@ def _parse_critic_dispatches(
824
948
  )
825
949
  seen.add(worker)
826
950
  dispatches.append({"worker": worker, "status": status, "durationMs": duration})
827
- expected = set(roster)
828
- if seen != expected:
829
- missing = sorted(expected - seen)
830
- raise ConvergenceContractError(
831
- "critic dispatches must account for every non-critic analyser exactly "
832
- f"once; missing: {', '.join(missing) or 'none'}"
833
- )
834
951
  completed = {row["worker"] for row in dispatches if row["status"] == "completed"}
835
952
  return dispatches, completed
836
953
 
837
954
 
955
+ def _critic_gap_assignees(
956
+ roster_order: list[str],
957
+ gaps: list[Mapping[str, Any]],
958
+ ) -> list[str]:
959
+ """gap 하나당 검증자 한 명. 로스터 순서 위의 round-robin 이다.
960
+
961
+ 종전 계약은 gap 배치를 분석자 전원에게 부치라고 요구했다. 거부도 승인과 같은
962
+ 값을 치르는데, 그 라운드는 `rounds: []` 로 기록조차 남지 않는 직렬 구간이다.
963
+ gap 마다 검증자 하나면 gap 이 로스터보다 적을 때 디스패치 수가 그만큼 줄고,
964
+ 많을 때는 각 분석자가 전체가 아니라 자기 몫만 읽는다.
965
+
966
+ 선언한 중복(`duplicateOf`)은 배정에서 빠진다 — 검증하지 않기로 한 gap 이다.
967
+ """
968
+ verifiable = [gap for gap in gaps if not gap.get("duplicateOf")]
969
+ if not roster_order:
970
+ return []
971
+ return [
972
+ roster_order[index % len(roster_order)]
973
+ for index in range(len(verifiable))
974
+ ]
975
+
976
+
977
+ def _critic_gap_coverage_errors(
978
+ roster_order: list[str],
979
+ gaps: list[Mapping[str, Any]],
980
+ dispatched: set[str],
981
+ ) -> list[str]:
982
+ """디스패치 집합이 두 모양 중 하나인가.
983
+
984
+ 구 산출물은 분석자 전원이 한 줄씩 있고(전면 fan-out), 새 산출물은 배정받은
985
+ 분석자만 있다. 최종 산출물에는 어느 쪽으로 돌았는지 적히지 않으므로 둘 다
986
+ 받는다 — `_validate_round_ledger_counts` 가 구/신 산술을 함께 받는 것과 같은
987
+ 이유다. gap 수가 로스터 크기 이상이면 두 집합은 같아진다.
988
+
989
+ 배정받은 분석자가 자기 몫 밖의 gap 에도 표를 던지는 것은 막지 않는다. 그건
990
+ 계약 위반이 아니라 더 본 것이고, 그 사이 방치된 gap 은 표가 없어
991
+ `unverifiedGaps` 로 스스로 드러난다.
992
+ """
993
+ assignees = set(_critic_gap_assignees(roster_order, gaps))
994
+ if dispatched == set(roster_order) or dispatched == assignees:
995
+ return []
996
+ missing = sorted(assignees - dispatched)
997
+ return [
998
+ "critic dispatches must cover every gap exactly once (round-robin over "
999
+ "the analyser roster) or account for every non-critic analyser exactly "
1000
+ f"once; missing: {', '.join(missing) or 'none'}"
1001
+ ]
1002
+
1003
+
838
1004
  def _parse_critic_gaps(
839
1005
  value: Any,
840
1006
  roster: set[str],
841
- provider: str,
842
1007
  completed: set[str],
1008
+ known_findings: set[str],
843
1009
  ) -> list[dict[str, Any]]:
844
1010
  if not isinstance(value, list):
845
1011
  raise ConvergenceContractError("critic gap results gaps must be an array")
@@ -851,7 +1017,7 @@ def _parse_critic_gaps(
851
1017
  if gap_id in seen:
852
1018
  raise ConvergenceContractError(f"duplicate critic gapId: {gap_id}")
853
1019
  seen.add(gap_id)
854
- parsed = _parse_critic_gap(gap, index, roster, provider, completed)
1020
+ parsed = _parse_critic_gap(gap, index, roster, completed, known_findings)
855
1021
  parsed["gapId"] = gap_id
856
1022
  gaps.append(parsed)
857
1023
  return gaps
@@ -861,11 +1027,28 @@ def _parse_critic_gap(
861
1027
  gap: Mapping[str, Any],
862
1028
  index: int,
863
1029
  roster: set[str],
864
- provider: str,
865
1030
  completed: set[str],
1031
+ known_findings: set[str],
866
1032
  ) -> dict[str, Any]:
867
1033
  label = f"critic gaps[{index}]"
868
- votes_value = _object(gap.get("votes"), f"{label}.votes")
1034
+ duplicate_of = gap.get("duplicateOf")
1035
+ if duplicate_of is not None:
1036
+ if not _nonempty_string(duplicate_of):
1037
+ raise ConvergenceContractError(
1038
+ f"{label}.duplicateOf must be a non-empty finding id"
1039
+ )
1040
+ if duplicate_of not in known_findings:
1041
+ raise ConvergenceContractError(
1042
+ f"{label}.duplicateOf names no existing finding: {duplicate_of}"
1043
+ )
1044
+ if gap.get("votes"):
1045
+ raise ConvergenceContractError(
1046
+ f"{label} declares duplicateOf and must carry no votes: a "
1047
+ "declared duplicate never enters gap verification"
1048
+ )
1049
+ votes_value = _object(
1050
+ {} if duplicate_of is not None else gap.get("votes"), f"{label}.votes"
1051
+ )
869
1052
  votes: dict[str, dict[str, Any]] = {}
870
1053
  for worker, raw_vote in votes_value.items():
871
1054
  if worker not in roster:
@@ -890,6 +1073,8 @@ def _parse_critic_gap(
890
1073
  "originEvidence": _required_string(gap, "originEvidence", label),
891
1074
  "votes": votes,
892
1075
  }
1076
+ if duplicate_of is not None:
1077
+ parsed["duplicateOf"] = duplicate_of
893
1078
  if "evidenceArtifacts" in gap:
894
1079
  parsed["evidenceArtifacts"] = _parse_evidence_artifacts(
895
1080
  gap.get("evidenceArtifacts"), label
@@ -906,7 +1091,12 @@ def _reduce_critic_gaps(
906
1091
  unverified: list[dict[str, str]] = []
907
1092
  next_number = _next_finding_number(state["findings"])
908
1093
  for gap in gaps:
909
- classification = _critic_gap_classification(gap["votes"])
1094
+ duplicate_of = gap.get("duplicateOf")
1095
+ classification = (
1096
+ _CRITIC_DUPLICATE
1097
+ if duplicate_of
1098
+ else _critic_gap_classification(gap["votes"])
1099
+ )
910
1100
  merged_id = None
911
1101
  if classification in {"full-consensus", "partial-consensus"}:
912
1102
  merged_id = f"F-{next_number:03d}"
@@ -932,6 +1122,7 @@ def _reduce_critic_gaps(
932
1122
  "classification": classification,
933
1123
  "mergedFindingId": merged_id,
934
1124
  "votes": deepcopy(gap["votes"]),
1125
+ **({"duplicateOf": duplicate_of} if duplicate_of else {}),
935
1126
  **(
936
1127
  {"evidenceArtifacts": deepcopy(gap["evidenceArtifacts"])}
937
1128
  if "evidenceArtifacts" in gap
@@ -1047,6 +1238,12 @@ def _validate_critic_contract(
1047
1238
  unverified,
1048
1239
  critic_config,
1049
1240
  ledger,
1241
+ {
1242
+ finding["findingId"]
1243
+ for finding in findings
1244
+ if isinstance(finding, Mapping)
1245
+ and _nonempty_string(finding.get("findingId"))
1246
+ },
1050
1247
  )
1051
1248
  )
1052
1249
  if critic_config.get("provider") != ledger.get("provider"):
@@ -1156,6 +1353,7 @@ def _validate_critic_gap_links(
1156
1353
  unverified: list[Any],
1157
1354
  config: Mapping[str, Any],
1158
1355
  ledger: Mapping[str, Any],
1356
+ finding_ids: set[str],
1159
1357
  ) -> list[str]:
1160
1358
  errors: list[str] = []
1161
1359
  seen_gaps: set[str] = set()
@@ -1197,6 +1395,12 @@ def _validate_critic_gap_links(
1197
1395
  )
1198
1396
  if classification == "unverified" and isinstance(gap_id, str):
1199
1397
  unverified_ids.add(gap_id)
1398
+ duplicate_of = gap.get("duplicateOf") if isinstance(gap, Mapping) else None
1399
+ if _nonempty_string(duplicate_of) and duplicate_of not in finding_ids:
1400
+ errors.append(
1401
+ f"criticVerification gap {gap_id} duplicateOf names no existing "
1402
+ f"finding: {duplicate_of}"
1403
+ )
1200
1404
  if set(critic_findings) != merged_ids:
1201
1405
  errors.append(
1202
1406
  "critic-origin findings do not match criticVerification "
@@ -1224,7 +1428,7 @@ def _validate_critic_gap_row(
1224
1428
  "mergedFindingId",
1225
1429
  "votes",
1226
1430
  }
1227
- allowed = required | {"evidenceArtifacts"}
1431
+ allowed = required | {"evidenceArtifacts", "duplicateOf"}
1228
1432
  if set(gap) - allowed:
1229
1433
  errors.append(f"criticVerification.gaps[{index}] has unsupported fields")
1230
1434
  if required - set(gap):
@@ -1236,7 +1440,7 @@ def _validate_critic_gap_row(
1236
1440
  errors.append(f"criticVerification.gaps[{index}].gapId is invalid")
1237
1441
  classification_valid = (
1238
1442
  _nonempty_string(classification)
1239
- and classification in _CRITIC_CLASSIFICATIONS
1443
+ and classification in _CRITIC_GAP_CLASSIFICATIONS
1240
1444
  )
1241
1445
  if not classification_valid:
1242
1446
  errors.append(f"criticVerification gap {gap_id} has invalid classification")
@@ -1245,6 +1449,14 @@ def _validate_critic_gap_row(
1245
1449
  errors.append(f"criticVerification gap {gap_id} requires mergedFindingId")
1246
1450
  elif classification_valid and merged_id is not None:
1247
1451
  errors.append(f"criticVerification gap {gap_id} must not be merged")
1452
+ if classification_valid and (
1453
+ (classification == _CRITIC_DUPLICATE)
1454
+ != _nonempty_string(gap.get("duplicateOf"))
1455
+ ):
1456
+ errors.append(
1457
+ f"criticVerification gap {gap_id} classification "
1458
+ f"`{classification}` and duplicateOf do not agree"
1459
+ )
1248
1460
  errors.extend(_validate_critic_gap_content(gap, gap_id))
1249
1461
  errors.extend(_validate_critic_gap_votes(gap_id, gap.get("votes"), classification))
1250
1462
  return errors, gap_id, merged_id, classification
@@ -1311,6 +1523,14 @@ def _validate_critic_gap_votes(
1311
1523
  ) -> list[str]:
1312
1524
  if not isinstance(votes, Mapping):
1313
1525
  return [f"criticVerification gap {gap_id} votes must be an object"]
1526
+ if classification == _CRITIC_DUPLICATE:
1527
+ # 선언된 중복은 아무도 판정하지 않는다. 표가 한 장이라도 있으면 그 gap 은
1528
+ # 검증 라운드에 들어간 것이고, 그렇다면 분류가 표에서 나와야 한다.
1529
+ return (
1530
+ []
1531
+ if not votes
1532
+ else [f"criticVerification gap {gap_id} duplicate must carry no votes"]
1533
+ )
1314
1534
  errors: list[str] = []
1315
1535
  parsed: dict[str, dict[str, Any]] = {}
1316
1536
  for worker, vote in votes.items():
@@ -1363,7 +1583,7 @@ def _validate_critic_counts(
1363
1583
  config: Mapping[str, Any],
1364
1584
  classifications: list[Any],
1365
1585
  ) -> list[str]:
1366
- allowed = {
1586
+ required = {
1367
1587
  "provider",
1368
1588
  "modelExecutionValue",
1369
1589
  "gapsProposed",
@@ -1372,8 +1592,13 @@ def _validate_critic_counts(
1372
1592
  "gapsUnverified",
1373
1593
  }
1374
1594
  errors: list[str] = []
1375
- if set(config) != allowed:
1595
+ # `gapsDuplicate` 이전에 쓰인 산출물에는 이 키가 없다. 중복이 0 건인 것과
1596
+ # 중복이라는 개념이 없던 것을 구별할 수 없으므로 부재를 0 으로 읽는다.
1597
+ if set(config) - {"gapsDuplicate"} != required:
1376
1598
  errors.append("config.critic has unsupported fields")
1599
+ duplicate_count = sum(
1600
+ value == _CRITIC_DUPLICATE for value in classifications
1601
+ )
1377
1602
  expected = {
1378
1603
  "gapsProposed": len(classifications),
1379
1604
  "gapsMerged": sum(
@@ -1388,24 +1613,30 @@ def _validate_critic_counts(
1388
1613
  ),
1389
1614
  "gapsUnverified": sum(value == "unverified" for value in classifications),
1390
1615
  }
1616
+ if "gapsDuplicate" in config or duplicate_count:
1617
+ expected["gapsDuplicate"] = duplicate_count
1391
1618
  for key, value in expected.items():
1392
1619
  declared = config.get(key)
1393
1620
  if not isinstance(declared, int) or isinstance(declared, bool) or declared < 0:
1394
1621
  errors.append(f"config.critic.{key} must be a non-negative integer")
1395
1622
  elif declared != value:
1396
1623
  errors.append(f"config.critic.{key} {declared} does not match ledger {value}")
1397
- proposed = config.get("gapsProposed")
1398
- merged = config.get("gapsMerged")
1399
- rejected = config.get("gapsRejected")
1400
- unverified = config.get("gapsUnverified")
1401
- counts = (proposed, merged, rejected, unverified)
1624
+ counts = (
1625
+ config.get("gapsProposed"),
1626
+ config.get("gapsMerged"),
1627
+ config.get("gapsRejected"),
1628
+ config.get("gapsUnverified"),
1629
+ config.get("gapsDuplicate", 0),
1630
+ )
1402
1631
  if all(
1403
1632
  isinstance(value, int) and not isinstance(value, bool)
1404
1633
  for value in counts
1405
1634
  ):
1406
- if proposed != merged + rejected + unverified:
1635
+ proposed, *parts = counts
1636
+ if proposed != sum(parts):
1407
1637
  errors.append(
1408
- "config.critic.gapsProposed must equal gapsMerged + gapsRejected + gapsUnverified"
1638
+ "config.critic.gapsProposed must equal gapsMerged + gapsRejected "
1639
+ "+ gapsUnverified + gapsDuplicate"
1409
1640
  )
1410
1641
  return errors
1411
1642
 
@@ -1436,17 +1667,19 @@ def _validate_critic_roster(
1436
1667
  worker = row.get("worker") if isinstance(row, Mapping) else None
1437
1668
  if not _nonempty_string(worker) or worker not in roster:
1438
1669
  errors.append(f"critic voter must be a non-critic analyser: {worker}")
1439
- expected_dispatches = set(roster)
1440
1670
  actual_dispatches = {
1441
1671
  row.get("worker")
1442
1672
  for row in dispatches
1443
1673
  if isinstance(row, Mapping) and _nonempty_string(row.get("worker"))
1444
1674
  } if isinstance(dispatches, list) else set()
1445
- if actual_dispatches != expected_dispatches:
1446
- errors.append(
1447
- "criticVerification.dispatches must account for every non-critic "
1448
- "analyser exactly once"
1675
+ errors.extend(
1676
+ _critic_gap_coverage_errors(
1677
+ [worker for worker in declared if _nonempty_string(worker)]
1678
+ if isinstance(declared, list) else [],
1679
+ [gap for gap in ledger.get("gaps") or [] if isinstance(gap, Mapping)],
1680
+ actual_dispatches,
1449
1681
  )
1682
+ )
1450
1683
  completed = {
1451
1684
  row.get("worker")
1452
1685
  for row in dispatches
@@ -1467,6 +1700,38 @@ def _validate_critic_roster(
1467
1700
  return errors
1468
1701
 
1469
1702
 
1703
+ def _received_no_verdict(finding: Mapping[str, Any]) -> bool:
1704
+ """이 finding 에 쓸 수 있는 표가 한 장도 없는가.
1705
+
1706
+ 비결과 디스패치(`timeout` / `error` / 결과 파일 없음)는 계획된 워커마다
1707
+ `verification-error` 표로 기록된다(`_votes_for_finding`). 실행된 모든
1708
+ 라운드가 그것뿐이면 아무도 이 finding 을 들여다보지 않은 것이고, 그 상태를
1709
+ `contested` 로 적는 것은 없는 판정을 지어내는 일이다. gap 쪽에는 이미 같은
1710
+ 규칙이 있다(`prompts/lead/convergence.md` 의 "A gap that received no verdict
1711
+ is NOT a rejected gap").
1712
+
1713
+ 표가 한 장이라도 기록돼 있어야 한다. 라운드가 아예 없는 finding 은 Round 0
1714
+ 에서 이미 분류돼 큐를 떠난 쪽이 대부분이고, 최종 산출물에는 큐가 남지
1715
+ 않으므로 검증기가 그 둘을 구별할 수 없다. 구별할 수 없는 것을 판정하면
1716
+ 검증기와 엔진이 갈라진다.
1717
+ """
1718
+ rounds = finding.get("rounds")
1719
+ if not isinstance(rounds, list):
1720
+ return False
1721
+ seen = False
1722
+ for row in rounds:
1723
+ votes = row.get("votes") if isinstance(row, Mapping) else None
1724
+ if not isinstance(votes, Mapping):
1725
+ continue
1726
+ for vote in votes.values():
1727
+ if not isinstance(vote, Mapping):
1728
+ continue
1729
+ seen = True
1730
+ if vote.get("verdict") != "verification-error":
1731
+ return False
1732
+ return seen
1733
+
1734
+
1470
1735
  def _final_collaborative_classification(finding: Mapping[str, Any]) -> str:
1471
1736
  usable: list[Mapping[str, Any]] = []
1472
1737
  for round_row in finding.get("rounds", []):
@@ -1477,11 +1742,27 @@ def _final_collaborative_classification(finding: Mapping[str, Any]) -> str:
1477
1742
  return "partial-consensus" if usable and agreeing > len(usable) / 2 else "contested"
1478
1743
 
1479
1744
 
1745
+ def _round_settled_classification(rounds: list[Any]) -> str | None:
1746
+ """라운드 기록만으로 확정된 적대적 분류. finalize 가 찍은 값과 구별한다."""
1747
+ if not isinstance(rounds, list) or not rounds:
1748
+ return None
1749
+ try:
1750
+ return _classify_adversarial_history(rounds)
1751
+ except ConvergenceContractError:
1752
+ # 표 자체가 계약 위반이면 그 사실은 `_validate_finding_ledger` 가 이미
1753
+ # 별도 오류로 낸다. 여기서는 "라운드 안에서 확정되지 않았다"로만 읽는다.
1754
+ return None
1755
+
1756
+
1480
1757
  def _expected_final_classification(
1481
1758
  finding: Mapping[str, Any],
1482
1759
  adversarial: bool,
1760
+ *,
1761
+ historical: bool = False,
1483
1762
  ) -> str | None:
1484
1763
  rounds = finding.get("rounds")
1764
+ if not historical and _received_no_verdict(finding):
1765
+ return "unverified"
1485
1766
  if not isinstance(rounds, list) or not rounds:
1486
1767
  return None
1487
1768
  if adversarial:
@@ -1511,16 +1792,28 @@ def _validate_round_ledger_counts(
1511
1792
  if not isinstance(history_row, Mapping):
1512
1793
  continue
1513
1794
  ledgers = _round_ledgers(findings, round_number)
1514
- resolved = _resolved_ledger_count(findings, round_number, adversarial)
1515
- expected = (len(ledgers), resolved, len(ledgers) - resolved)
1795
+ # 두 엔진의 산술이 갈라지는 지점은 하나다 — `counter-evidence` 반증.
1796
+ # 지금은 그 라운드에서 큐를 떠나고(현), 구 엔진은 마지막 라운드까지
1797
+ # 끌고 갔다(구). 최종 산출물에는 큐가 남지 않아 어느 엔진이 썼는지
1798
+ # 구별할 수 없으므로 두 산술을 모두 받는다.
1799
+ accepted = {
1800
+ (len(ledgers), resolved, len(ledgers) - resolved)
1801
+ for resolved in {
1802
+ _resolved_ledger_count(findings, round_number, adversarial),
1803
+ _resolved_ledger_count(
1804
+ findings, round_number, adversarial, legacy=True
1805
+ ),
1806
+ }
1807
+ }
1516
1808
  actual = (
1517
1809
  history_row.get("inputQueueSize"),
1518
1810
  history_row.get("resolvedCount"),
1519
1811
  history_row.get("carriedForwardCount"),
1520
1812
  )
1521
- if actual != expected:
1813
+ if actual not in accepted:
1522
1814
  errors.append(
1523
- f"round {round_number} counters {actual} do not match finding ledgers {expected}"
1815
+ f"round {round_number} counters {actual} do not match finding "
1816
+ f"ledgers {sorted(accepted)}"
1524
1817
  )
1525
1818
  return errors
1526
1819
 
@@ -1548,7 +1841,10 @@ def _resolved_ledger_count(
1548
1841
  findings: list[Any],
1549
1842
  round_number: int,
1550
1843
  adversarial: bool,
1844
+ *,
1845
+ legacy: bool = False,
1551
1846
  ) -> int:
1847
+ """이 라운드에서 큐를 떠난 finding 수. `legacy` 는 구 엔진 규칙으로 센다."""
1552
1848
  resolved = 0
1553
1849
  for finding in findings:
1554
1850
  rounds = finding.get("rounds") if isinstance(finding, Mapping) else None
@@ -1569,11 +1865,12 @@ def _resolved_ledger_count(
1569
1865
  if not isinstance(votes, Mapping):
1570
1866
  continue
1571
1867
  try:
1572
- classification = (
1573
- _classify_adversarial_history(current_rounds)
1574
- if adversarial
1575
- else classify_collaborative_round(votes)
1576
- )
1868
+ if not adversarial:
1869
+ classification = classify_collaborative_round(votes)
1870
+ elif legacy:
1871
+ classification = _legacy_adversarial_history(current_rounds)
1872
+ else:
1873
+ classification = _classify_adversarial_history(current_rounds)
1577
1874
  except ConvergenceContractError:
1578
1875
  continue
1579
1876
  resolved += classification is not None
@@ -1671,7 +1968,10 @@ def _validate_no_reappearance_after_resolution(
1671
1968
  )
1672
1969
  except ConvergenceContractError:
1673
1970
  continue
1674
- if classification is not None:
1971
+ # 중간 라운드가 `contested` 로 읽히는 상태는 구 엔진만 만들 수 있다 —
1972
+ # 현 엔진은 그 라운드에서 큐를 떠나므로 뒤 라운드가 생기지 않고, 그렇게
1973
+ # 종결된 finding 은 `contested` 라 애초에 이 검사를 받지 않는다.
1974
+ if classification is not None and classification != "contested":
1675
1975
  return [
1676
1976
  f"finding {finding.get('findingId')} reappears after resolution in round {index + 1}"
1677
1977
  ]
@@ -2133,9 +2433,21 @@ def _parse_groups(
2133
2433
  value: Any,
2134
2434
  analysis_workers: list[str],
2135
2435
  finding_sources: list[str],
2136
- *,
2137
- adversarial: bool,
2138
2436
  ) -> tuple[list[dict[str, Any]], list[str]]:
2437
+ """Round 0 을 분류하고 검증 큐를 만든다.
2438
+
2439
+ 출처 role execution 이 둘 이상인 발견은 두 모드 모두 `full-consensus` 로
2440
+ 확정한다. 독립적인 공동 도출이 이미 교차검증이기 때문이다 — 적대적 모드의
2441
+ 입증 책임은 단일 출처 주장을 겨냥한다. 두 역할이 각자 도출한 발견을 큐에
2442
+ 넣으면 검증자는 새 정보 없이 표를 다시 던지고 라운드만 소모한다.
2443
+
2444
+ `source_workers` 의 길이가 곧 서로 다른 role execution 의 수다.
2445
+ `_parse_group` 이 `analysis_workers` 를 훑어 만들고 그 목록은 workerId 가
2446
+ 유일하며(`_parse_workers` 의 duplicate 검사), workerId 와
2447
+ `sourceRoleExecutionRef` 는 1:1 이다
2448
+ (`_validate_worker_execution_identity` 의 duplicate 검사). 한 워커가 두 번
2449
+ 세어질 경로가 없다.
2450
+ """
2139
2451
  if not isinstance(value, list):
2140
2452
  raise ConvergenceContractError("groups must be an array")
2141
2453
  findings: list[dict[str, Any]] = []
@@ -2150,7 +2462,7 @@ def _parse_groups(
2150
2462
  finding, source_workers = _parse_group(
2151
2463
  group, index, analysis_workers, finding_sources
2152
2464
  )
2153
- if len(source_workers) >= 2 and not adversarial:
2465
+ if len(source_workers) >= 2:
2154
2466
  finding["classification"] = "full-consensus"
2155
2467
  else:
2156
2468
  queue.append(finding_id)