okstra 0.179.2 → 0.183.2

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 (258) hide show
  1. package/README.md +1 -1
  2. package/dist/cli-registry.mjs +30 -2
  3. package/dist/cli-registry.mjs.map +1 -1
  4. package/dist/commands/execute/incremental-carry.mjs +9 -8
  5. package/dist/commands/execute/incremental-carry.mjs.map +1 -1
  6. package/dist/commands/execute/plan-verify.mjs +3 -1
  7. package/dist/commands/execute/plan-verify.mjs.map +1 -1
  8. package/dist/commands/execute/render-bundle.d.mts +4 -2
  9. package/dist/commands/execute/render-bundle.mjs +46 -5
  10. package/dist/commands/execute/render-bundle.mjs.map +1 -1
  11. package/dist/commands/execute/run.mjs +11 -3
  12. package/dist/commands/execute/run.mjs.map +1 -1
  13. package/dist/commands/inspect/model-io.d.mts +1 -0
  14. package/dist/commands/inspect/model-io.mjs +25 -0
  15. package/dist/commands/inspect/model-io.mjs.map +1 -0
  16. package/dist/commands/inspect/stage-map.mjs +29 -8
  17. package/dist/commands/inspect/stage-map.mjs.map +1 -1
  18. package/dist/commands/inspect/task-list.mjs +52 -6
  19. package/dist/commands/inspect/task-list.mjs.map +1 -1
  20. package/dist/commands/inspect/user-response.mjs +14 -4
  21. package/dist/commands/inspect/user-response.mjs.map +1 -1
  22. package/dist/commands/lifecycle/check-project.d.mts +1 -0
  23. package/dist/commands/lifecycle/check-project.mjs +69 -50
  24. package/dist/commands/lifecycle/check-project.mjs.map +1 -1
  25. package/dist/commands/lifecycle/contract-check.d.mts +1 -0
  26. package/dist/commands/lifecycle/contract-check.mjs +18 -0
  27. package/dist/commands/lifecycle/contract-check.mjs.map +1 -0
  28. package/dist/commands/lifecycle/preflight.mjs +154 -51
  29. package/dist/commands/lifecycle/preflight.mjs.map +1 -1
  30. package/dist/commands/pr/pr.d.mts +1 -0
  31. package/dist/commands/pr/pr.mjs +19 -1
  32. package/dist/commands/pr/pr.mjs.map +1 -1
  33. package/dist/commands/report/agent-activity.mjs +2 -2
  34. package/dist/commands/report/approval-decision.d.mts +1 -0
  35. package/dist/commands/report/approval-decision.mjs +21 -0
  36. package/dist/commands/report/approval-decision.mjs.map +1 -0
  37. package/dist/commands/report/design-snapshot.d.mts +1 -0
  38. package/dist/commands/report/design-snapshot.mjs +19 -0
  39. package/dist/commands/report/design-snapshot.mjs.map +1 -0
  40. package/dist/commands/report/translate.mjs +3 -0
  41. package/dist/commands/report/translate.mjs.map +1 -1
  42. package/dist/lib/host-registry-client.mjs +13 -9
  43. package/dist/lib/host-registry-client.mjs.map +1 -1
  44. package/docs/architecture/storage-model.md +1 -1
  45. package/docs/architecture.md +21 -10
  46. package/docs/cli.md +36 -20
  47. package/docs/container.md +6 -4
  48. package/docs/contributor-change-matrix.md +1 -1
  49. package/docs/for-ai/README.md +2 -2
  50. package/docs/for-ai/skills/okstra-brief-gen.md +5 -3
  51. package/docs/for-ai/skills/okstra-code-review.md +4 -4
  52. package/docs/for-ai/skills/okstra-container-build.md +20 -17
  53. package/docs/for-ai/skills/okstra-inspect.md +20 -23
  54. package/docs/for-ai/skills/okstra-manager.md +19 -18
  55. package/docs/for-ai/skills/okstra-memory.md +2 -2
  56. package/docs/for-ai/skills/okstra-pr-gen.md +3 -3
  57. package/docs/for-ai/skills/okstra-rollup.md +14 -13
  58. package/docs/for-ai/skills/okstra-run.md +7 -3
  59. package/docs/for-ai/skills/okstra-schedule-gen.md +15 -18
  60. package/docs/for-ai/skills/okstra-setup.md +7 -7
  61. package/docs/for-ai/skills/okstra-usage.md +5 -4
  62. package/docs/for-ai/skills/okstra-user-response.md +50 -32
  63. package/docs/project-structure-overview.md +43 -31
  64. package/docs/task-process/implementation-planning.md +2 -2
  65. package/docs/task-process/implementation.md +7 -4
  66. package/package.json +1 -1
  67. package/runtime/BUILD.json +2 -2
  68. package/runtime/agents/workers/claude-worker.md +4 -4
  69. package/runtime/agents/workers/report-writer-worker.md +15 -164
  70. package/runtime/agents/workers/translator-worker.md +5 -13
  71. package/runtime/bin/okstra-error-log.py +51 -11
  72. package/runtime/bin/okstra-report-translate.py +210 -23
  73. package/runtime/prompts/host-orchestration/implementation.md +1 -1
  74. package/runtime/prompts/launch.template.md +9 -12
  75. package/runtime/prompts/lead/adapters/cmux.md +1 -1
  76. package/runtime/prompts/lead/context-loader.md +41 -141
  77. package/runtime/prompts/lead/convergence.md +10 -8
  78. package/runtime/prompts/lead/okstra-lead-contract.md +40 -49
  79. package/runtime/prompts/lead/plan-body-verification.md +217 -23
  80. package/runtime/prompts/lead/report-writer.md +81 -421
  81. package/runtime/prompts/lead/team-contract.md +9 -54
  82. package/runtime/prompts/profiles/_clarification-recommendation.md +5 -4
  83. package/runtime/prompts/profiles/_coding-conventions-preflight.md +3 -2
  84. package/runtime/prompts/profiles/_common-contract.md +4 -4
  85. package/runtime/prompts/profiles/_implementation-deliverable.md +1 -1
  86. package/runtime/prompts/profiles/_implementation-diff-review.md +1 -1
  87. package/runtime/prompts/profiles/_implementation-verifier.md +3 -3
  88. package/runtime/prompts/profiles/change-impact-analysis.md +1 -1
  89. package/runtime/prompts/profiles/error-analysis.md +1 -1
  90. package/runtime/prompts/profiles/feature-analysis.md +1 -1
  91. package/runtime/prompts/profiles/final-verification.md +1 -1
  92. package/runtime/prompts/profiles/implementation-planning.md +20 -12
  93. package/runtime/prompts/profiles/improvement-discovery.md +1 -1
  94. package/runtime/prompts/profiles/project-analysis.md +1 -1
  95. package/runtime/prompts/profiles/release-handoff.md +0 -1
  96. package/runtime/prompts/profiles/requirements-discovery.md +1 -1
  97. package/runtime/prompts/wizard/prompts.ko.json +0 -1
  98. package/runtime/python/okstra_ctl/adapters/hosts/capability_adapter.py +69 -17
  99. package/runtime/python/okstra_ctl/adapters/hosts/claude-code/adapter.py +13 -4
  100. package/runtime/python/okstra_ctl/adapters/hosts/claude-code/relay.md +8 -2
  101. package/runtime/python/okstra_ctl/adapters/hosts/codex/adapter.py +2 -2
  102. package/runtime/python/okstra_ctl/adapters/hosts/codex/relay.md +50 -5
  103. package/runtime/python/okstra_ctl/adapters/hosts/external/relay.md +1 -1
  104. package/runtime/python/okstra_ctl/adapters/hosts/grok/adapter.py +2 -2
  105. package/runtime/python/okstra_ctl/adapters/hosts/grok/relay.md +66 -5
  106. package/runtime/python/okstra_ctl/adapters/providers/grok/adapter.py +70 -2
  107. package/runtime/python/okstra_ctl/agent_activity.py +141 -38
  108. package/runtime/python/okstra_ctl/agent_invocation.py +19 -6
  109. package/runtime/python/okstra_ctl/agent_prompt_cli.py +70 -23
  110. package/runtime/python/okstra_ctl/analysis_inputs.py +5 -4
  111. package/runtime/python/okstra_ctl/analysis_packet.py +124 -3
  112. package/runtime/python/okstra_ctl/approval_decisions.py +328 -0
  113. package/runtime/python/okstra_ctl/attempt_evidence.py +2 -2
  114. package/runtime/python/okstra_ctl/backfill.py +13 -10
  115. package/runtime/python/okstra_ctl/batch.py +2 -4
  116. package/runtime/python/okstra_ctl/build_tools.py +6 -3
  117. package/runtime/python/okstra_ctl/claim_reproduction.py +101 -0
  118. package/runtime/python/okstra_ctl/clarification_items.py +27 -13
  119. package/runtime/python/okstra_ctl/cmux.py +130 -52
  120. package/runtime/python/okstra_ctl/code_review_target.py +34 -8
  121. package/runtime/python/okstra_ctl/consumers.py +5 -4
  122. package/runtime/python/okstra_ctl/container.py +103 -8
  123. package/runtime/python/okstra_ctl/context_cost.py +2 -1
  124. package/runtime/python/okstra_ctl/contract_graph.py +497 -0
  125. package/runtime/python/okstra_ctl/contract_graph_cli.py +62 -0
  126. package/runtime/python/okstra_ctl/convergence.py +338 -17
  127. package/runtime/python/okstra_ctl/convergence_engine.py +10 -18
  128. package/runtime/python/okstra_ctl/convergence_provenance.py +58 -8
  129. package/runtime/python/okstra_ctl/convergence_store.py +55 -34
  130. package/runtime/python/okstra_ctl/design_prep.py +7 -4
  131. package/runtime/python/okstra_ctl/design_snapshot.py +134 -0
  132. package/runtime/python/okstra_ctl/dispatch_core.py +97 -69
  133. package/runtime/python/okstra_ctl/dispatch_state.py +163 -63
  134. package/runtime/python/okstra_ctl/doctor.py +6 -3
  135. package/runtime/python/okstra_ctl/domain/worker_presentation.py +70 -9
  136. package/runtime/python/okstra_ctl/entrypoints/hosts.py +16 -30
  137. package/runtime/python/okstra_ctl/error_log_write.py +35 -30
  138. package/runtime/python/okstra_ctl/error_report.py +26 -1
  139. package/runtime/python/okstra_ctl/error_zip.py +27 -5
  140. package/runtime/python/okstra_ctl/execution_identity.py +3 -2
  141. package/runtime/python/okstra_ctl/execution_manifest.py +7 -4
  142. package/runtime/python/okstra_ctl/execution_mutation_audit.py +6 -2
  143. package/runtime/python/okstra_ctl/final_report_schema.py +26 -17
  144. package/runtime/python/okstra_ctl/fix_cycles.py +2 -2
  145. package/runtime/python/okstra_ctl/fixed_text.py +39 -0
  146. package/runtime/python/okstra_ctl/git_reconcile.py +41 -9
  147. package/runtime/python/okstra_ctl/handoff.py +5 -4
  148. package/runtime/python/okstra_ctl/i18n.py +4 -2
  149. package/runtime/python/okstra_ctl/implementation_direction.py +22 -14
  150. package/runtime/python/okstra_ctl/implementation_outcome.py +4 -7
  151. package/runtime/python/okstra_ctl/incremental_carry.py +130 -17
  152. package/runtime/python/okstra_ctl/incremental_scope.py +12 -4
  153. package/runtime/python/okstra_ctl/index.py +8 -11
  154. package/runtime/python/okstra_ctl/initial_prompt_materialization.py +79 -7
  155. package/runtime/python/okstra_ctl/invocation.py +3 -6
  156. package/runtime/python/okstra_ctl/json_boundary.py +366 -0
  157. package/runtime/python/okstra_ctl/json_registry.py +10 -12
  158. package/runtime/python/okstra_ctl/jsonl.py +19 -2
  159. package/runtime/python/okstra_ctl/lead_events.py +33 -1
  160. package/runtime/python/okstra_ctl/listing.py +3 -3
  161. package/runtime/python/okstra_ctl/log_report.py +24 -2
  162. package/runtime/python/okstra_ctl/manager_cli.py +92 -7
  163. package/runtime/python/okstra_ctl/manager_store.py +12 -10
  164. package/runtime/python/okstra_ctl/material.py +5 -1
  165. package/runtime/python/okstra_ctl/migrate.py +29 -25
  166. package/runtime/python/okstra_ctl/model_cli.py +3 -15
  167. package/runtime/python/okstra_ctl/model_io_cli.py +1051 -0
  168. package/runtime/python/okstra_ctl/mutation_probe.py +13 -4
  169. package/runtime/python/okstra_ctl/pane_reclaim.py +3 -2
  170. package/runtime/python/okstra_ctl/path_hints.py +12 -0
  171. package/runtime/python/okstra_ctl/paths.py +21 -0
  172. package/runtime/python/okstra_ctl/plan_items.py +115 -4
  173. package/runtime/python/okstra_ctl/plan_items_cli.py +626 -34
  174. package/runtime/python/okstra_ctl/ports/worker_dispatch.py +2 -1
  175. package/runtime/python/okstra_ctl/pr_template.py +3 -2
  176. package/runtime/python/okstra_ctl/project_meta.py +5 -7
  177. package/runtime/python/okstra_ctl/recap.py +5 -4
  178. package/runtime/python/okstra_ctl/reconcile.py +21 -27
  179. package/runtime/python/okstra_ctl/registry/host_discovery.py +3 -2
  180. package/runtime/python/okstra_ctl/registry/provider_registry.py +3 -2
  181. package/runtime/python/okstra_ctl/render.py +74 -16
  182. package/runtime/python/okstra_ctl/render_final_report.py +10 -8
  183. package/runtime/python/okstra_ctl/report_assembly.py +509 -0
  184. package/runtime/python/okstra_ctl/report_contract.py +2 -1
  185. package/runtime/python/okstra_ctl/report_finalize.py +67 -32
  186. package/runtime/python/okstra_ctl/report_html/render.py +3 -2
  187. package/runtime/python/okstra_ctl/report_inputs.py +72 -0
  188. package/runtime/python/okstra_ctl/report_language.py +3 -2
  189. package/runtime/python/okstra_ctl/report_markdown.py +81 -8
  190. package/runtime/python/okstra_ctl/report_narrative.py +351 -0
  191. package/runtime/python/okstra_ctl/report_projections.py +265 -0
  192. package/runtime/python/okstra_ctl/report_synthesis_packet.py +518 -0
  193. package/runtime/python/okstra_ctl/report_views.py +3 -2
  194. package/runtime/python/okstra_ctl/rollup.py +65 -4
  195. package/runtime/python/okstra_ctl/run.py +184 -65
  196. package/runtime/python/okstra_ctl/run_audit.py +3 -2
  197. package/runtime/python/okstra_ctl/run_context.py +6 -9
  198. package/runtime/python/okstra_ctl/run_index_row.py +2 -8
  199. package/runtime/python/okstra_ctl/schedule_semantics.py +5 -2
  200. package/runtime/python/okstra_ctl/schema_excerpt.py +15 -8
  201. package/runtime/python/okstra_ctl/session_transcript.py +27 -1
  202. package/runtime/python/okstra_ctl/set_work_status.py +64 -38
  203. package/runtime/python/okstra_ctl/stage_fix_carry.py +8 -6
  204. package/runtime/python/okstra_ctl/stage_ledger.py +132 -18
  205. package/runtime/python/okstra_ctl/stage_map.py +96 -28
  206. package/runtime/python/okstra_ctl/stage_targets.py +3 -4
  207. package/runtime/python/okstra_ctl/team.py +3 -2
  208. package/runtime/python/okstra_ctl/team_reconcile.py +11 -2
  209. package/runtime/python/okstra_ctl/time_report.py +51 -4
  210. package/runtime/python/okstra_ctl/usage_identity.py +2 -1
  211. package/runtime/python/okstra_ctl/usage_report.py +58 -4
  212. package/runtime/python/okstra_ctl/user_response.py +1431 -66
  213. package/runtime/python/okstra_ctl/wizard.py +11 -44
  214. package/runtime/python/okstra_ctl/work_categories.py +3 -2
  215. package/runtime/python/okstra_ctl/worker_dispatch.py +5 -2
  216. package/runtime/python/okstra_ctl/worker_prompt_body.py +49 -3
  217. package/runtime/python/okstra_ctl/worker_prompt_contract.py +3 -2
  218. package/runtime/python/okstra_ctl/worker_prompt_policy.py +31 -3
  219. package/runtime/python/okstra_ctl/worker_runner.py +14 -12
  220. package/runtime/python/okstra_ctl/workflow.py +2 -1
  221. package/runtime/python/okstra_ctl/worktree.py +3 -2
  222. package/runtime/python/okstra_ctl/wrapper_status.py +4 -2
  223. package/runtime/python/okstra_ctl/write_policy.py +4 -2
  224. package/runtime/python/okstra_token_usage/antigravity.py +39 -12
  225. package/runtime/python/okstra_token_usage/collect.py +90 -38
  226. package/runtime/python/okstra_token_usage/grok.py +127 -0
  227. package/runtime/schemas/final-report-v2.0.schema.json +9 -0
  228. package/runtime/schemas/final-report-v3.0.schema.json +10219 -0
  229. package/runtime/schemas/report-narrative-v3.0.schema.json +30 -0
  230. package/runtime/schemas/report-synthesis-packet-v1.0.schema.json +140 -0
  231. package/runtime/skills/okstra-brief-gen/SKILL.md +9 -7
  232. package/runtime/skills/okstra-code-review/SKILL.md +21 -11
  233. package/runtime/skills/okstra-container-build/SKILL.md +18 -18
  234. package/runtime/skills/okstra-inspect/SKILL.md +12 -11
  235. package/runtime/skills/okstra-inspect/facets/error-zip.md +8 -8
  236. package/runtime/skills/okstra-inspect/facets/errors.md +2 -2
  237. package/runtime/skills/okstra-inspect/facets/history.md +9 -14
  238. package/runtime/skills/okstra-inspect/facets/logs.md +2 -2
  239. package/runtime/skills/okstra-inspect/facets/recap.md +5 -5
  240. package/runtime/skills/okstra-inspect/facets/report.md +6 -10
  241. package/runtime/skills/okstra-inspect/facets/status.md +9 -8
  242. package/runtime/skills/okstra-inspect/facets/time.md +3 -3
  243. package/runtime/skills/okstra-manager/SKILL.md +16 -14
  244. package/runtime/skills/okstra-memory/SKILL.md +3 -3
  245. package/runtime/skills/okstra-pr-gen/SKILL.md +5 -4
  246. package/runtime/skills/okstra-rollup/SKILL.md +6 -16
  247. package/runtime/skills/okstra-run/SKILL.md +7 -7
  248. package/runtime/skills/okstra-schedule-gen/SKILL.md +21 -17
  249. package/runtime/skills/okstra-setup/SKILL.md +21 -13
  250. package/runtime/skills/okstra-setup/references/project-config.md +2 -2
  251. package/runtime/skills/okstra-usage/SKILL.md +10 -10
  252. package/runtime/skills/okstra-user-response/SKILL.md +78 -107
  253. package/runtime/templates/report-writer-prompt-preamble.md +29 -19
  254. package/runtime/templates/reports/html/macros/forms.html +6 -4
  255. package/runtime/templates/reports/schedule.template.md +4 -4
  256. package/runtime/templates/worker-error-contract.md +17 -29
  257. package/runtime/validators/validate-run.py +636 -72
  258. package/runtime/validators/validate_session_conformance.py +43 -7
