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
@@ -7,15 +7,28 @@ does not depend on a parser the lead re-writes each time.
7
7
  from __future__ import annotations
8
8
 
9
9
  import argparse
10
+ import importlib.util
10
11
  import json
11
12
  from collections.abc import Mapping
13
+ from datetime import datetime, timezone
12
14
  from pathlib import Path
13
15
  import sys
14
16
  from typing import Any
15
17
 
18
+ from .convergence import (
19
+ ConvergenceContractError,
20
+ canonical_run_state_artifact,
21
+ validated_run_authority,
22
+ )
16
23
  from .convergence_store import write_json_atomic
24
+ from .json_boundary import JsonBoundaryError, load_owned_object
17
25
  from .plan_derivations import extract_tokens, find_derivations
18
26
  from .plan_items import PlanItemContractError, extract_plan_items
27
+ from .paths import RunRef
28
+ from .stage_ledger import build_stage_ledger
29
+ from .claim_reproduction import NOT_RUNNABLE, reproduce
30
+ from .final_report_schema import load_schema_version
31
+ from .report_narrative import parse_narrative
19
32
  from .user_response import parse_user_response_entries
20
33
  from .verdict_blocks import (
21
34
  PLAN_ITEM_VERDICTS,
@@ -23,16 +36,14 @@ from .verdict_blocks import (
23
36
  VerdictBlockError,
24
37
  parse_verdict_blocks,
25
38
  )
39
+ from .fixed_text import line, scalar
26
40
 
27
41
 
28
42
  def _load_json_object(path: Path) -> dict[str, Any]:
29
43
  try:
30
- value = json.loads(path.read_text(encoding="utf-8"))
31
- except (OSError, UnicodeError, json.JSONDecodeError) as exc:
32
- raise PlanItemContractError(f"cannot read JSON object {path}: {exc}") from exc
33
- if not isinstance(value, dict):
34
- raise PlanItemContractError(f"JSON input must be an object: {path}")
35
- return value
44
+ return load_owned_object(path, artifact="plan-items artifact")
45
+ except JsonBoundaryError as exc:
46
+ raise PlanItemContractError(str(exc)) from exc
36
47
 
37
48
 
38
49
  def _planning(data: Mapping[str, Any]) -> Mapping[str, Any]:
@@ -57,11 +68,19 @@ def _parser() -> argparse.ArgumentParser:
57
68
  )
58
69
  commands = parser.add_subparsers(dest="command", required=True)
59
70
  extract = commands.add_parser("extract")
60
- extract.add_argument("--data", type=Path, required=True)
71
+ _add_plan_source(extract)
61
72
  extract.add_argument("--output", type=Path, required=True)
62
73
  validate = commands.add_parser("validate")
63
- validate.add_argument("--data", type=Path, required=True)
74
+ _add_plan_source(validate)
64
75
  validate.add_argument("--items", type=Path, required=True)
76
+ prepare = commands.add_parser("prepare")
77
+ _add_plan_source(prepare)
78
+ prepare.add_argument("--run-manifest", type=Path, required=True)
79
+ prompt = commands.add_parser("prompt")
80
+ prompt.add_argument("--run-manifest", type=Path, required=True)
81
+ validate_prepared = commands.add_parser("validate-prepared")
82
+ _add_plan_source(validate_prepared)
83
+ validate_prepared.add_argument("--run-manifest", type=Path, required=True)
65
84
  collect = commands.add_parser(
66
85
  "collect-verdicts",
67
86
  help="read this round's worker responses into a verdicts envelope",
@@ -84,35 +103,286 @@ def _parser() -> argparse.ArgumentParser:
84
103
  "seed",
85
104
  help="create the planBodyVerification.planItems[] rows a round lands in",
86
105
  )
87
- seed.add_argument("--data", type=Path, required=True)
106
+ _add_plan_source(seed)
107
+ seed.add_argument("--state", type=Path)
108
+ seed.add_argument(
109
+ "--run-manifest", type=Path,
110
+ help="record the stage ledger so the gate can scope itself to the "
111
+ "stage about to start; without it the gate does not narrow",
112
+ )
88
113
  apply_verdicts = commands.add_parser(
89
114
  "apply-verdicts",
90
- help="overwrite planBodyVerification.planItems[].verdicts in data.json",
115
+ help="overwrite planBodyVerification.planItems[].verdicts in owned state",
116
+ )
117
+ target = apply_verdicts.add_mutually_exclusive_group(required=True)
118
+ target.add_argument("--data", type=Path)
119
+ target.add_argument("--state", type=Path)
120
+ incoming = apply_verdicts.add_mutually_exclusive_group(required=True)
121
+ incoming.add_argument(
122
+ "--verdicts", type=Path,
123
+ help="historical automation envelope; model-facing calls use --result",
124
+ )
125
+ incoming.add_argument(
126
+ "--result", action="append", default=[],
127
+ metavar="<worker-id>=<path>",
128
+ help="one worker's plan-verify Markdown result (repeatable)",
129
+ )
130
+ apply_verdicts.add_argument(
131
+ "--run-manifest", type=Path,
132
+ help="resolve the project a `fact` claim's probe runs against; without "
133
+ "it every such claim records `not-runnable` and takes the quorum "
134
+ "route instead of blocking on one vote",
91
135
  )
