okstra 0.180.0 → 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 (223) hide show
  1. package/dist/cli-registry.mjs +16 -2
  2. package/dist/cli-registry.mjs.map +1 -1
  3. package/dist/commands/execute/render-bundle.d.mts +4 -2
  4. package/dist/commands/execute/render-bundle.mjs +46 -5
  5. package/dist/commands/execute/render-bundle.mjs.map +1 -1
  6. package/dist/commands/execute/run.mjs +11 -3
  7. package/dist/commands/execute/run.mjs.map +1 -1
  8. package/dist/commands/inspect/model-io.d.mts +1 -0
  9. package/dist/commands/inspect/model-io.mjs +25 -0
  10. package/dist/commands/inspect/model-io.mjs.map +1 -0
  11. package/dist/commands/inspect/stage-map.mjs +29 -8
  12. package/dist/commands/inspect/stage-map.mjs.map +1 -1
  13. package/dist/commands/inspect/task-list.mjs +52 -6
  14. package/dist/commands/inspect/task-list.mjs.map +1 -1
  15. package/dist/commands/inspect/user-response.mjs +14 -4
  16. package/dist/commands/inspect/user-response.mjs.map +1 -1
  17. package/dist/commands/lifecycle/check-project.d.mts +1 -0
  18. package/dist/commands/lifecycle/check-project.mjs +69 -50
  19. package/dist/commands/lifecycle/check-project.mjs.map +1 -1
  20. package/dist/commands/lifecycle/contract-check.d.mts +1 -0
  21. package/dist/commands/lifecycle/contract-check.mjs +18 -0
  22. package/dist/commands/lifecycle/contract-check.mjs.map +1 -0
  23. package/dist/commands/lifecycle/preflight.mjs +154 -51
  24. package/dist/commands/lifecycle/preflight.mjs.map +1 -1
  25. package/dist/commands/pr/pr.d.mts +1 -0
  26. package/dist/commands/pr/pr.mjs +19 -1
  27. package/dist/commands/pr/pr.mjs.map +1 -1
  28. package/dist/commands/report/agent-activity.mjs +2 -2
  29. package/dist/commands/report/translate.mjs +3 -0
  30. package/dist/commands/report/translate.mjs.map +1 -1
  31. package/dist/lib/host-registry-client.mjs +13 -9
  32. package/dist/lib/host-registry-client.mjs.map +1 -1
  33. package/docs/architecture.md +11 -0
  34. package/docs/cli.md +26 -13
  35. package/docs/container.md +6 -4
  36. package/docs/contributor-change-matrix.md +1 -1
  37. package/docs/for-ai/README.md +2 -2
  38. package/docs/for-ai/skills/okstra-brief-gen.md +5 -3
  39. package/docs/for-ai/skills/okstra-code-review.md +4 -4
  40. package/docs/for-ai/skills/okstra-container-build.md +20 -17
  41. package/docs/for-ai/skills/okstra-inspect.md +20 -23
  42. package/docs/for-ai/skills/okstra-manager.md +19 -18
  43. package/docs/for-ai/skills/okstra-memory.md +2 -2
  44. package/docs/for-ai/skills/okstra-pr-gen.md +3 -3
  45. package/docs/for-ai/skills/okstra-rollup.md +14 -13
  46. package/docs/for-ai/skills/okstra-run.md +7 -3
  47. package/docs/for-ai/skills/okstra-schedule-gen.md +15 -18
  48. package/docs/for-ai/skills/okstra-setup.md +7 -7
  49. package/docs/for-ai/skills/okstra-usage.md +5 -4
  50. package/docs/for-ai/skills/okstra-user-response.md +50 -32
  51. package/docs/project-structure-overview.md +29 -26
  52. package/docs/task-process/implementation.md +7 -4
  53. package/package.json +1 -1
  54. package/runtime/BUILD.json +2 -2
  55. package/runtime/agents/workers/claude-worker.md +4 -4
  56. package/runtime/agents/workers/report-writer-worker.md +3 -3
  57. package/runtime/agents/workers/translator-worker.md +5 -13
  58. package/runtime/bin/okstra-error-log.py +51 -11
  59. package/runtime/bin/okstra-report-translate.py +210 -23
  60. package/runtime/prompts/host-orchestration/implementation.md +1 -1
  61. package/runtime/prompts/launch.template.md +3 -7
  62. package/runtime/prompts/lead/context-loader.md +41 -141
  63. package/runtime/prompts/lead/convergence.md +8 -6
  64. package/runtime/prompts/lead/okstra-lead-contract.md +25 -35
  65. package/runtime/prompts/lead/plan-body-verification.md +187 -14
  66. package/runtime/prompts/lead/report-writer.md +20 -1
  67. package/runtime/prompts/lead/team-contract.md +8 -53
  68. package/runtime/prompts/profiles/_coding-conventions-preflight.md +3 -2
  69. package/runtime/prompts/profiles/_common-contract.md +1 -1
  70. package/runtime/prompts/profiles/_implementation-diff-review.md +1 -1
  71. package/runtime/prompts/profiles/_implementation-verifier.md +3 -3
  72. package/runtime/prompts/profiles/final-verification.md +1 -1
  73. package/runtime/prompts/profiles/implementation-planning.md +8 -2
  74. package/runtime/prompts/profiles/release-handoff.md +0 -1
  75. package/runtime/prompts/wizard/prompts.ko.json +0 -1
  76. package/runtime/python/okstra_ctl/adapters/hosts/capability_adapter.py +69 -17
  77. package/runtime/python/okstra_ctl/adapters/hosts/claude-code/adapter.py +13 -4
  78. package/runtime/python/okstra_ctl/adapters/hosts/claude-code/relay.md +6 -1
  79. package/runtime/python/okstra_ctl/adapters/hosts/codex/adapter.py +2 -2
  80. package/runtime/python/okstra_ctl/adapters/hosts/codex/relay.md +50 -5
  81. package/runtime/python/okstra_ctl/adapters/hosts/grok/adapter.py +2 -2
  82. package/runtime/python/okstra_ctl/adapters/hosts/grok/relay.md +66 -5
  83. package/runtime/python/okstra_ctl/adapters/providers/grok/adapter.py +70 -2
  84. package/runtime/python/okstra_ctl/agent_activity.py +118 -35
  85. package/runtime/python/okstra_ctl/agent_invocation.py +19 -6
  86. package/runtime/python/okstra_ctl/agent_prompt_cli.py +65 -18
  87. package/runtime/python/okstra_ctl/analysis_inputs.py +5 -4
  88. package/runtime/python/okstra_ctl/analysis_packet.py +81 -1
  89. package/runtime/python/okstra_ctl/approval_decisions.py +3 -2
  90. package/runtime/python/okstra_ctl/attempt_evidence.py +2 -2
  91. package/runtime/python/okstra_ctl/backfill.py +13 -10
  92. package/runtime/python/okstra_ctl/batch.py +2 -4
  93. package/runtime/python/okstra_ctl/build_tools.py +6 -3
  94. package/runtime/python/okstra_ctl/claim_reproduction.py +101 -0
  95. package/runtime/python/okstra_ctl/clarification_items.py +27 -13
  96. package/runtime/python/okstra_ctl/cmux.py +130 -52
  97. package/runtime/python/okstra_ctl/code_review_target.py +34 -8
  98. package/runtime/python/okstra_ctl/consumers.py +5 -4
  99. package/runtime/python/okstra_ctl/container.py +103 -8
  100. package/runtime/python/okstra_ctl/context_cost.py +2 -1
  101. package/runtime/python/okstra_ctl/contract_graph.py +497 -0
  102. package/runtime/python/okstra_ctl/contract_graph_cli.py +62 -0
  103. package/runtime/python/okstra_ctl/convergence.py +338 -17
  104. package/runtime/python/okstra_ctl/convergence_engine.py +10 -18
  105. package/runtime/python/okstra_ctl/convergence_provenance.py +58 -8
  106. package/runtime/python/okstra_ctl/convergence_store.py +55 -34
  107. package/runtime/python/okstra_ctl/design_prep.py +7 -4
  108. package/runtime/python/okstra_ctl/dispatch_core.py +35 -65
  109. package/runtime/python/okstra_ctl/dispatch_state.py +134 -59
  110. package/runtime/python/okstra_ctl/doctor.py +6 -3
  111. package/runtime/python/okstra_ctl/domain/worker_presentation.py +70 -9
  112. package/runtime/python/okstra_ctl/entrypoints/hosts.py +16 -30
  113. package/runtime/python/okstra_ctl/error_log_write.py +35 -30
  114. package/runtime/python/okstra_ctl/error_report.py +26 -1
  115. package/runtime/python/okstra_ctl/error_zip.py +27 -5
  116. package/runtime/python/okstra_ctl/execution_identity.py +3 -2
  117. package/runtime/python/okstra_ctl/execution_manifest.py +7 -4
  118. package/runtime/python/okstra_ctl/final_report_schema.py +2 -2
  119. package/runtime/python/okstra_ctl/fix_cycles.py +2 -2
  120. package/runtime/python/okstra_ctl/fixed_text.py +39 -0
  121. package/runtime/python/okstra_ctl/git_reconcile.py +41 -9
  122. package/runtime/python/okstra_ctl/handoff.py +5 -4
  123. package/runtime/python/okstra_ctl/i18n.py +4 -2
  124. package/runtime/python/okstra_ctl/implementation_direction.py +22 -14
  125. package/runtime/python/okstra_ctl/implementation_outcome.py +4 -7
  126. package/runtime/python/okstra_ctl/incremental_carry.py +2 -1
  127. package/runtime/python/okstra_ctl/incremental_scope.py +8 -3
  128. package/runtime/python/okstra_ctl/index.py +8 -11
  129. package/runtime/python/okstra_ctl/initial_prompt_materialization.py +79 -7
  130. package/runtime/python/okstra_ctl/invocation.py +3 -6
  131. package/runtime/python/okstra_ctl/json_boundary.py +366 -0
  132. package/runtime/python/okstra_ctl/json_registry.py +10 -12
  133. package/runtime/python/okstra_ctl/jsonl.py +19 -2
  134. package/runtime/python/okstra_ctl/lead_events.py +33 -1
  135. package/runtime/python/okstra_ctl/listing.py +3 -3
  136. package/runtime/python/okstra_ctl/log_report.py +24 -2
  137. package/runtime/python/okstra_ctl/manager_cli.py +92 -7
  138. package/runtime/python/okstra_ctl/manager_store.py +12 -10
  139. package/runtime/python/okstra_ctl/material.py +5 -1
  140. package/runtime/python/okstra_ctl/migrate.py +29 -25
  141. package/runtime/python/okstra_ctl/model_cli.py +3 -15
  142. package/runtime/python/okstra_ctl/model_io_cli.py +1051 -0
  143. package/runtime/python/okstra_ctl/mutation_probe.py +13 -4
  144. package/runtime/python/okstra_ctl/pane_reclaim.py +3 -2
  145. package/runtime/python/okstra_ctl/paths.py +9 -0
  146. package/runtime/python/okstra_ctl/plan_items.py +115 -4
  147. package/runtime/python/okstra_ctl/plan_items_cli.py +513 -18
  148. package/runtime/python/okstra_ctl/pr_template.py +3 -2
  149. package/runtime/python/okstra_ctl/project_meta.py +5 -7
  150. package/runtime/python/okstra_ctl/recap.py +5 -4
  151. package/runtime/python/okstra_ctl/reconcile.py +21 -27
  152. package/runtime/python/okstra_ctl/registry/host_discovery.py +3 -2
  153. package/runtime/python/okstra_ctl/registry/provider_registry.py +3 -2
  154. package/runtime/python/okstra_ctl/render.py +26 -15
  155. package/runtime/python/okstra_ctl/render_final_report.py +3 -2
  156. package/runtime/python/okstra_ctl/report_assembly.py +172 -17
  157. package/runtime/python/okstra_ctl/report_finalize.py +7 -10
  158. package/runtime/python/okstra_ctl/report_html/render.py +3 -2
  159. package/runtime/python/okstra_ctl/report_language.py +3 -2
  160. package/runtime/python/okstra_ctl/report_markdown.py +13 -1
  161. package/runtime/python/okstra_ctl/report_narrative.py +40 -8
  162. package/runtime/python/okstra_ctl/report_synthesis_packet.py +518 -0
  163. package/runtime/python/okstra_ctl/report_views.py +3 -2
  164. package/runtime/python/okstra_ctl/rollup.py +65 -4
  165. package/runtime/python/okstra_ctl/run.py +159 -56
  166. package/runtime/python/okstra_ctl/run_audit.py +3 -2
  167. package/runtime/python/okstra_ctl/run_context.py +6 -9
  168. package/runtime/python/okstra_ctl/run_index_row.py +2 -8
  169. package/runtime/python/okstra_ctl/schedule_semantics.py +5 -2
  170. package/runtime/python/okstra_ctl/schema_excerpt.py +4 -2
  171. package/runtime/python/okstra_ctl/session_transcript.py +27 -1
  172. package/runtime/python/okstra_ctl/set_work_status.py +64 -38
  173. package/runtime/python/okstra_ctl/stage_fix_carry.py +4 -2
  174. package/runtime/python/okstra_ctl/stage_map.py +26 -6
  175. package/runtime/python/okstra_ctl/stage_targets.py +3 -4
  176. package/runtime/python/okstra_ctl/team.py +2 -1
  177. package/runtime/python/okstra_ctl/team_reconcile.py +11 -2
  178. package/runtime/python/okstra_ctl/time_report.py +51 -4
  179. package/runtime/python/okstra_ctl/usage_identity.py +2 -1
  180. package/runtime/python/okstra_ctl/usage_report.py +58 -4
  181. package/runtime/python/okstra_ctl/user_response.py +1431 -66
  182. package/runtime/python/okstra_ctl/wizard.py +11 -44
  183. package/runtime/python/okstra_ctl/work_categories.py +3 -2
  184. package/runtime/python/okstra_ctl/worker_prompt_body.py +18 -7
  185. package/runtime/python/okstra_ctl/worker_prompt_contract.py +3 -2
  186. package/runtime/python/okstra_ctl/worker_runner.py +14 -12
  187. package/runtime/python/okstra_ctl/workflow.py +2 -1
  188. package/runtime/python/okstra_ctl/worktree.py +3 -2
  189. package/runtime/python/okstra_ctl/wrapper_status.py +4 -2
  190. package/runtime/python/okstra_ctl/write_policy.py +4 -2
  191. package/runtime/python/okstra_token_usage/antigravity.py +39 -12
  192. package/runtime/python/okstra_token_usage/collect.py +90 -38
  193. package/runtime/python/okstra_token_usage/grok.py +127 -0
  194. package/runtime/schemas/final-report-v2.0.schema.json +9 -0
  195. package/runtime/schemas/final-report-v3.0.schema.json +9 -0
  196. package/runtime/schemas/report-synthesis-packet-v1.0.schema.json +140 -0
  197. package/runtime/skills/okstra-brief-gen/SKILL.md +9 -7
  198. package/runtime/skills/okstra-code-review/SKILL.md +21 -11
  199. package/runtime/skills/okstra-container-build/SKILL.md +18 -18
  200. package/runtime/skills/okstra-inspect/SKILL.md +12 -11
  201. package/runtime/skills/okstra-inspect/facets/error-zip.md +8 -8
  202. package/runtime/skills/okstra-inspect/facets/errors.md +2 -2
  203. package/runtime/skills/okstra-inspect/facets/history.md +9 -14
  204. package/runtime/skills/okstra-inspect/facets/logs.md +2 -2
  205. package/runtime/skills/okstra-inspect/facets/recap.md +5 -5
  206. package/runtime/skills/okstra-inspect/facets/report.md +6 -10
  207. package/runtime/skills/okstra-inspect/facets/status.md +9 -8
  208. package/runtime/skills/okstra-inspect/facets/time.md +3 -3
  209. package/runtime/skills/okstra-manager/SKILL.md +16 -14
  210. package/runtime/skills/okstra-memory/SKILL.md +3 -3
  211. package/runtime/skills/okstra-pr-gen/SKILL.md +5 -4
  212. package/runtime/skills/okstra-rollup/SKILL.md +6 -16
  213. package/runtime/skills/okstra-run/SKILL.md +7 -7
  214. package/runtime/skills/okstra-schedule-gen/SKILL.md +21 -17
  215. package/runtime/skills/okstra-setup/SKILL.md +21 -13
  216. package/runtime/skills/okstra-setup/references/project-config.md +2 -2
  217. package/runtime/skills/okstra-usage/SKILL.md +10 -10
  218. package/runtime/skills/okstra-user-response/SKILL.md +78 -107
  219. package/runtime/templates/report-writer-prompt-preamble.md +17 -1
  220. package/runtime/templates/reports/schedule.template.md +4 -4
  221. package/runtime/templates/worker-error-contract.md +17 -29
  222. package/runtime/validators/validate-run.py +379 -63
  223. package/runtime/validators/validate_session_conformance.py +43 -7