@@ -2890,7 +2890,7 @@ def validate_report(
2890
2890
  report_data: Mapping[str, Any] | None = None,
2891
2891
  team_state: Mapping[str, Any] | None = None,
2892
2892
  ) -> None:
2893
- if (report_data or {}).get("schemaVersion") == "2.0":
2893
+ if (report_data or {}).get("schemaVersion") in {"2.0", "3.0"}:
2894
2894
  _validate_v2_report(report_data or {}, failures, team_state=team_state)
2895
2895
  return
2896
2896
 
@@ -2997,6 +2997,9 @@ def validate_report(
2997
2997
 
2998
2998
 
2999
2999
  _REPORT_BASENAME_SEQ_RE = re.compile(r"-(?P<seq>\d{3})(?:\.data)?\.(?:md|json)$")
3000
+ _REPORT_BASENAME_TASK_TYPE_RE = re.compile(
3001
+ r"^final-report-(?P<task_type>[a-z][a-z-]*?)-\d{3}(?:\.data)?\.(?:md|json)$"
3002
+ )
3000
3003
 
3001
3004
 
3002
3005
  def _report_run_seq(report_path: Path) -> str | None:
@@ -3007,6 +3010,25 @@ def _report_run_seq(report_path: Path) -> str | None:
3007
3010
  return match.group("seq") if match else None
3008
3011
 
3009
3012
 
3013
+ def _report_task_type(report_path: Path) -> str:
3014
+ """This run's task type, read off `final-report-<task-type>-<seq>`.
3015
+
3016
+ The report's own `header.taskType` is the first source, but it is not
3017
+ always reachable. `report_narrative._allowed_top_level()` has no `header` —
3018
+ it is not a writer-owned block — so a gate scored from a narrative, which is
3019
+ the only input a report-contract-3.0 run has before assembly, carries no
3020
+ task type at all. Globbing with an empty one matched nothing and reported
3021
+ every verdict as unbacked under a `runs//worker-results/` path.
3022
+
3023
+ The filename carries it in every caller: the full-run path passes the report
3024
+ itself, and `_report_path_for_state` builds the same canonical name from the
3025
+ state file. Returns `""` when the name does not carry one, so a caller can
3026
+ tell "not resolvable" from a real task type.
3027
+ """
3028
+ match = _REPORT_BASENAME_TASK_TYPE_RE.match(report_path.name)
3029
+ return match.group("task_type") if match else ""
3030
+
3031
+
3010
3032
  def validate_worker_results_audit(
3011
3033
  report_path: Path,
3012
3034
  task_type: str,
@@ -3090,29 +3112,21 @@ PLAN_VERIFY_GATE_VALUES = (
3090
3112
  # the approved-frontmatter gate (the `.match` would otherwise return None).
3091
3113
  _FRONTMATTER_BLOCK_RE = re.compile(r"\A\ufeff?\s*---\n(.*?)\n---\n", re.DOTALL)
3092
3114
  def _validate_verdict_card_fields(data: dict, failures: list[str]) -> None:
3093
- """`verdictCard.direction` must byte-match its authoritative home in §7.
3094
-
3095
- The markdown check above compares only the Verdict Token, so a Card whose
3096
- Direction contradicted §7 shipped silently. `nextStep` is deliberately NOT
3097
- compared: every shipped fixture authors the Card's cell as the actionable
3098
- command (`/okstra-run task-type=release-handoff`) while §7 states the same
3099
- action as prose ("Proceed to release-handoff."), so a byte-match rule there
3100
- would reject the reference reports.
3101
- """
3115
+ """Verdict Card의 정본 표시 값이 §7의 값과 같은지 모두 검사한다."""
3102
3116
  card = data.get("verdictCard")
3103
3117
  final = data.get("finalVerdict")
3104
3118
  if not isinstance(card, dict) or not isinstance(final, dict):
3105
3119
  return
3106
- card_value = str(card.get("direction") or "").strip()
3107
- final_value = str(final.get("direction") or "").strip()
3108
- if not card_value or not final_value or card_value == final_value:
3109
- return
3110
- failures.append(
3111
- f"final-report data.json: verdictCard.direction `{card_value}` does not "
3112
- f"match finalVerdict.direction `{final_value}` — the Card is a "
3113
- "non-authoritative index and MUST byte-match §7 "
3114
- "(_common-contract.md Verdict Card)."
3115
- )
3120
+ for field in ("finalConclusion", "direction", "nextStep"):
3121
+ card_value = str(card.get(field) or "").strip()
3122
+ final_value = str(final.get(field) or "").strip()
3123
+ if not card_value or not final_value or card_value == final_value:
3124
+ continue
3125
+ failures.append(
3126
+ f"final-report data.json: verdictCard.{field} value `{card_value}` "
3127
+ f"does not match finalVerdict.{field} value `{final_value}` — the "
3128
+ "Card is a non-authoritative index and must preserve the §7 value."
3129
+ )
3116
3130
 
3117
3131
 
3118
3132
  def _route_target_matches(value: Any, target: str, *, command: bool) -> bool:
@@ -3585,8 +3599,12 @@ def validate_final_report_data(
3585
3599
  # namespaced audit ref (claude:F-005), or a §section reference (§5.4) — any one
3586
3600
  # satisfies "this claim is anchored". A bare brief/worker ID (RC-*/RF-*/F-*) is
3587
3601
  # handled separately by `_validate_no_opaque_id_references`.
3588
- _EVIDENCE_TOKEN = re.compile(
3589
- r"[\w./-]+\.\w+:\d+|\b[A-Z]{1,3}(?:-[a-z]+)?-\d+\b|§\s*\d")
3602
+ _PATH_LINE_EVIDENCE_TOKEN = re.compile(r"[\w./-]+\.\w+:\d+")
3603
+ _SECTION_EVIDENCE_TOKEN = re.compile(r"§\s*\d")
3604
+ _IN_REPORT_ID_TOKEN = re.compile(r"\b[A-Z]{1,3}(?:-[a-z]+)?-\d+\b")
3605
+ _NAMESPACED_EVIDENCE_TOKEN = re.compile(
3606
+ r"(?<![\w-])[a-z][a-z0-9-]*:[A-Z]{1,3}(?:-[a-z]+)?-\d+(?![\w-])"
3607
+ )
3590
3608
  # Explicit "I don't know" escapes — anti-fabrication's other valid answer.
3591
3609
  _INSUFFICIENCY_MARKERS = (
3592
3610
  "근거 불충분", "근거가 불충분", "증거 불충분", "증거가 불충분",
@@ -3596,6 +3614,44 @@ _INSUFFICIENCY_MARKERS = (
3596
3614
  _RATIONALE_FIELDS = ("motivation", "problem", "approach", "justification")
3597
3615
 
3598
3616
 
3617
+ def _report_reference_ids(data: Mapping[str, Any]) -> set[str]:
3618
+ references: set[str] = set()
3619
+
3620
+ def visit(value: Any, key: str = "") -> None:
3621
+ if isinstance(value, Mapping):
3622
+ for child_key, child_value in value.items():
3623
+ visit(child_value, str(child_key))
3624
+ return
3625
+ if isinstance(value, list):
3626
+ for child in value:
3627
+ visit(child, key)
3628
+ return
3629
+ if not isinstance(value, str):
3630
+ return
3631
+ if (key == "id" or key.endswith("Id")) and _IN_REPORT_ID_TOKEN.fullmatch(value):
3632
+ references.add(value)
3633
+ if key == "sourceItems":
3634
+ references.update(_NAMESPACED_EVIDENCE_TOKEN.findall(value))
3635
+
3636
+ visit(data)
3637
+ return references
3638
+
3639
+
3640
+ def _rationale_reference_status(
3641
+ text: str,
3642
+ known_references: set[str],
3643
+ ) -> tuple[bool, tuple[str, ...]]:
3644
+ if _PATH_LINE_EVIDENCE_TOKEN.search(text) or _SECTION_EVIDENCE_TOKEN.search(text):
3645
+ return True, ()
3646
+ namespaced = tuple(_NAMESPACED_EVIDENCE_TOKEN.findall(text))
3647
+ without_namespaced = _NAMESPACED_EVIDENCE_TOKEN.sub("", text)
3648
+ report_ids = tuple(_IN_REPORT_ID_TOKEN.findall(without_namespaced))
3649
+ candidates = (*namespaced, *report_ids)
3650
+ if any(candidate in known_references for candidate in candidates):
3651
+ return True, ()
3652
+ return False, tuple(dict.fromkeys(candidates))
3653
+
3654
+
3599
3655
  def _validate_rationale_evidence(data: dict, failures: list[str]) -> None:
3600
3656
  """Every `## 작업 배경과 근거` field must anchor its claim: carry at least
3601
3657
  one evidence reference (path:line, report ID, §section) OR an explicit
@@ -3605,14 +3661,25 @@ def _validate_rationale_evidence(data: dict, failures: list[str]) -> None:
3605
3661
  rationale = data.get("rationale")
3606
3662
  if not isinstance(rationale, dict):
3607
3663
  return # absence/shape is the schema's job; don't double-report.
3664
+ known_references = _report_reference_ids(data)
3608
3665
  for field in _RATIONALE_FIELDS:
3609
3666
  text = rationale.get(field)
3610
3667
  if not isinstance(text, str):
3611
3668
  continue
3612
- if _EVIDENCE_TOKEN.search(text):
3669
+ grounded, unknown_references = _rationale_reference_status(
3670
+ text,
3671
+ known_references,
3672
+ )
3673
+ if grounded:
3613
3674
  continue
3614
3675
  if any(m in text.lower() for m in _INSUFFICIENCY_MARKERS):
3615
3676
  continue
3677
+ if unknown_references:
3678
+ failures.append(
3679
+ f"final-report data.json: rationale.{field} cites unknown "
3680
+ "in-report reference(s): " + ", ".join(unknown_references) + "."
3681
+ )
3682
+ continue
3616
3683
  failures.append(
3617
3684
  f"final-report data.json: rationale.{field} cites no evidence "
3618
3685
  f"(expected a path:line, an in-report ID like C-001, or a §"
@@ -3787,6 +3854,42 @@ def _is_variation_point_item(item: dict) -> bool:
3787
3854
  return str(item.get("id") or "").upper().startswith("P-VAR")
3788
3855
 
3789
3856
 
3857
+ def _single_vote_dissents(item: dict, kinds: set[str]) -> list[dict]:
3858
+ """이 항목에서 1표 차단을 주장하는 DISAGREE 행들."""
3859
+ return [
3860
+ row for row in (item.get("verdicts") or [])
3861
+ if isinstance(row, dict)
3862
+ and str(row.get("verdict") or "").strip().upper() == "DISAGREE"
3863
+ and str(row.get("breakageKind") or "").strip().lower() in kinds
3864
+ ]
3865
+
3866
+
3867
+ def _single_vote_block_survives(item: dict, kinds: set[str]) -> bool:
3868
+ """1표 차단이 성립하는지.
3869
+
3870
+ 1표 차단에는 근거가 있다 — 명시된 두 인용이 서로 모순이라는 것은 한 명이
3871
+ 실측으로 확정할 수 있는 사실이고, 사실을 다수결로 기각하면 안 된다. 문제는
3872
+ 1표라는 것이 아니라 **1표에 재현 요구가 없었다**는 것이다. "이 경로는 존재하지
3873
+ 않는다" 라고 쓰기만 하면 그대로 차단이 됐다.
3874
+
3875
+ 이제 주장이 스스로 `fact` 를 선언하고 okstra 가 그것을 재현했을 때만 1표로
3876
+ 막는다. 선언했는데 재현되지 않았거나 `judgement` 였다면 정족수로 내려간다.
3877
+
3878
+ 아무 행도 `claimKind` 를 선언하지 않았으면 종전대로 막는다. 그 필드를 실을 수
3879
+ 없던 시절의 판정을 뒤에서 뒤집지 않기 위해서다 — 도입은 완화 방향으로만
3880
+ 작동하고, 선언한 주장만 재현을 요구받는다.
3881
+ """
3882
+ dissents = _single_vote_dissents(item, kinds)
3883
+ declared = [row for row in dissents if row.get("claimKind")]
3884
+ if not declared:
3885
+ return bool(dissents)
3886
+ return any(
3887
+ str(row.get("claimKind") or "") == "fact"
3888
+ and str(row.get("reproductionResult") or "") == "reproduced"
3889
+ for row in declared
3890
+ )
3891
+
3892
+
3790
3893
  def _classify_plan_item_gate(item: dict) -> str:
3791
3894
  """Recompute one plan item's gate class from its per-worker verdicts,
3792
3895
  per `prompts/lead/plan-body-verification.md` "Round protocol". Returns one of
@@ -3831,8 +3934,11 @@ def _classify_plan_item_gate(item: dict) -> str:
3831
3934
  # `a` for any item except `P-Var-*` (majority-gated, see
3832
3935
  # `_is_variation_point_item`); `f` only for P-Req items (requirement coverage).
3833
3936
  is_req = str(item.get("id") or "").upper().startswith("P-REQ")
3834
- if not _is_variation_point_item(item) and (
3835
- blocking_kinds & _SINGLE_VOTE_BLOCKING_KINDS or (is_req and "f" in blocking_kinds)
3937
+ single_vote_kinds = set(_SINGLE_VOTE_BLOCKING_KINDS) | ({"f"} if is_req else set())
3938
+ if (
3939
+ not _is_variation_point_item(item)
3940
+ and blocking_kinds & single_vote_kinds
3941
+ and _single_vote_block_survives(item, single_vote_kinds)
3836
3942
  ):
3837
3943
  # "One confirmed DISAGREE" presupposes the item was actually
3838
3944
  # cross-verified. When the peer returned a non-result nothing confirmed
@@ -3848,9 +3954,58 @@ def _classify_plan_item_gate(item: dict) -> str:
3848
3954
  # made the gate stricter than a healthy roster would.
3849
3955
  if len(non_error) >= 2 and len(blocking_disagree) > len(agree):
3850
3956
  return "majority-disagree"
3957
+ # A tie is not consensus, and until now it read as one. The majority test is
3958
+ # strict, so an even panel splitting 1-AGREE / 1-DISAGREE on a blocking kind
3959
+ # fell through to `has-dissent` and the gate passed — the dissent recorded
3960
+ # and never acted on. An even panel is not only the two-analyser roster: one
3961
+ # UNVERIFIABLE or one lost dispatch turns any roster even for that item.
3962
+ # Send the split back for a round; if it survives a round that judged the
3963
+ # rewritten text, nothing further is going to settle it and the user decides.
3964
+ # `_validate_unresolved_tie_was_reverified` is what makes the first branch
3965
+ # more than a label — `needs-reverify` folds into `passed-with-dissent`.
3966
+ if len(non_error) >= 2 and len(blocking_disagree) == len(agree):
3967
+ if _max_verdict_round(item) >= _TIE_SETTLED_ROUND:
3968
+ return "majority-disagree"
3969
+ return "needs-reverify"
3851
3970
  return "has-dissent"
3852
3971
 
3853
3972
 
3973
+ # 동수를 한 번 재검증한 뒤에도 갈리면 그때는 사용자가 판단한다. 초기 검증이
3974
+ # 라운드 1이고 자가수정 뒤의 표적 재검증이 라운드 2이므로, 라운드 2 이상의
3975
+ # 판정이 붙은 동수는 이미 한 번 돌아온 것이다.
3976
+ _TIE_SETTLED_ROUND = 2
3977
+
3978
+
3979
+ def _max_verdict_round(item: dict) -> int:
3980
+ """이 항목의 판정이 붙은 가장 늦은 라운드. 스탬프가 없으면 1.
3981
+
3982
+ `apply-verdicts --round <N>` 이 각 행을 찍는다. 스탬프가 없는 행은 자가수정이
3983
+ 한 번도 없었던 run 에서만 나오고, 그때는 라운드가 하나뿐이다.
3984
+ """
3985
+ rounds = [
3986
+ verdict["round"]
3987
+ for verdict in (item.get("verdicts") or [])
3988
+ if isinstance(verdict, dict)
3989
+ and isinstance(verdict.get("round"), int)
3990
+ and not isinstance(verdict.get("round"), bool)
3991
+ ]
3992
+ return max(rounds, default=1)
3993
+
3994
+
3995
+ def _is_unsettled_tie(item: dict) -> bool:
3996
+ """아직 재검증되지 않은 동수 항목."""
3997
+ return (
3998
+ _classify_plan_item_gate(item) == "needs-reverify"
3999
+ and _max_verdict_round(item) < _TIE_SETTLED_ROUND
4000
+ and len([
4001
+ verdict for verdict in (item.get("verdicts") or [])
4002
+ if isinstance(verdict, dict)
4003
+ and str(verdict.get("verdict") or "").strip().upper()
4004
+ not in ("", "VERIFICATION-ERROR")
4005
+ ]) >= 2
4006
+ )
4007
+
4008
+
3854
4009
  def _disagree_breakage_kinds(item: dict) -> set[str]:
3855
4010
  return {
3856
4011
  str(v.get("breakageKind") or "").strip().lower()
@@ -3980,6 +4135,103 @@ def _is_dissent_downgraded(
3980
4135
  )
3981
4136
 
3982
4137
 
4138
+ _STARTABLE_STAGE_STATUSES = frozenset({"ready", "active"})
4139
+
4140
+
4141
+ def _stage_scope_bucket(item: dict, pbv: dict) -> str:
4142
+ """Whether this item has standing to block the stage about to start.
4143
+
4144
+ The plan covers every stage; implementation runs one at a time. Judging all
4145
+ of them at once means a defect in a stage nobody has reached, or in one
4146
+ already frozen, stops the next stage from starting — and a frozen stage's
4147
+ item cannot be fixed at all, because the Stage Ledger forbids editing its
4148
+ commands. Measured on one run, 9 of 13 blockers were that shape, 6 of them
4149
+ frozen.
4150
+
4151
+ Returns `in-scope` (may block), `observed` (only frozen stages), or
4152
+ `deferred` (only stages not yet startable). Anything unresolvable is
4153
+ `in-scope`: an absent ledger is no basis to narrow, and an item with no
4154
+ `stageScope` belongs to the plan as a whole — `P-Opt-*` and `P-Var-*` live
4155
+ there permanently, and scoping them out would stop an unrequested-work
4156
+ verdict from blocking a start.
4157
+ """
4158
+ ledger = pbv.get("stageLedger")
4159
+ if not isinstance(ledger, dict) or not ledger:
4160
+ return "in-scope"
4161
+ scope = item.get("stageScope")
4162
+ stages = [
4163
+ value for value in scope
4164
+ if isinstance(value, int) and not isinstance(value, bool)
4165
+ ] if isinstance(scope, list) else []
4166
+ if not stages:
4167
+ return "in-scope"
4168
+ statuses = {str(ledger.get(str(stage)) or "") for stage in stages}
4169
+ if statuses & _STARTABLE_STAGE_STATUSES:
4170
+ return "in-scope"
4171
+ return "observed" if "done" in statuses else "deferred"
4172
+
4173
+
4174
+ def _set_aside_reason(item: dict, pbv: dict, accepted_item_ids: set[str]) -> str | None:
4175
+ """Why this item stopped blocking, or ``None`` if it never did.
4176
+
4177
+ A gate that passes while defects were set aside has to say which ones and on
4178
+ what grounds. Without that the two halves of the acceptance condition — the
4179
+ next stage can start, and the known risks are written down — collapse into
4180
+ the first, and a defect deferred for a good reason is indistinguishable in
4181
+ the record from one nobody found.
4182
+ """
4183
+ raw = (
4184
+ "has-dissent"
4185
+ if _is_dissent_downgraded(item, pbv, accepted_item_ids)
4186
+ else _classify_plan_item_gate(item)
4187
+ )
4188
+ if raw != "majority-disagree":
4189
+ return None
4190
+ bucket = _stage_scope_bucket(item, pbv)
4191
+ if bucket != "in-scope":
4192
+ return bucket
4193
+ return "record" if str(item.get("block") or "") == "record" else None
4194
+
4195
+
4196
+ def _set_aside_register(pbv: dict, accepted_item_ids: set[str]) -> list[dict]:
4197
+ """Every set-aside item, in id order, as the gate records them."""
4198
+ register = [
4199
+ {"id": str(item.get("id") or ""), "reason": reason}
4200
+ for item in (pbv.get("planItems") or [])
4201
+ if isinstance(item, dict)
4202
+ for reason in [_set_aside_reason(item, pbv, accepted_item_ids)]
4203
+ if reason is not None
4204
+ ]
4205
+ return sorted(register, key=lambda row: row["id"])
4206
+
4207
+
4208
+ def _plan_item_gate_class(
4209
+ item: dict, pbv: dict, accepted_item_ids: set[str],
4210
+ ) -> str:
4211
+ """The gate class for one item, after stage scope is applied.
4212
+
4213
+ An out-of-scope blocker is not dropped — it lands on `has-dissent`, so the
4214
+ gate still reads `passed-with-dissent` rather than `passed` and the record
4215
+ says something is outstanding. Silently scoring it `passed` would hide the
4216
+ defect instead of deferring it.
4217
+ """
4218
+ classification = (
4219
+ "has-dissent"
4220
+ if _is_dissent_downgraded(item, pbv, accepted_item_ids)
4221
+ else _classify_plan_item_gate(item)
4222
+ )
4223
+ if classification != "majority-disagree":
4224
+ return classification
4225
+ if _stage_scope_bucket(item, pbv) != "in-scope":
4226
+ return "has-dissent"
4227
+ if str(item.get("block") or "") == "record":
4228
+ # 자기 기록의 부정확은 기록되고 다음 run 의 입력이 되지, 구현 착수를 막지
4229
+ # 않는다. 요구사항이 실제로 안 만들어지는 경우는 이 경로가 아니라
4230
+ # `_independent_coverage_blockers` 의 `coverage-gap` 이 계속 막는다.
4231
+ return "has-dissent"
4232
+ return classification
4233
+
4234
+
3983
4235
  def _recompute_plan_body_gate(
3984
4236
  pbv: dict,
3985
4237
  accepted_item_ids: set[str] | None = None,
@@ -3989,9 +4241,7 @@ def _recompute_plan_body_gate(
3989
4241
  no plan items to judge (disabled / empty round)."""
3990
4242
  accepted = accepted_item_ids or set()
3991
4243
  classes = [
3992
- "has-dissent"
3993
- if _is_dissent_downgraded(it, pbv, accepted)
3994
- else _classify_plan_item_gate(it)
4244
+ _plan_item_gate_class(it, pbv, accepted)
3995
4245
  for it in (pbv.get("planItems") or [])
3996
4246
  if isinstance(it, dict)
3997
4247
  ]
@@ -4013,6 +4263,60 @@ def _recompute_plan_body_gate(
4013
4263
  return "passed"
4014
4264
 
4015
4265
 
4266
+ def _validate_set_aside_register(
4267
+ data: dict, failures: list[str], accepted_item_ids: set[str] | None = None,
4268
+ ) -> None:
4269
+ """A gate that set defects aside must say which ones and why.
4270
+
4271
+ The gate stops blocking on a defect that belongs to a frozen or unreached
4272
+ stage, or to the plan's own record. That is the point — but a reader of the
4273
+ report cannot tell such a defect from one that was never raised unless the
4274
+ run writes the register down. `gateBlockedBy` names what blocked; this names
4275
+ what did not, and why.
4276
+
4277
+ Declared against recomputed, the same shape as `_validate_gate_blocked_by`:
4278
+ a hand-written register drifts from the verdicts it claims to summarise, and
4279
+ the drift is invisible precisely because nothing else reads it.
4280
+ """
4281
+ ip = data.get("implementationPlanning")
4282
+ if not isinstance(ip, dict):
4283
+ return
4284
+ pbv = ip.get("planBodyVerification")
4285
+ if not isinstance(pbv, dict):
4286
+ return
4287
+ round_count = pbv.get("roundCount")
4288
+ if not isinstance(round_count, int) or round_count < 1:
4289
+ return
4290
+ accepted = (
4291
+ _resolved_noncritical_dissent_ids(data)
4292
+ if accepted_item_ids is None
4293
+ else accepted_item_ids
4294
+ )
4295
+ expected = _set_aside_register(pbv, accepted)
4296
+ declared_raw = pbv.get("setAside")
4297
+ declared = sorted(
4298
+ (
4299
+ {"id": str(row.get("id") or ""), "reason": str(row.get("reason") or "")}
4300
+ for row in declared_raw
4301
+ if isinstance(row, dict)
4302
+ ),
4303
+ key=lambda row: row["id"],
4304
+ ) if isinstance(declared_raw, list) else None
4305
+ if declared == expected:
4306
+ return
4307
+ if declared is None and not expected:
4308
+ return
4309
+ failures.append(
4310
+ "final-report data.json: planBodyVerification.setAside is "
4311
+ f"{declared!r} but the recorded verdicts set aside {expected!r}. The "
4312
+ "gate stopped blocking on those items — a frozen or unreached stage, or "
4313
+ "the plan's own record — and a run that passes without listing them "
4314
+ "leaves a deferred defect indistinguishable from one nobody raised. "
4315
+ "Re-record the round with `okstra plan-items complete-round`, which "
4316
+ "writes this register from the same computation."
4317
+ )
4318
+
4319
+
4016
4320
  def _validate_plan_body_gate_recompute(
4017
4321
  data: dict,
4018
4322
  failures: list[str],
@@ -4072,6 +4376,29 @@ def _blocks_approval(row: dict) -> bool:
4072
4376
  return status == "documented-deviation" and disposition.startswith("blocked C-")
4073
4377
 
4074
4378
 
4379
+ def _plan_item_clarification_ids(item: object) -> set[str]:
4380
+ """이 plan item 이 가리키는 `C-NNN` 들.
4381
+
4382
+ 계약 v3 에서 리포트 정본의 이 링크는 복수형 `clarificationRefs[]` 다 —
4383
+ `report_assembly` 가 활동 원장의 `clarificationRefs[]` + `planItemIds[]` 에서
4384
+ 유도해 쓰고, v3.0 스키마의 `planItems[]` 는 `additionalProperties: false` 아래
4385
+ 그 이름만 허용한다. 단수형 `clarificationId` 는 lead 가 쓰는 plan-body 상태
4386
+ 파일과 v2 리포트에 남아 있으므로 읽을 때는 둘 다 받는다
4387
+ (`incremental_scope` 가 이미 그렇게 한다).
4388
+ """
4389
+ if not isinstance(item, dict):
4390
+ return set()
4391
+ ids = {
4392
+ str(ref).strip()
4393
+ for ref in (item.get("clarificationRefs") or [])
4394
+ if str(ref).strip()
4395
+ }
4396
+ single = item.get("clarificationId")
4397
+ if isinstance(single, str) and single.strip():
4398
+ ids.add(single.strip())
4399
+ return ids
4400
+
4401
+
4075
4402
  def _plan_body_promoted_clarification_ids(pbv: dict) -> set[str]:
4076
4403
  """`C-NNN` ids this run's own plan-body round created by promoting a
4077
4404
  majority-disagree item (step 8). Used to break the Requirement Coverage
@@ -4079,11 +4406,9 @@ def _plan_body_promoted_clarification_ids(pbv: dict) -> set[str]:
4079
4406
  the gate already counted, rather than contributing an independent one.
4080
4407
  """
4081
4408
  return {
4082
- item["clarificationId"].strip()
4409
+ clarification_id
4083
4410
  for item in (pbv.get("planItems") or [])
4084
- if isinstance(item, dict)
4085
- and isinstance(item.get("clarificationId"), str)
4086
- and item["clarificationId"].strip()
4411
+ for clarification_id in _plan_item_clarification_ids(item)
4087
4412
  }
4088
4413
 
4089
4414
 
@@ -4536,6 +4861,8 @@ def _validate_approval_dispositions(
4536
4861
  row: dict,
4537
4862
  context: dict,
4538
4863
  failures: list[str],
4864
+ *,
4865
+ schema_version: str = "2.0",
4539
4866
  ) -> None:
4540
4867
  row_id = str(row.get("id") or "<unknown>")
4541
4868
  classification = str(context.get("classification") or "")
@@ -4546,7 +4873,11 @@ def _validate_approval_dispositions(
4546
4873
  for index, option in enumerate(row.get("options") or [])
4547
4874
  if isinstance(option, dict)
4548
4875
  )
4549
- resolution = context.get("resolution")
4876
+ resolution = (
4877
+ row.get("resolution")
4878
+ if schema_version == "3.0"
4879
+ else context.get("resolution")
4880
+ )
4550
4881
  if isinstance(resolution, dict):
4551
4882
  candidates.append(("resolution.disposition", resolution.get("disposition")))
4552
4883
  for field, disposition in candidates:
@@ -4562,11 +4893,17 @@ def _validate_resolved_approval(
4562
4893
  row: dict,
4563
4894
  context: dict,
4564
4895
  failures: list[str],
4896
+ *,
4897
+ schema_version: str = "2.0",
4565
4898
  ) -> None:
4566
4899
  if row.get("status") != "resolved":
4567
4900
  return
4568
4901
  row_id = str(row.get("id") or "<unknown>")
4569
- resolution = context.get("resolution")
4902
+ resolution = (
4903
+ row.get("resolution")
4904
+ if schema_version == "3.0"
4905
+ else context.get("resolution")
4906
+ )
4570
4907
  if not isinstance(resolution, dict):
4571
4908
  failures.append(
4572
4909
  f"final-report data.json: resolved approval clarification `{row_id}` "
@@ -4882,6 +5219,9 @@ def _validate_approval_context(
4882
5219
  ) -> None:
4883
5220
  if not _is_activity_contract_v1_planning(run_manifest):
4884
5221
  return
5222
+ if data.get("schemaVersion") == "3.0":
5223
+ _validate_v3_approval_context(data, failures)
5224
+ return
4885
5225
  ip = data.get("implementationPlanning") or {}
4886
5226
  pbv = ip.get("planBodyVerification") or {}
4887
5227
  plan_items_by_id = {
@@ -4991,6 +5331,81 @@ def _validate_approval_context(
4991
5331
  )
4992
5332
 
4993
5333
 
5334
+ def _v3_expected_plan_backlinks(
5335
+ activities: Mapping[str, dict],
5336
+ ) -> dict[str, set[str]]:
5337
+ expected: dict[str, set[str]] = {}
5338
+ for activity in activities.values():
5339
+ refs = {str(value) for value in activity.get("clarificationRefs") or []}
5340
+ for item_id in activity.get("planItemIds") or []:
5341
+ expected.setdefault(str(item_id), set()).update(refs)
5342
+ return expected
5343
+
5344
+
5345
+ def _validate_v3_plan_backlinks(
5346
+ data: dict, activities: Mapping[str, dict], failures: list[str],
5347
+ ) -> None:
5348
+ expected = _v3_expected_plan_backlinks(activities)
5349
+ planning = data.get("implementationPlanning") or {}
5350
+ verification = planning.get("planBodyVerification") or {}
5351
+ for item in verification.get("planItems") or []:
5352
+ if not isinstance(item, dict):
5353
+ continue
5354
+ item_id = str(item.get("id") or "")
5355
+ actual = {str(value) for value in item.get("clarificationRefs") or []}
5356
+ if actual != expected.get(item_id, set()):
5357
+ failures.append(
5358
+ f"final-report data.json: plan item `{item_id}` clarificationRefs "
5359
+ "do not match activity-ledger backlinks."
5360
+ )
5361
+
5362
+
5363
+ def _validate_v3_resolution_links(
5364
+ row: dict, activities: Mapping[str, dict], failures: list[str],
5365
+ ) -> None:
5366
+ resolution = row.get("resolution")
5367
+ if not isinstance(resolution, dict):
5368
+ return
5369
+ row_id = str(row.get("id") or "<unknown>")
5370
+ for activity_id in resolution.get("checkRefs") or []:
5371
+ activity = activities.get(activity_id)
5372
+ if activity is None:
5373
+ failures.append(
5374
+ f"final-report data.json: clarification `{row_id}` references "
5375
+ f"unknown activity `{activity_id}`."
5376
+ )
5377
+ elif row_id not in (activity.get("clarificationRefs") or []):
5378
+ failures.append(
5379
+ f"final-report data.json: activity `{activity_id}` does not "
5380
+ f"link back to clarification `{row_id}`."
5381
+ )
5382
+
5383
+
5384
+ def _validate_v3_approval_context(data: dict, failures: list[str]) -> None:
5385
+ """검증 가능한 원장 참조만으로 v3 승인 역추적을 다시 계산한다."""
5386
+ activities = _approval_activities_by_id(data)
5387
+ _validate_v3_plan_backlinks(data, activities, failures)
5388
+ approved = (data.get("frontmatter") or {}).get("approved") is True
5389
+ for row in data.get("clarificationItems") or []:
5390
+ if not isinstance(row, dict) or row.get("blocks") != "approval":
5391
+ continue
5392
+ context = row.get("approvalContext")
5393
+ if not isinstance(context, dict):
5394
+ continue
5395
+ _validate_approval_dispositions(
5396
+ row, context, failures, schema_version="3.0"
5397
+ )
5398
+ _validate_resolved_approval(
5399
+ row, context, failures, schema_version="3.0"
5400
+ )
5401
+ _validate_v3_resolution_links(row, activities, failures)
5402
+ if approved and row.get("status") in {"open", "answered"}:
5403
+ failures.append(
5404
+ f"final-report data.json: approval is true while clarification "
5405
+ f"`{row.get('id')}` remains `{row.get('status')}`."
5406
+ )
5407
+
5408
+
4994
5409
  def _validate_activity_contract_plan_limits(
4995
5410
  data: dict,
4996
5411
  run_manifest: dict,
@@ -5435,7 +5850,7 @@ def _validate_clarification_evidence_note(data: dict, failures: list[str]) -> No
5435
5850
  )
5436
5851
 
5437
5852
 
5438
- _CLARIFICATION_OPTION_SCHEMA_VERSION = "2.0"
5853
+ _CLARIFICATION_OPTION_SCHEMA_VERSIONS = frozenset({"2.0", "3.0"})
5439
5854
  # The four profiles that read `_clarification-recommendation.md`. Unlike the
5440
5855
  # evidence-note gate above — which is called from inside the
5441
5856
  # `implementation-planning` branch — this one is called phase-agnostically, so
@@ -5508,7 +5923,7 @@ def _validate_clarification_options(data: dict, failures: list[str]) -> None:
5508
5923
  demanding one would fail every v1 `decision` row for a structure the format
5509
5924
  has no place to hold.
5510
5925
  """
5511
- if data.get("schemaVersion") != _CLARIFICATION_OPTION_SCHEMA_VERSION:
5926
+ if data.get("schemaVersion") not in _CLARIFICATION_OPTION_SCHEMA_VERSIONS:
5512
5927
  return
5513
5928
  task_type = (data.get("header") or {}).get("taskType")
5514
5929
  if task_type not in _CLARIFICATION_OPTION_TASK_TYPES:
@@ -5517,7 +5932,10 @@ def _validate_clarification_options(data: dict, failures: list[str]) -> None:
5517
5932
  if not isinstance(row, dict) or row.get("kind") != "decision":
5518
5933
  continue
5519
5934
  row_id = str(row.get("id") or "<unknown>")
5520
- _validate_option_set(row.get("options"), row_id, failures)
5935
+ _validate_option_set(
5936
+ row.get("options"), row_id, failures,
5937
+ schema_version=str(data.get("schemaVersion") or ""),
5938
+ )
5521
5939
  if _LEGACY_EXPECTED_FORM_RE.search(str(row.get("expectedForm") or "")):
5522
5940
  failures.append(
5523
5941
  f"final-report data.json: clarification `{row_id}` still encodes "
@@ -5529,7 +5947,7 @@ def _validate_clarification_options(data: dict, failures: list[str]) -> None:
5529
5947
 
5530
5948
 
5531
5949
  def _validate_option_set(
5532
- options: object, row_id: str, failures: list[str]
5950
+ options: object, row_id: str, failures: list[str], *, schema_version: str = "2.0"
5533
5951
  ) -> None:
5534
5952
  """Check one `decision` row's `options[]` for pickability and reach."""
5535
5953
  if not isinstance(options, list) or len(options) < 2:
@@ -5550,7 +5968,9 @@ def _validate_option_set(
5550
5968
  for index, option in enumerate(entries):
5551
5969
  tokens = option.get("scopeImpact")
5552
5970
  reach = (
5553
- [token for token in tokens if token in _REACH_TOKENS]
5971
+ [str(option.get("reach"))]
5972
+ if schema_version == "3.0" and option.get("reach") in _REACH_TOKENS
5973
+ else [token for token in tokens if token in _REACH_TOKENS]
5554
5974
  if isinstance(tokens, list)
5555
5975
  else []
5556
5976
  )
@@ -5649,8 +6069,7 @@ def _has_clarification_backtrace(
5649
6069
  resolver cannot disagree about what counts as a link.
5650
6070
  """
5651
6071
  if isinstance(plan_items, list) and any(
5652
- isinstance(item, dict) and item.get("clarificationId") == row_id
5653
- for item in plan_items
6072
+ row_id in _plan_item_clarification_ids(item) for item in plan_items
5654
6073
  ):
5655
6074
  return True
5656
6075
  return isinstance(coverage, list) and any(
@@ -6134,6 +6553,7 @@ def _validate_plan_body_state_file(
6134
6553
  data: dict,
6135
6554
  report_path: Path,
6136
6555
  failures: list[str],
6556
+ state_path: Path | None = None,
6137
6557
  ) -> None:
6138
6558
  """The per-round state file must exist once a round has run.
6139
6559
 
@@ -6157,20 +6577,34 @@ def _validate_plan_body_state_file(
6157
6577
  round_count = pbv.get("roundCount")
6158
6578
  if not isinstance(round_count, int) or round_count < 1:
6159
6579
  return
6160
- seq = _report_run_seq(report_path)
6161
- if seq is None:
6162
- return
6163
6580
  state_dir = report_path.parent.parent / "state"
6164
- expected = state_dir / f"plan-body-verification-implementation-planning-{seq}.json"
6165
- if not expected.is_file():
6166
- failures.append(
6167
- f"plan-body verification ran ({round_count} round(s)) but "
6168
- f"`state/{expected.name}` was not written. It is the only record "
6169
- "of superseded rounds — data.json keeps just the final verdicts, "
6170
- "so without it a self-fixed run leaves no trace of what the "
6171
- 'earlier rounds found (plan-body-verification.md §"schema").'
6172
- )
6173
- return
6581
+ if state_path is not None:
6582
+ # 호출자가 경로를 넘겼으면 그걸 본다. 리드는 launch 프롬프트의
6583
+ # `Run Paths` 에서 정본 경로를 받으므로, 여기서 이름을 다시 만들면
6584
+ # 그 정본과 어긋날 수 있다 — 실제로 그랬다.
6585
+ written = [state_path] if state_path.is_file() else []
6586
+ else:
6587
+ # 이름을 유도할 근거가 없다. run 은 seq 계열을 둘 갖고(`state` /
6588
+ # `reports`) 리포트 정본은 자기 run 의 state seq 를 담지 않으므로,
6589
+ # 리포트 seq 로 만든 이름은 추측이다. 이 검사가 묻는 것은 "덮어써진
6590
+ # 라운드의 기록이 남았는가" 이지 파일 이름이 아니므로, 이 run 의 상태
6591
+ # 디렉터리에 사이드카가 있는지만 본다. 이름의 정본은 `paths.py` 다.
6592
+ written = sorted(
6593
+ state_dir.glob("plan-body-verification-implementation-planning-*.json")
6594
+ )
6595
+ if not written:
6596
+ failures.append(
6597
+ f"plan-body verification ran ({round_count} round(s)) but no "
6598
+ f"`state/plan-body-verification-*.json` was written. It is the only "
6599
+ "record of superseded rounds — data.json keeps just the final "
6600
+ "verdicts, so without it a self-fixed run leaves no trace of what "
6601
+ 'the earlier rounds found (plan-body-verification.md §"schema"). '
6602
+ "The path is rendered into the launch prompt's `Run Paths` block; "
6603
+ "write it there rather than deriving a name."
6604
+ )
6605
+ return
6606
+ # 여럿이면 가장 최신(seq 가 큰) 것이 이 run 의 것이다.
6607
+ expected = written[-1]
6174
6608
  try:
6175
6609
  state = json.loads(expected.read_text(encoding="utf-8"))
6176
6610
  except (OSError, json.JSONDecodeError) as exc:
@@ -6697,6 +7131,53 @@ def _validate_round_recorded_verdicts(data: dict, failures: list[str]) -> None:
6697
7131
  )
6698
7132
 
6699
7133
 
7134
+ def _validate_unresolved_tie_was_reverified(
7135
+ data: dict,
7136
+ failures: list[str],
7137
+ ) -> None:
7138
+ """A split panel is sent back once before the gate is declared.
7139
+
7140
+ The gate needs a strict majority to block, so a panel splitting evenly on a
7141
+ blocking kind reaches neither consensus nor `majority-disagree`. That state
7142
+ is classified `needs-reverify`, and `needs-reverify` folds into
7143
+ `passed-with-dissent` — which is correct for the shape it was built for (a
7144
+ peer that returned nothing) and wrong for this one: nothing failed here, two
7145
+ verifiers read the same plan and disagreed, and passing on that records a
7146
+ dissent nobody acted on.
7147
+
7148
+ So the round is not optional. Re-dispatch those items and record the votes
7149
+ with `--round 2`; a split that survives becomes `majority-disagree` and the
7150
+ user decides. This is satisfiable with the machinery the contract already
7151
+ defines — it is the same targeted re-verification step 7 runs after a
7152
+ self-fix, with the tied items added to that queue.
7153
+ """
7154
+ ip = data.get("implementationPlanning")
7155
+ if not isinstance(ip, dict):
7156
+ return
7157
+ pbv = ip.get("planBodyVerification")
7158
+ if not isinstance(pbv, dict):
7159
+ return
7160
+ unsettled = sorted({
7161
+ str(item.get("id") or "").strip()
7162
+ for item in pbv.get("planItems") or []
7163
+ if isinstance(item, dict)
7164
+ and not item.get("carriedForwardFromSeq")
7165
+ and _is_unsettled_tie(item)
7166
+ })
7167
+ if not unsettled:
7168
+ return
7169
+ failures.append(
7170
+ f"final-report data.json: plan item(s) {unsettled} carry an even split "
7171
+ "on a blocking breakage kind and were never re-verified. A tie is not "
7172
+ "consensus: the gate's majority test is strict, so this split neither "
7173
+ "blocks nor resolves, and declaring the gate on it passes a dissent "
7174
+ "nobody settled. Re-dispatch those items in a plan-body round and "
7175
+ "record the votes with `okstra plan-items apply-verdicts --data "
7176
+ "<data.json> --verdicts <verdicts.json> --round 2`. A split that "
7177
+ "survives that round becomes `majority-disagree` and goes to the user."
7178
+ )
7179
+
7180
+
6700
7181
  def _validate_verdict_rounds_outlive_self_fix(
6701
7182
  data: dict,
6702
7183
  failures: list[str],
@@ -6941,7 +7422,15 @@ def _validate_plan_body_verdict_provenance(
6941
7422
  if not voters:
6942
7423
  return
6943
7424
 
6944
- task_type = str((data.get("header") or {}).get("taskType") or "")
7425
+ task_type = (
7426
+ str((data.get("header") or {}).get("taskType") or "")
7427
+ or _report_task_type(report_path)
7428
+ )
7429
+ if not task_type:
7430
+ # Neither source names it, so the glob below would be built from an
7431
+ # empty segment and match nothing — reporting every verdict as unbacked
7432
+ # on the strength of a path this check could not construct.
7433
+ return
6945
7434
  dispatched = _plan_verify_result_workers(report_path, task_type)
6946
7435
  if dispatched is None:
6947
7436
  return
@@ -7609,15 +8098,20 @@ def _validate_plan_body_clarification_matching(
7609
8098
  if _is_dissent_downgraded(item, pbv, accepted):
7610
8099
  continue
7611
8100
  item_id = item.get("id") or "<unknown>"
7612
- cid = item.get("clarificationId")
7613
- if not cid:
8101
+ cids = _plan_item_clarification_ids(item)
8102
+ if not cids:
7614
8103
  failures.append(
7615
8104
  f"final-report data.json: plan item `{item_id}` is majority-disagree "
7616
- "but carries no `clarificationId`. A blocking disagreement MUST "
8105
+ "but carries no `clarificationRefs`. A blocking disagreement MUST "
7617
8106
  "surface as a `## 1. Clarification Items` row (blocks=approval) so "
7618
- "the user sees the blocker (implementation-planning.md self-review step 7)."
8107
+ "the user sees the blocker (implementation-planning.md self-review "
8108
+ "step 12). Report assembly derives this link from the activity "
8109
+ "ledger's `clarificationRefs[]` + `planItemIds[]`, so record the "
8110
+ "decision through `okstra approval-decision` rather than editing "
8111
+ "the report."
7619
8112
  )
7620
- elif cid not in approval_ids:
8113
+ continue
8114
+ for cid in sorted(cids - approval_ids):
7621
8115
  reason = (
7622
8116
  "references a non-existent §1 row"
7623
8117
  if cid not in all_ids
@@ -7625,8 +8119,9 @@ def _validate_plan_body_clarification_matching(
7625
8119
  )
7626
8120
  failures.append(
7627
8121
  f"final-report data.json: plan item `{item_id}` (majority-disagree) "
7628
- f"has clarificationId `{cid}` which {reason}. Every majority-disagree "
7629
- "item MUST map 1:1 to a `blocks: approval` Clarification row."
8122
+ f"has clarificationRefs entry `{cid}` which {reason}. Every "
8123
+ "majority-disagree item MUST reach a `blocks: approval` "
8124
+ "Clarification row."
7630
8125
  )
7631
8126
 
7632
8127
 
@@ -7793,7 +8288,9 @@ def validate_plan_body_section(
7793
8288
  _validate_aborted_gate_has_clarification(data, failures)
7794
8289
  _validate_round_recorded_verdicts(data, failures)
7795
8290
  _validate_verdicts_match_current_subjects(data, failures)
8291
+ _validate_set_aside_register(data, failures, accepted_item_ids)
7796
8292
  _validate_verdict_rounds_outlive_self_fix(data, failures)
8293
+ _validate_unresolved_tie_was_reverified(data, failures)
7797
8294
  _validate_plan_item_extraction_completeness(data, failures)
7798
8295
  _validate_plan_item_subject_substance(data, failures)
7799
8296
  _validate_plan_body_clarification_matching(data, failures, accepted_item_ids)
@@ -7808,16 +8305,16 @@ def _gate_summary_item(
7808
8305
  accepted_item_ids: set[str],
7809
8306
  ) -> dict:
7810
8307
  """One `gate.items[]` row: the gate class plus its state-file counterpart."""
7811
- classification = (
7812
- "has-dissent"
7813
- if _is_dissent_downgraded(item, pbv, accepted_item_ids)
7814
- else _classify_plan_item_gate(item)
7815
- )
8308
+ classification = _plan_item_gate_class(item, pbv, accepted_item_ids)
7816
8309
  return {
7817
8310
  "id": item.get("id"),
7818
8311
  "classification": classification,
7819
8312
  "stateClassification": _state_classification(item, classification),
7820
8313
  "correctnessCritical": _is_correctness_critical(item),
8314
+ # 왜 안 막는지가 기록에 남아야 한다. 이 값이 없으면 범위 밖 강등과
8315
+ # 실제 합의가 산출물에서 같은 모양으로 읽힌다.
8316
+ "stageScope": _stage_scope_bucket(item, pbv),
8317
+ "block": str(item.get("block") or "execution"),
7821
8318
  }
7822
8319
 
7823
8320
 
@@ -7858,6 +8355,7 @@ def plan_body_gate_summary(data: dict) -> dict | None:
7858
8355
  _gate_blocking_causes(pbv, coverage_blockers, accepted_item_ids)
7859
8356
  ),
7860
8357
  "coverageBlockers": coverage_blockers,
8358
+ "setAside": _set_aside_register(pbv, accepted_item_ids),
7861
8359
  "blockingItems": [
7862
8360
  item["id"] for item in items if item["classification"] == "majority-disagree"
7863
8361
  ],
@@ -9233,7 +9731,9 @@ SECTION_PLAN_BODY = "plan-body"
9233
9731
  _FULL_ONLY_REQUIRED_FLAGS = ("team_state", "run_manifest", "task_manifest")
9234
9732
 
9235
9733
 
9236
- def run_plan_body_section(report_path: Path) -> int:
9734
+ def run_plan_body_section(
9735
+ report_path: Path, state_path: Path | None = None
9736
+ ) -> int:
9237
9737
  """`--section plan-body` — the §5.5.9 checks a self-fix round can run on
9238
9738
  its own, plus the recomputed gate the lead records for that round.
9239
9739
 
@@ -9260,7 +9760,45 @@ def run_plan_body_section(report_path: Path) -> int:
9260
9760
  return 2
9261
9761
  failures = _data_schema_failures(data)
9262
9762
  warnings = validate_plan_body_section(data, report_path, failures)
9263
- _validate_plan_body_state_file(data, report_path, failures)
9763
+ _validate_plan_body_state_file(data, report_path, failures, state_path)
9764
+ payload = {
9765
+ "ok": not failures,
9766
+ "section": SECTION_PLAN_BODY,
9767
+ "gate": plan_body_gate_summary(data),
9768
+ "failures": failures,
9769
+ "warnings": warnings,
9770
+ }
9771
+ print(json.dumps(payload, ensure_ascii=False, indent=2))
9772
+ return 0 if not failures else 2
9773
+
9774
+
9775
+ def run_plan_body_inputs(narrative_path: Path, state_path: Path) -> int:
9776
+ """게시 전 서사와 수렴 소유 상태에서 같은 계획 게이트를 채점한다."""
9777
+ from okstra_ctl.report_narrative import parse_narrative
9778
+ from okstra_ctl.final_report_schema import load_schema_version
9779
+
9780
+ try:
9781
+ narrative = parse_narrative(
9782
+ narrative_path.read_text(encoding="utf-8"),
9783
+ load_schema_version("3.0"),
9784
+ )
9785
+ state = json.loads(state_path.read_text(encoding="utf-8"))
9786
+ except (OSError, UnicodeError, ValueError) as exc:
9787
+ print(f"validate-run: plan-body inputs are invalid ({exc})", file=sys.stderr)
9788
+ return 2
9789
+ state_owner = state.get("owner") if isinstance(state, dict) else None
9790
+ verification = state.get("planBodyVerification") if isinstance(state, dict) else None
9791
+ planning = narrative.get("implementationPlanning")
9792
+ if state_owner != "convergence" or not isinstance(verification, dict):
9793
+ print("validate-run: plan-body state must be convergence-owned", file=sys.stderr)
9794
+ return 2
9795
+ if not isinstance(planning, dict):
9796
+ print("validate-run: narrative has no implementationPlanning", file=sys.stderr)
9797
+ return 2
9798
+ data = {**narrative, "schemaVersion": "3.0"}
9799
+ data["implementationPlanning"] = {**planning, "planBodyVerification": verification}
9800
+ failures: list[str] = []
9801
+ warnings = validate_plan_body_section(data, _report_path_for_state(state_path), failures)
9264
9802
  payload = {
9265
9803
  "ok": not failures,
9266
9804
  "section": SECTION_PLAN_BODY,
@@ -9272,6 +9810,14 @@ def run_plan_body_section(report_path: Path) -> int:
9272
9810
  return 0 if not failures else 2
9273
9811
 
9274
9812
 
9813
+ def _report_path_for_state(state_path: Path) -> Path:
9814
+ match = re.search(r"-(\d{3})\.json$", state_path.name)
9815
+ seq = match.group(1) if match else "001"
9816
+ return state_path.parent.parent / "reports" / (
9817
+ f"final-report-implementation-planning-{seq}.data.json"
9818
+ )
9819
+
9820
+
9275
9821
  def _data_schema_failures(data: dict) -> list[str]:
9276
9822
  """Schema errors in the round's data.json, as failures.
9277
9823
 
@@ -9315,9 +9861,11 @@ def main() -> int:
9315
9861
  )
9316
9862
  parser.add_argument(
9317
9863
  "--report",
9318
- required=True,
9864
+ required=False,
9319
9865
  help="Project-relative or absolute path to the report record (.data.json). Schema-v1 reports still use the Markdown file.",
9320
9866
  )
9867
+ parser.add_argument("--narrative", required=False)
9868
+ parser.add_argument("--state", required=False)
9321
9869
  parser.add_argument(
9322
9870
  "--run-manifest",
9323
9871
  required=False,
@@ -9343,7 +9891,18 @@ def main() -> int:
9343
9891
  args = parser.parse_args()
9344
9892
 
9345
9893
  if args.section == SECTION_PLAN_BODY:
9346
- return run_plan_body_section(Path(args.report).resolve())
9894
+ if args.narrative and args.state and not args.report:
9895
+ return run_plan_body_inputs(
9896
+ Path(args.narrative).resolve(), Path(args.state).resolve()
9897
+ )
9898
+ if args.report and not args.narrative and not args.state:
9899
+ return run_plan_body_section(
9900
+ Path(args.report).resolve(),
9901
+ Path(args.state).resolve() if args.state else None,
9902
+ )
9903
+ parser.error(
9904
+ "--section plan-body requires either --report or both --narrative and --state"
9905
+ )
9347
9906
 
9348
9907
  missing = [
9349
9908
  f"--{flag.replace('_', '-')}"
@@ -9550,7 +10109,12 @@ def main() -> int:
9550
10109
  validation_data, project_root, failures
9551
10110
  )
9552
10111
  if task_type == "implementation-planning":
9553
- _validate_plan_body_state_file(validation_data, report_path, failures)
10112
+ _validate_plan_body_state_file(
10113
+ validation_data,
10114
+ report_path,
10115
+ failures,
10116
+ Path(args.state).resolve() if args.state else None,
10117
+ )
9554
10118
  if task_type == "final-verification":
9555
10119
  _validate_verification_target_match(
9556
10120
  validation_data,