92
- apply_verdicts.add_argument("--data", type=Path, required=True)
93
- apply_verdicts.add_argument("--verdicts", type=Path, required=True)
94
136
  apply_verdicts.add_argument(
95
137
  "--round", type=int, required=True, dest="round_number",
96
138
  help="the verification round these verdicts were cast in; stamped on "
97
139
  "every row so a later self-fix can be told from a current judgement",
98
140
  )
141
+ complete = commands.add_parser(
142
+ "complete-round",
143
+ help="derive and atomically record one verified plan-body round",
144
+ )
145
+ complete.add_argument("--state", type=Path, required=True)
146
+ complete.add_argument("--run-manifest", type=Path, required=True)
147
+ complete.add_argument("--round", type=int, required=True, dest="round_number")
148
+ complete.add_argument("--self-fix-note", action="append", default=[], metavar="<item-id>=<markdown-file>")
149
+ complete.add_argument("--self-fix-group", action="append", default=[], metavar="<cause-file>=<item-id>[,<item-id>...]")
150
+ complete.add_argument("--self-fix-stop-reason", choices=("all-resolved", "no-progress", "max-rounds-reached"))
99
151
  return parser
100
152
 
101
153
 
154
+ def _add_plan_source(parser: argparse.ArgumentParser) -> None:
155
+ source = parser.add_mutually_exclusive_group(required=True)
156
+ source.add_argument("--data", type=Path)
157
+ source.add_argument("--narrative", type=Path)
158
+
159
+
160
+ def _plan_source(args: argparse.Namespace) -> dict[str, Any]:
161
+ if args.data is not None:
162
+ return _load_json_object(args.data)
163
+ try:
164
+ markdown = args.narrative.read_text(encoding="utf-8")
165
+ except (OSError, UnicodeError) as exc:
166
+ raise PlanItemContractError(
167
+ f"cannot read report narrative {args.narrative}: {exc}"
168
+ ) from exc
169
+ try:
170
+ return parse_narrative(markdown, load_schema_version("3.0"))
171
+ except ValueError as exc:
172
+ raise PlanItemContractError(f"invalid report narrative: {exc}") from exc
173
+
174
+
102
175
  def _extract(args: argparse.Namespace) -> dict[str, Any]:
103
- envelope = _envelope(_load_json_object(args.data))
176
+ envelope = _envelope(_plan_source(args))
104
177
  write_json_atomic(args.output, envelope)
105
178
  return {"ok": True, "operation": "extract", "path": str(args.output)}
106
179
 
107
180
 
108
181
  def _validate(args: argparse.Namespace) -> dict[str, Any]:
109
- expected = _envelope(_load_json_object(args.data))
182
+ expected = _envelope(_plan_source(args))
110
183
  actual = _load_json_object(args.items)
111
184
  if actual != expected:
112
185
  raise PlanItemContractError("plan items envelope does not match deterministic extraction")
113
186
  return {"ok": True, "operation": "validate", "path": str(args.items)}
114
187
 
115
188
 