@@ -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
@@ -4029,6 +4135,103 @@ def _is_dissent_downgraded(
4029
4135
  )
4030
4136
 
4031
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
+
4032
4235
  def _recompute_plan_body_gate(
4033
4236
  pbv: dict,
4034
4237
  accepted_item_ids: set[str] | None = None,
@@ -4038,9 +4241,7 @@ def _recompute_plan_body_gate(
4038
4241
  no plan items to judge (disabled / empty round)."""
4039
4242
  accepted = accepted_item_ids or set()
4040
4243
  classes = [
4041
- "has-dissent"
4042
- if _is_dissent_downgraded(it, pbv, accepted)
4043
- else _classify_plan_item_gate(it)
4244
+ _plan_item_gate_class(it, pbv, accepted)
4044
4245
  for it in (pbv.get("planItems") or [])
4045
4246
  if isinstance(it, dict)
4046
4247
  ]
@@ -4062,6 +4263,60 @@ def _recompute_plan_body_gate(
4062
4263
  return "passed"
4063
4264
 
4064
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
+
4065
4320
  def _validate_plan_body_gate_recompute(
4066
4321
  data: dict,
4067
4322
  failures: list[str],
@@ -4121,6 +4376,29 @@ def _blocks_approval(row: dict) -> bool:
4121
4376
  return status == "documented-deviation" and disposition.startswith("blocked C-")
4122
4377
 
4123
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
+
4124
4402
  def _plan_body_promoted_clarification_ids(pbv: dict) -> set[str]:
4125
4403
  """`C-NNN` ids this run's own plan-body round created by promoting a
4126
4404
  majority-disagree item (step 8). Used to break the Requirement Coverage
@@ -4128,11 +4406,9 @@ def _plan_body_promoted_clarification_ids(pbv: dict) -> set[str]:
4128
4406
  the gate already counted, rather than contributing an independent one.
4129
4407
  """
4130
4408
  return {
4131
- item["clarificationId"].strip()
4409
+ clarification_id
4132
4410
  for item in (pbv.get("planItems") or [])
4133
- if isinstance(item, dict)
4134
- and isinstance(item.get("clarificationId"), str)
4135
- and item["clarificationId"].strip()
4411
+ for clarification_id in _plan_item_clarification_ids(item)
4136
4412
  }
4137
4413
 
4138
4414
 
@@ -5793,8 +6069,7 @@ def _has_clarification_backtrace(
5793
6069
  resolver cannot disagree about what counts as a link.
5794
6070
  """
5795
6071
  if isinstance(plan_items, list) and any(
5796
- isinstance(item, dict) and item.get("clarificationId") == row_id
5797
- for item in plan_items
6072
+ row_id in _plan_item_clarification_ids(item) for item in plan_items
5798
6073
  ):
5799
6074
  return True
5800
6075
  return isinstance(coverage, list) and any(
@@ -6278,6 +6553,7 @@ def _validate_plan_body_state_file(
6278
6553
  data: dict,
6279
6554
  report_path: Path,
6280
6555
  failures: list[str],
6556
+ state_path: Path | None = None,
6281
6557
  ) -> None:
6282
6558
  """The per-round state file must exist once a round has run.
6283
6559
 
@@ -6301,20 +6577,34 @@ def _validate_plan_body_state_file(
6301
6577
  round_count = pbv.get("roundCount")
6302
6578
  if not isinstance(round_count, int) or round_count < 1:
6303
6579
  return
6304
- seq = _report_run_seq(report_path)
6305
- if seq is None:
6306
- return
6307
6580
  state_dir = report_path.parent.parent / "state"
6308
- expected = state_dir / f"plan-body-verification-implementation-planning-{seq}.json"
6309
- if not expected.is_file():
6310
- failures.append(
6311
- f"plan-body verification ran ({round_count} round(s)) but "
6312
- f"`state/{expected.name}` was not written. It is the only record "
6313
- "of superseded rounds — data.json keeps just the final verdicts, "
6314
- "so without it a self-fixed run leaves no trace of what the "
6315
- 'earlier rounds found (plan-body-verification.md §"schema").'
6316
- )
6317
- 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]
6318
6608
  try:
6319
6609
  state = json.loads(expected.read_text(encoding="utf-8"))
6320
6610
  except (OSError, json.JSONDecodeError) as exc:
@@ -7132,7 +7422,15 @@ def _validate_plan_body_verdict_provenance(
7132
7422
  if not voters:
7133
7423
  return
7134
7424
 
7135
- 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
7136
7434
  dispatched = _plan_verify_result_workers(report_path, task_type)
7137
7435
  if dispatched is None:
7138
7436
  return
@@ -7800,15 +8098,20 @@ def _validate_plan_body_clarification_matching(
7800
8098
  if _is_dissent_downgraded(item, pbv, accepted):
7801
8099
  continue
7802
8100
  item_id = item.get("id") or "<unknown>"
7803
- cid = item.get("clarificationId")
7804
- if not cid:
8101
+ cids = _plan_item_clarification_ids(item)
8102
+ if not cids:
7805
8103
  failures.append(
7806
8104
  f"final-report data.json: plan item `{item_id}` is majority-disagree "
7807
- "but carries no `clarificationId`. A blocking disagreement MUST "
8105
+ "but carries no `clarificationRefs`. A blocking disagreement MUST "
7808
8106
  "surface as a `## 1. Clarification Items` row (blocks=approval) so "
7809
- "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."
7810
8112
  )
7811
- elif cid not in approval_ids:
8113
+ continue
8114
+ for cid in sorted(cids - approval_ids):
7812
8115
  reason = (
7813
8116
  "references a non-existent §1 row"
7814
8117
  if cid not in all_ids
@@ -7816,8 +8119,9 @@ def _validate_plan_body_clarification_matching(
7816
8119
  )
7817
8120
  failures.append(
7818
8121
  f"final-report data.json: plan item `{item_id}` (majority-disagree) "
7819
- f"has clarificationId `{cid}` which {reason}. Every majority-disagree "
7820
- "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."
7821
8125
  )
7822
8126
 
7823
8127
 
@@ -7984,6 +8288,7 @@ def validate_plan_body_section(
7984
8288
  _validate_aborted_gate_has_clarification(data, failures)
7985
8289
  _validate_round_recorded_verdicts(data, failures)
7986
8290
  _validate_verdicts_match_current_subjects(data, failures)
8291
+ _validate_set_aside_register(data, failures, accepted_item_ids)
7987
8292
  _validate_verdict_rounds_outlive_self_fix(data, failures)
7988
8293
  _validate_unresolved_tie_was_reverified(data, failures)
7989
8294
  _validate_plan_item_extraction_completeness(data, failures)
@@ -8000,16 +8305,16 @@ def _gate_summary_item(
8000
8305
  accepted_item_ids: set[str],
8001
8306
  ) -> dict:
8002
8307
  """One `gate.items[]` row: the gate class plus its state-file counterpart."""
8003
- classification = (
8004
- "has-dissent"
8005
- if _is_dissent_downgraded(item, pbv, accepted_item_ids)
8006
- else _classify_plan_item_gate(item)
8007
- )
8308
+ classification = _plan_item_gate_class(item, pbv, accepted_item_ids)
8008
8309
  return {
8009
8310
  "id": item.get("id"),
8010
8311
  "classification": classification,
8011
8312
  "stateClassification": _state_classification(item, classification),
8012
8313
  "correctnessCritical": _is_correctness_critical(item),
8314
+ # 왜 안 막는지가 기록에 남아야 한다. 이 값이 없으면 범위 밖 강등과
8315
+ # 실제 합의가 산출물에서 같은 모양으로 읽힌다.
8316
+ "stageScope": _stage_scope_bucket(item, pbv),
8317
+ "block": str(item.get("block") or "execution"),
8013
8318
  }
8014
8319
 
8015
8320
 
@@ -8050,6 +8355,7 @@ def plan_body_gate_summary(data: dict) -> dict | None:
8050
8355
  _gate_blocking_causes(pbv, coverage_blockers, accepted_item_ids)
8051
8356
  ),
8052
8357
  "coverageBlockers": coverage_blockers,
8358
+ "setAside": _set_aside_register(pbv, accepted_item_ids),
8053
8359
  "blockingItems": [
8054
8360
  item["id"] for item in items if item["classification"] == "majority-disagree"
8055
8361
  ],
@@ -9425,7 +9731,9 @@ SECTION_PLAN_BODY = "plan-body"
9425
9731
  _FULL_ONLY_REQUIRED_FLAGS = ("team_state", "run_manifest", "task_manifest")
9426
9732
 
9427
9733
 
9428
- 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:
9429
9737
  """`--section plan-body` — the §5.5.9 checks a self-fix round can run on
9430
9738
  its own, plus the recomputed gate the lead records for that round.
9431
9739
 
@@ -9452,7 +9760,7 @@ def run_plan_body_section(report_path: Path) -> int:
9452
9760
  return 2
9453
9761
  failures = _data_schema_failures(data)
9454
9762
  warnings = validate_plan_body_section(data, report_path, failures)
9455
- _validate_plan_body_state_file(data, report_path, failures)
9763
+ _validate_plan_body_state_file(data, report_path, failures, state_path)
9456
9764
  payload = {
9457
9765
  "ok": not failures,
9458
9766
  "section": SECTION_PLAN_BODY,
@@ -9588,7 +9896,10 @@ def main() -> int:
9588
9896
  Path(args.narrative).resolve(), Path(args.state).resolve()
9589
9897
  )
9590
9898
  if args.report and not args.narrative and not args.state:
9591
- return run_plan_body_section(Path(args.report).resolve())
9899
+ return run_plan_body_section(
9900
+ Path(args.report).resolve(),
9901
+ Path(args.state).resolve() if args.state else None,
9902
+ )
9592
9903
  parser.error(
9593
9904
  "--section plan-body requires either --report or both --narrative and --state"
9594
9905
  )
@@ -9798,7 +10109,12 @@ def main() -> int:
9798
10109
  validation_data, project_root, failures
9799
10110
  )
9800
10111
  if task_type == "implementation-planning":
9801
- _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
+ )
9802
10118
  if task_type == "final-verification":
9803
10119
  _validate_verification_target_match(
9804
10120
  validation_data,