189
+ def _prepared_items_path(
190
+ run_manifest: Path, *, require_regular: bool = False
191
+ ) -> Path:
192
+ try:
193
+ authority = validated_run_authority(run_manifest)
194
+ state = canonical_run_state_artifact(
195
+ authority,
196
+ manifest_field="planBodyVerificationPath",
197
+ prefix="plan-body-verification",
198
+ label="plan body verification path",
199
+ )
200
+ except ConvergenceContractError as exc:
201
+ raise PlanItemContractError(str(exc)) from exc
202
+ path = state.with_name(
203
+ "plan-items-" + state.name.removeprefix("plan-body-verification-")
204
+ )
205
+ if path.absolute() != path.resolve(strict=False):
206
+ raise PlanItemContractError("plan items path does not match run authority")
207
+ if require_regular and not path.is_file():
208
+ raise PlanItemContractError("plan items path is not a regular file")
209
+ if path.exists() and not path.is_file():
210
+ raise PlanItemContractError("plan items path is not a regular file")
211
+ return path
212
+
213
+
214
+ def _prepare(args: argparse.Namespace) -> dict[str, Any]:
215
+ output = _prepared_items_path(args.run_manifest)
216
+ envelope = _envelope(_plan_source(args))
217
+ write_json_atomic(output, envelope)
218
+ return {"ok": True, "operation": "prepare", "path": str(output)}
219
+
220
+
221
+ _PAYLOAD_FIELDS = {
222
+ "Dir": ("goal", "coreMechanism", "architectureBoundaries", "expectedChangeAreas",
223
+ "fileStructure", "interfaces", "blastRadius", "testSeams", "assumptions",
224
+ "planningInvariants", "userConstraints"),
225
+ "Opt": ("name", "ticketId", "fileStructure", "interfaces", "blastRadius"),
226
+ "Step": ("stage", "step", "ticketId", "action", "files", "plannedPaths",
227
+ "command", "outcome", "expectedDetail"),
228
+ "Dep": ("id", "ticketId", "kind", "item", "impact", "mitigation"),
229
+ "Val": ("id", "phase", "ticketId", "check", "commandOrObservation", "expectedOutcome",
230
+ "stageRefs"),
231
+ "Rb": ("id", "ticketId", "step", "action", "triggerSignal", "verificationMethod"),
232
+ # 두 계약의 합집합이다. `ImplementationRequirementCoverageRow` 는 id/source/
233
+ # requirement/coveredBy/status 와 사용자 처분 기록(approvalDisposition,
234
+ # decisionRefs)을, `SelectedDirectionRequirementCoverage` 는 originalRequirementId
235
+ # 와 네 개의 *Refs 를 싣는다. 어느 한쪽만 남기면 다른 계약의 계획이 렌더에서
236
+ # 거부되므로 줄이지 말 것.
237
+ "Req": ("id", "source", "requirement", "coveredBy", "originalRequirementId",
238
+ "stageRefs", "stepRefs", "validationRefs", "fileRefs", "ticketId", "status",
239
+ "approvalDisposition", "decisionRefs"),
240
+ "Prep": ("stage", "kind", "evidence"),
241
+ }
242
+ _VAR_ANALYSIS_FIELDS = (
243
+ "hasMultipleImplementations", "noVariationRationale", "points",
244
+ )
245
+ _VAR_POINT_FIELDS = (
246
+ "behavior", "implementations", "evidence", "extractionDecision",
247
+ )
248
+ _OBJECT_LIST_FIELDS = {
249
+ "fileStructure": ("id", "ticketId", "action", "path", "summary", "details"),
250
+ "testSeams": ("boundary", "injectedAs", "replacedInTest"),
251
+ "planningInvariants": ("id", "statement", "requirementIds", "evidence"),
252
+ "evidence": ("step", "field", "match"),
253
+ }
254
+
255
+
256
+ def _label(key: str) -> str:
257
+ return " ".join(part.capitalize() for part in key.replace("Id", " ID").split())
258
+
259
+
260
+ def _render_object_list(key: str, value: object) -> list[str]:
261
+ if not isinstance(value, list):
262
+ raise PlanItemContractError(f"plan item {key} must be an array")
263
+ rows = [f"- {_label(key)}:\n"]
264
+ allowed = _OBJECT_LIST_FIELDS[key]
265
+ for index, entry in enumerate(value, 1):
266
+ if not isinstance(entry, Mapping) or set(entry) - set(allowed):
267
+ raise PlanItemContractError(f"plan item {key}[{index}] has unknown fields")
268
+ rows.append(f" - Entry {index}:\n")
269
+ for field in allowed:
270
+ if field in entry:
271
+ field_path = f"{key}[{index - 1}].{field}"
272
+ field_value = entry[field]
273
+ if isinstance(field_value, list):
274
+ rows.append(f" - `{field_path}`:\n")
275
+ rows.extend(
276
+ f" - `{scalar(item)}`\n" for item in field_value
277
+ )
278
+ elif isinstance(field_value, Mapping):
279
+ raise PlanItemContractError(
280
+ f"plan item {field_path} must be scalar"
281
+ )
282
+ else:
283
+ rows.append(
284
+ f" - `{field_path}`: `{scalar(field_value)}`\n"
285
+ )
286
+ return rows
287
+
288
+
289
+ def _render_variation_points(value: object) -> list[str]:
290
+ if not isinstance(value, list):
291
+ raise PlanItemContractError("plan item points must be an array")
292
+ rows = ["- Points:\n"]
293
+ allowed = {"behavior", "implementations", "evidence", "extractionDecision"}
294
+ decision_fields = ("extract", "interfaceKind", "coveredBy", "rationale")
295
+ for index, point in enumerate(value, 1):
296
+ if not isinstance(point, Mapping) or set(point) - allowed:
297
+ raise PlanItemContractError(f"plan item points[{index}] has unknown fields")
298
+ rows.append(f" - Point {index}:\n")
299
+ for field in ("behavior", "implementations", "evidence"):
300
+ rows.extend(_render_scalar_or_list(field, point.get(field), " "))
301
+ decision = point.get("extractionDecision")
302
+ if not isinstance(decision, Mapping) or set(decision) - set(decision_fields):
303
+ raise PlanItemContractError("variation extractionDecision has unknown fields")
304
+ rows.append(" - Extraction decision:\n")
305
+ for field in decision_fields:
306
+ rows.append(" " + line(_label(field), decision.get(field)))
307
+ return rows
308
+
309
+
310
+ def _render_variation_point_payload(payload: Mapping[str, Any]) -> list[str]:
311
+ rows: list[str] = []
312
+ for field in ("behavior", "implementations", "evidence"):
313
+ rows.extend(_render_scalar_or_list(field, payload.get(field)))
314
+ decision = payload.get("extractionDecision")
315
+ decision_fields = ("extract", "interfaceKind", "coveredBy", "rationale")
316
+ if not isinstance(decision, Mapping) or set(decision) - set(decision_fields):
317
+ raise PlanItemContractError("variation extractionDecision has unknown fields")
318
+ rows.append("- Extraction decision:\n")
319
+ for field in decision_fields:
320
+ rows.append(" " + line(_label(field), decision.get(field)))
321
+ return rows
322
+
323
+
324
+ def _render_scalar_or_list(key: str, value: object, indent: str = "") -> list[str]:
325
+ if isinstance(value, list):
326
+ if any(isinstance(entry, (Mapping, list)) for entry in value):
327
+ raise PlanItemContractError(f"plan item {key} must contain scalars")
328
+ rows = [f"{indent}- {_label(key)}:\n"]
329
+ rows.extend(f"{indent} - `{scalar(entry)}`\n" for entry in value)
330
+ return rows
331
+ if isinstance(value, Mapping):
332
+ raise PlanItemContractError(f"plan item {key} must be scalar")
333
+ return [indent + line(_label(key), value)]
334
+
335
+
336
+ def _render_payload(item_id: str, payload: object) -> list[str]:
337
+ if not isinstance(payload, Mapping):
338
+ raise PlanItemContractError(f"plan item {item_id} payload must be an object")
339
+ kind = item_id.split("-", 2)[1] if item_id.startswith("P-") else ""
340
+ allowed = (
341
+ _VAR_ANALYSIS_FIELDS if item_id == "P-Var-0"
342
+ else _VAR_POINT_FIELDS if kind == "Var"
343
+ else _PAYLOAD_FIELDS.get(kind)
344
+ )
345
+ if allowed is None or set(payload) - set(allowed):
346
+ raise PlanItemContractError(f"plan item {item_id} has unknown payload fields")
347
+ if kind == "Var" and item_id != "P-Var-0":
348
+ return _render_variation_point_payload(payload)
349
+ rows: list[str] = []
350
+ for key in allowed:
351
+ if key not in payload:
352
+ continue
353
+ if key in _OBJECT_LIST_FIELDS:
354
+ rows.extend(_render_object_list(key, payload[key]))
355
+ elif key == "points":
356
+ rows.extend(_render_variation_points(payload[key]))
357
+ else:
358
+ rows.extend(_render_scalar_or_list(key, payload[key]))
359
+ return rows
360
+
361
+
362
+ def _prompt(args: argparse.Namespace) -> str:
363
+ envelope = _load_json_object(
364
+ _prepared_items_path(args.run_manifest, require_regular=True)
365
+ )
366
+ items = envelope.get("items") if isinstance(envelope.get("items"), list) else []
367
+ rows = ["# Plan verification queue\n", line("Item count", len(items))]
368
+ for index, item in enumerate(items, 1):
369
+ if not isinstance(item, Mapping):
370
+ continue
371
+ item_id = str(item.get("id") or "")
372
+ rows.extend((f"\n## Plan item {index}\n", line("Item ID", item_id),
373
+ line("Subject", item.get("subject"))))
374
+ rows.extend(_render_payload(item_id, item.get("payload")))
375
+ return "".join(rows)
376
+
377
+
378
+ def _validate_prepared(args: argparse.Namespace) -> dict[str, Any]:
379
+ expected = _envelope(_plan_source(args))
380
+ path = _prepared_items_path(args.run_manifest, require_regular=True)
381
+ if _load_json_object(path) != expected:
382
+ raise PlanItemContractError("prepared plan items do not match deterministic extraction")
383
+ return {"ok": True, "operation": "validate-prepared", "path": str(path)}
384
+
385
+
116
386
  def _split_result_arg(raw: str) -> tuple[str, Path]:
117
387
  worker, separator, path = raw.partition("=")
118
388
  if not separator or not worker.strip() or not path.strip():
@@ -169,8 +439,15 @@ def _worker_blocks(
169
439
  parser; each is a round scored on a table that does not match the queue.
170
440
  """
171
441
  collected: list[tuple[str, dict[str, VerdictBlock]]] = []
442
+ seen_workers: set[str] = set()
172
443
  for raw in raw_results:
173
444
  worker, path = _split_result_arg(raw)
445
+ if worker in seen_workers:
446
+ raise PlanItemContractError(
447
+ f"duplicate worker result for `{worker}` — one worker may cast "
448
+ "only one verdict per plan-item round"
449
+ )
450
+ seen_workers.add(worker)
174
451
  try:
175
452
  blocks = parse_verdict_blocks(path.read_text(encoding="utf-8"))
176
453
  except (OSError, UnicodeError) as exc:
@@ -229,6 +506,45 @@ def _plan_body_items(data: dict[str, Any], data_path: Path) -> list[dict[str, An
229
506
  return items
230
507
 
231
508
 
509
+ def _state_plan_body_items(
510
+ state: dict[str, Any], state_path: Path,
511
+ ) -> list[dict[str, Any]]:
512
+ if state.get("owner") != "convergence":
513
+ raise PlanItemContractError(
514
+ f"plan-body state owner must be convergence: {state_path}"
515
+ )
516
+ verification = state.get("planBodyVerification")
517
+ if not isinstance(verification, Mapping):
518
+ raise PlanItemContractError(
519
+ f"planBodyVerification must be an object: {state_path}"
520
+ )
521
+ items = verification.get("planItems")
522
+ if not isinstance(items, list):
523
+ raise PlanItemContractError(
524
+ f"planBodyVerification.planItems must be an array: {state_path}"
525
+ )
526
+ return items
527
+
528
+
529
+ def _new_v3_state() -> dict[str, Any]:
530
+ return {
531
+ "schemaVersion": "1.1",
532
+ "owner": "convergence",
533
+ "planItems": [],
534
+ "roundHistory": [],
535
+ "selfFixRoundsApplied": 0,
536
+ "planBodyVerification": {
537
+ "roundCount": 0,
538
+ "gateResult": "passed",
539
+ "gateBlockedBy": [],
540
+ "selfFixRoundsApplied": 0,
541
+ "selfFixStopReason": "not-attempted",
542
+ "planItems": [],
543
+ "dissentLog": [],
544
+ },
545
+ }
546
+
547
+
232
548
  def _derivations(args: argparse.Namespace) -> dict[str, Any]:
233
549
  """Candidate statements each answered clarification may have falsified.
234
550
 
@@ -269,6 +585,37 @@ def _derivations(args: argparse.Namespace) -> dict[str, Any]:
269
585
  }
270
586
 
271
587
 
588
+ def _stage_ledger_snapshot(run_manifest: Path | None) -> dict[str, str] | None:
589
+ """`{스테이지 번호: 상태}`. 원장을 못 읽으면 ``None``.
590
+
591
+ 게이트가 범위를 좁히려면 어느 스테이지가 지금 시작 가능한지 알아야 한다. 그
592
+ 사실은 Stage 원장에 있고, 상태 어휘(`done` / `active` / `ready` / `blocked`)는
593
+ `stage_targets.StageLifecycle.status` 의 것을 그대로 쓴다 — 원장이 자기 어휘를
594
+ 따로 가지면 같은 stage 가 소비처마다 다르게 읽힌다.
595
+
596
+ ``None`` 은 판정 근거가 없다는 뜻이고, 소비처는 좁히지 않는다. 첫 계획 run 이라
597
+ 원장이 아직 없는 경우와 `--run-manifest` 를 안 넘긴 경우가 여기 해당한다.
598
+ """
599
+ if run_manifest is None:
600
+ return None
601
+ try:
602
+ authority = validated_run_authority(run_manifest)
603
+ task_root = RunRef.from_run_dir(authority.run_dir).task_root
604
+ ledger = build_stage_ledger(task_root)
605
+ except (ConvergenceContractError, ValueError, OSError):
606
+ return None
607
+ if not isinstance(ledger, Mapping) or not isinstance(ledger.get("stages"), list):
608
+ return None
609
+ snapshot = {
610
+ str(row["stage"]): str(row["status"])
611
+ for row in ledger["stages"]
612
+ if isinstance(row, Mapping)
613
+ and isinstance(row.get("stage"), int)
614
+ and isinstance(row.get("status"), str)
615
+ }
616
+ return snapshot or None
617
+
618
+
272
619
  def _seed(args: argparse.Namespace) -> dict[str, Any]:
273
620
  """Create the `planBodyVerification.planItems[]` rows a round lands in.
274
621
 
@@ -283,9 +630,17 @@ def _seed(args: argparse.Namespace) -> dict[str, Any]:
283
630
  already applied, `carriedForwardFromSeq`, `selfFixNote` — because a re-seed
284
631
  between rounds must not erase the round before it.
285
632
  """
286
- extracted = _envelope(_load_json_object(args.data))["items"]
287
- data = _load_json_object(args.data)
288
- recorded = _plan_body_items(data, args.data)
633
+ extracted = _envelope(_plan_source(args))["items"]
634
+ if args.state is not None:
635
+ data = _load_json_object(args.state) if args.state.is_file() else _new_v3_state()
636
+ recorded = _state_plan_body_items(data, args.state)
637
+ target = args.state
638
+ else:
639
+ if args.data is None:
640
+ raise PlanItemContractError("--narrative requires --state")
641
+ data = _load_json_object(args.data)
642
+ recorded = _plan_body_items(data, args.data)
643
+ target = args.data
289
644
  known = {
290
645
  item.get("id")
291
646
  for item in recorded
@@ -301,33 +656,94 @@ def _seed(args: argparse.Namespace) -> dict[str, Any]:
301
656
  key: item[key]
302
657
  for key in ("id", "subject", "sourceSection")
303
658
  if key in item
304
- } | {"verdicts": []}
659
+ } | (
660
+ {"stageScope": item["stageScope"]}
661
+ if item.get("stageScope") else {}
662
+ ) | (
663
+ {"block": item["block"]} if item.get("block") else {}
664
+ ) | {"verdicts": []}
305
665
  for item in extracted
306
666
  if item["id"] not in known
307
667
  ]
308
668
  recorded.extend(added)
309
- write_json_atomic(args.data, data)
669
+ if args.state is not None:
670
+ audit_rows = data.get("planItems")
671
+ if not isinstance(audit_rows, list):
672
+ raise PlanItemContractError("state planItems must be an array")
673
+ audited = {
674
+ item.get("id") for item in audit_rows if isinstance(item, Mapping)
675
+ }
676
+ audit_rows.extend(
677
+ {
678
+ "id": item["id"],
679
+ "subject": item["subject"],
680
+ "sourceSection": item["sourceSection"],
681
+ "ticketId": item.get("ticketId") or "unknown",
682
+ "rounds": [],
683
+ "clarificationId": None,
684
+ }
685
+ for item in extracted
686
+ if item["id"] not in audited
687
+ )
688
+ ledger = _stage_ledger_snapshot(getattr(args, "run_manifest", None))
689
+ if ledger is not None:
690
+ verification = (
691
+ data["planBodyVerification"] if args.state is not None
692
+ else _planning(data)["planBodyVerification"]
693
+ )
694
+ verification["stageLedger"] = ledger
695
+ write_json_atomic(target, data)
310
696
  return {
311
697
  "ok": True,
312
698
  "operation": "seed",
313
- "path": str(args.data),
699
+ "path": str(target),
314
700
  "seeded": len(added),
315
701
  "existing": len(known),
316
702
  }
317
703
 
318
704
 
705
+ def _judged_row(row: Mapping[str, Any], project_root: Path | None) -> dict[str, Any]:
706
+ """한 verdict 행에 재현 결과를 채워 돌려준다.
707
+
708
+ `reproductionResult` 는 들어온 값을 쓰지 않고 **항상 여기서 덮어쓴다.** 그
709
+ 필드가 1표 차단의 자격을 결정하므로, 워커가 보낸 값을 그대로 실으면 검증자가
710
+ 자기 주장의 판정을 스스로 적는 것이 된다. 계약이 "okstra 가 쓴다" 고 적은 것이
711
+ 이 뜻이고, 그 문장은 이 덮어쓰기가 있어야 참이다.
712
+
713
+ 프로젝트를 못 찾으면 `not-runnable` 이다 — 못 돌렸다는 사실이 기록되고, 그
714
+ 주장은 정족수로 내려간다. 조용히 통과시키지도, 근거 없이 차단하지도 않는다.
715
+ """
716
+ judged = dict(row)
717
+ if str(judged.get("claimKind") or "") != "fact":
718
+ judged.pop("reproductionResult", None)
719
+ return judged
720
+ judged["reproductionResult"] = (
721
+ reproduce(judged.get("reproduction"), project_root=project_root)
722
+ if project_root is not None
723
+ else NOT_RUNNABLE
724
+ )
725
+ return judged
726
+
727
+
728
+ def _probe_project_root(run_manifest: Path | None) -> Path | None:
729
+ if run_manifest is None:
730
+ return None
731
+ try:
732
+ return validated_run_authority(run_manifest).project_root
733
+ except ConvergenceContractError:
734
+ return None
735
+
736
+
319
737
  def _apply_verdicts(args: argparse.Namespace) -> dict[str, Any]:
320
- data = _load_json_object(args.data)
321
- incoming = _load_json_object(args.verdicts).get("planItems")
322
- if not isinstance(incoming, list):
323
- raise PlanItemContractError("verdicts envelope has no `planItems` array")
324
- rows = {
325
- item["id"]: item.get("verdicts", [])
326
- for item in incoming
327
- if isinstance(item, Mapping) and isinstance(item.get("id"), str)
328
- }
329
- recorded = _plan_body_items(data, args.data)
738
+ target = args.state or args.data
739
+ data = _load_json_object(target)
740
+ recorded = (
741
+ _state_plan_body_items(data, target)
742
+ if args.state is not None
743
+ else _plan_body_items(data, target)
744
+ )
330
745
  known = {item.get("id") for item in recorded if isinstance(item, Mapping)}
746
+ rows = _incoming_verdict_rows(args, known)
331
747
  missing = sorted(item_id for item_id in rows if item_id not in known)
332
748
  if missing:
333
749
  raise PlanItemContractError(
@@ -337,6 +753,7 @@ def _apply_verdicts(args: argparse.Namespace) -> dict[str, Any]:
337
753
  )
338
754
  if args.round_number < 1:
339
755
  raise PlanItemContractError("--round must be 1 or greater")
756
+ project_root = _probe_project_root(getattr(args, "run_manifest", None))
340
757
  for item in recorded:
341
758
  if isinstance(item, Mapping) and item.get("id") in rows:
342
759
  # Overwrite, never merge: the contract records one round at a time,
@@ -349,19 +766,189 @@ def _apply_verdicts(args: argparse.Namespace) -> dict[str, Any]:
349
766
  # row's vintage. Stamping it here is what lets the validator tell a
350
767
  # current judgement from one two rewrites old.
351
768
  item["verdicts"] = [
352
- {**row, "round": args.round_number} for row in rows[item["id"]]
769
+ {**_judged_row(row, project_root), "round": args.round_number}
770
+ for row in rows[item["id"]]
353
771
  ]
354
- write_json_atomic(args.data, data)
355
- return {"ok": True, "operation": "apply-verdicts", "path": str(args.data)}
772
+ write_json_atomic(target, data)
773
+ return {"ok": True, "operation": "apply-verdicts", "path": str(target)}
774
+
775
+
776
+ def _round_snapshot(item: Mapping[str, Any], round_number: int) -> dict[str, Any]:
777
+ votes = {
778
+ str(row["worker"]): str(row["verdict"])
779
+ for row in item.get("verdicts", [])
780
+ if isinstance(row, Mapping) and row.get("round") == round_number
781
+ and isinstance(row.get("worker"), str) and isinstance(row.get("verdict"), str)
782
+ }
783
+ disagrees = sum(value.startswith("DISAGREE") for value in votes.values())
784
+ if votes and disagrees == 0:
785
+ classification = "full-consensus"
786
+ elif disagrees * 2 > len(votes):
787
+ classification = "majority-disagree"
788
+ else:
789
+ classification = "partial-consensus"
790
+ return {"round": round_number, "votes": votes, "classification": classification}
791
+
792
+
793
+ def _plan_gate_summary(verification: Mapping[str, Any]) -> dict[str, Any]:
794
+ path = Path(__file__).parents[2] / "validators" / "validate-run.py"
795
+ spec = importlib.util.spec_from_file_location("okstra_plan_gate", path)
796
+ if spec is None or spec.loader is None:
797
+ raise PlanItemContractError("cannot load plan-body gate authority")
798
+ module = importlib.util.module_from_spec(spec)
799
+ spec.loader.exec_module(module)
800
+ summary = module.plan_body_gate_summary({
801
+ "implementationPlanning": {"planBodyVerification": dict(verification)}
802
+ })
803
+ if not isinstance(summary, Mapping):
804
+ raise PlanItemContractError("plan-body gate authority returned no gate")
805
+ return dict(summary)
806
+
807
+
808
+ def _record_self_fixes(args: argparse.Namespace, audit: list[object], verification: dict[str, Any]) -> None:
809
+ notes: dict[str, str] = {}
810
+ known = {item.get("id") for item in audit if isinstance(item, Mapping)}
811
+ if args.self_fix_stop_reason and not args.self_fix_group:
812
+ raise PlanItemContractError("--self-fix-stop-reason requires --self-fix-group")
813
+ for raw in args.self_fix_note:
814
+ item_id, separator, filename = raw.partition("=")
815
+ if not separator or not item_id or not filename:
816
+ raise PlanItemContractError("--self-fix-note must be <item-id>=<markdown-file>")
817
+ if item_id not in known:
818
+ raise PlanItemContractError(f"unknown self-fix item `{item_id}`")
819
+ try:
820
+ notes[item_id] = Path(filename).read_text(encoding="utf-8").strip()
821
+ except (OSError, UnicodeError) as exc:
822
+ raise PlanItemContractError(f"cannot read self-fix note {filename}: {exc}") from exc
823
+ for item in audit:
824
+ if isinstance(item, dict) and item.get("id") in notes:
825
+ item["selfFixNote"] = notes[item["id"]]
826
+ groups = []
827
+ for raw in args.self_fix_group:
828
+ filename, separator, item_ids = raw.partition("=")
829
+ if not separator or not filename or not item_ids:
830
+ raise PlanItemContractError("--self-fix-group must be <cause-file>=<item-id>[,<item-id>...]")
831
+ try:
832
+ cause = Path(filename).read_text(encoding="utf-8").strip()
833
+ except (OSError, UnicodeError) as exc:
834
+ raise PlanItemContractError(f"cannot read self-fix cause {filename}: {exc}") from exc
835
+ ids = [item for item in item_ids.split(",") if item]
836
+ unknown = sorted(set(ids) - known)
837
+ if unknown:
838
+ raise PlanItemContractError("unknown self-fix item(s): " + ", ".join(unknown))
839
+ groups.append({"round": args.round_number, "causeSummary": cause, "itemIds": ids})
840
+ if groups:
841
+ verification["selfFixGroups"] = groups
842
+ verification["selfFixRoundsApplied"] = args.round_number
843
+ verification["selfFixStopReason"] = args.self_fix_stop_reason or "all-resolved"
844
+
845
+
846
+ def _round_inputs(args: argparse.Namespace) -> tuple[dict[str, Any], list[dict[str, Any]], list[object], list[object]]:
847
+ data = _load_json_object(args.state)
848
+ audit, history = data.get("planItems"), data.get("roundHistory")
849
+ if not isinstance(audit, list) or not isinstance(history, list):
850
+ raise PlanItemContractError("state planItems and roundHistory must be arrays")
851
+ return data, _state_plan_body_items(data, args.state), audit, history
852
+
853
+
854
+ def _round_snapshots(current: list[dict[str, Any]], verification: Mapping[str, Any], round_number: int) -> tuple[dict[str, dict[str, Any]], dict[str, Any]]:
855
+ snapshots = {str(item.get("id")): _round_snapshot(item, round_number) for item in current}
856
+ if not snapshots or any(not row["votes"] for row in snapshots.values()):
857
+ raise PlanItemContractError("every current plan item needs a verdict before completion")
858
+ summary = _plan_gate_summary(verification)
859
+ classes = {row["id"]: row["stateClassification"] for row in summary["items"]}
860
+ for item_id, snapshot in snapshots.items():
861
+ snapshot["classification"] = classes.get(item_id, snapshot["classification"])
862
+ return snapshots, summary
863
+
864
+
865
+ def _record_audit_round(audit: list[object], snapshots: Mapping[str, dict[str, Any]], round_number: int) -> None:
866
+ for item in audit:
867
+ if isinstance(item, dict) and str(item.get("id")) in snapshots:
868
+ rounds = item.get("rounds")
869
+ if not isinstance(rounds, list):
870
+ raise PlanItemContractError("state plan item rounds must be an array")
871
+ item["rounds"] = [row for row in rounds if row.get("round") != round_number]
872
+ item["rounds"].append(snapshots[str(item["id"])])
873
+
874
+
875
+ def _participant_counts(args: argparse.Namespace, snapshots: Mapping[str, dict[str, Any]]) -> tuple[set[str], int, int]:
876
+ workers = {worker for row in snapshots.values() for worker in row["votes"]}
877
+ voting = {
878
+ worker for row in snapshots.values()
879
+ for worker, verdict in row["votes"].items()
880
+ if verdict != "verification-error"
881
+ }
882
+ assignments = _load_json_object(args.run_manifest).get("workerAssignments")
883
+ if not isinstance(assignments, list):
884
+ raise PlanItemContractError("run manifest workerAssignments must be an array")
885
+ rostered = sum(1 for row in assignments if isinstance(row, Mapping)
886
+ and isinstance(row.get("workerId"), str) and row["workerId"] != "report-writer")
887
+ return workers, len(voting), rostered
888
+
889
+
890
+ def _complete_round(args: argparse.Namespace) -> dict[str, Any]:
891
+ if args.round_number < 1:
892
+ raise PlanItemContractError("--round must be 1 or greater")
893
+ data, current, audit, history = _round_inputs(args)
894
+ verification = data["planBodyVerification"]
895
+ snapshots, summary = _round_snapshots(current, verification, args.round_number)
896
+ _record_audit_round(audit, snapshots, args.round_number)
897
+ gate = str(summary["recomputed"])
898
+ workers, voting, rostered = _participant_counts(args, snapshots)
899
+ uniform = [
900
+ {"worker": worker, "verdict": values[0], "itemCount": len(values)}
901
+ for worker in workers
902
+ for values in [[row["votes"][worker] for row in snapshots.values()]]
903
+ if len(set(values)) == 1
904
+ ]
905
+ verification.update({"roundCount": max(int(verification.get("roundCount", 0)), args.round_number),
906
+ "gateResult": gate, "gateBlockedBy": summary["blockedBy"],
907
+ # 무엇이 막았는지는 `gateBlockedBy` 가, 무엇이 막지 않았고
908
+ # 왜인지는 이 쪽이 적는다. 둘 다 같은 계산에서 나온다.
909
+ "setAside": summary["setAside"],
910
+ "participatingAnalysers": {"rostered": rostered, "voting": voting},
911
+ "uniformVerifiers": uniform})
912
+ _record_self_fixes(args, audit, verification)
913
+ if args.self_fix_group:
914
+ data["selfFixRoundsApplied"] = args.round_number
915
+ history[:] = [row for row in history if row.get("round") != args.round_number]
916
+ history.append({"round": args.round_number, "completedAt": datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ"), "gateResult": gate, "gateBlockedBy": verification["gateBlockedBy"]})
917
+ write_json_atomic(args.state, data)
918
+ return {"ok": True, "operation": "complete-round", "path": str(args.state), "gateResult": gate}
919
+
920
+
921
+ def _incoming_verdict_rows(
922
+ args: argparse.Namespace, known: set[object],
923
+ ) -> dict[str, list[dict[str, Any]]]:
924
+ if args.verdicts is not None:
925
+ incoming = _load_json_object(args.verdicts).get("planItems")
926
+ if not isinstance(incoming, list):
927
+ raise PlanItemContractError("verdicts envelope has no `planItems` array")
928
+ return {
929
+ item["id"]: item.get("verdicts", [])
930
+ for item in incoming
931
+ if isinstance(item, Mapping) and isinstance(item.get("id"), str)
932
+ }
933
+ assigned = {item_id for item_id in known if isinstance(item_id, str)}
934
+ collected = _worker_blocks(args.result, assigned)
935
+ return {
936
+ item_id: [_verdict_row(worker, blocks[item_id]) for worker, blocks in collected]
937
+ for item_id in assigned
938
+ }
356
939
 
357
940
 
358
941
  _HANDLERS = {
359
942
  "extract": _extract,
360
943
  "validate": _validate,
944
+ "prepare": _prepare,
945
+ "prompt": _prompt,
946
+ "validate-prepared": _validate_prepared,
361
947
  "collect-verdicts": _collect_verdicts,
362
948
  "derivations": _derivations,
363
949
  "seed": _seed,
364
950
  "apply-verdicts": _apply_verdicts,
951
+ "complete-round": _complete_round,
365
952
  }
366
953
 
367
954
 
@@ -372,7 +959,12 @@ def main(argv: list[str] | None = None) -> int:
372
959
  except (PlanItemContractError, VerdictBlockError, OSError, ValueError) as exc:
373
960
  print(f"plan-items: {exc}", file=sys.stderr)
374
961
  return 2
375
- print(json.dumps(result, ensure_ascii=False, indent=2))
962
+ if isinstance(result, str):
963
+ print(result, end="")
964
+ elif args.command in {"prepare", "validate-prepared"}:
965
+ print("Plan items\n" + line("Status", "ready") + line("Operation", result["operation"]), end="")
966
+ else:
967
+ print(json.dumps(result, ensure_ascii=False, indent=2))
376
968
  return 0
377
969
 
378
970