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
@@ -7,15 +7,26 @@ 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
19
30
  from .final_report_schema import load_schema_version
20
31
  from .report_narrative import parse_narrative
21
32
  from .user_response import parse_user_response_entries
@@ -25,16 +36,14 @@ from .verdict_blocks import (
25
36
  VerdictBlockError,
26
37
  parse_verdict_blocks,
27
38
  )
39
+ from .fixed_text import line, scalar
28
40
 
29
41
 
30
42
  def _load_json_object(path: Path) -> dict[str, Any]:
31
43
  try:
32
- value = json.loads(path.read_text(encoding="utf-8"))
33
- except (OSError, UnicodeError, json.JSONDecodeError) as exc:
34
- raise PlanItemContractError(f"cannot read JSON object {path}: {exc}") from exc
35
- if not isinstance(value, dict):
36
- raise PlanItemContractError(f"JSON input must be an object: {path}")
37
- return value
44
+ return load_owned_object(path, artifact="plan-items artifact")
45
+ except JsonBoundaryError as exc:
46
+ raise PlanItemContractError(str(exc)) from exc
38
47
 
39
48
 
40
49
  def _planning(data: Mapping[str, Any]) -> Mapping[str, Any]:
@@ -64,6 +73,14 @@ def _parser() -> argparse.ArgumentParser:
64
73
  validate = commands.add_parser("validate")
65
74
  _add_plan_source(validate)
66
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)
67
84
  collect = commands.add_parser(
68
85
  "collect-verdicts",
69
86
  help="read this round's worker responses into a verdicts envelope",
@@ -88,6 +105,11 @@ def _parser() -> argparse.ArgumentParser:
88
105
  )
89
106
  _add_plan_source(seed)
90
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
+ )
91
113
  apply_verdicts = commands.add_parser(
92
114
  "apply-verdicts",
93
115
  help="overwrite planBodyVerification.planItems[].verdicts in owned state",
@@ -95,12 +117,37 @@ def _parser() -> argparse.ArgumentParser:
95
117
  target = apply_verdicts.add_mutually_exclusive_group(required=True)
96
118
  target.add_argument("--data", type=Path)
97
119
  target.add_argument("--state", type=Path)
98
- apply_verdicts.add_argument("--verdicts", type=Path, required=True)
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",
135
+ )
99
136
  apply_verdicts.add_argument(
100
137
  "--round", type=int, required=True, dest="round_number",
101
138
  help="the verification round these verdicts were cast in; stamped on "
102
139
  "every row so a later self-fix can be told from a current judgement",
103
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"))
104
151
  return parser
105
152
 
106
153
 
@@ -139,6 +186,203 @@ def _validate(args: argparse.Namespace) -> dict[str, Any]:
139
186
  return {"ok": True, "operation": "validate", "path": str(args.items)}
140
187
 
141
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
+
142
386
  def _split_result_arg(raw: str) -> tuple[str, Path]:
143
387
  worker, separator, path = raw.partition("=")
144
388
  if not separator or not worker.strip() or not path.strip():
@@ -195,8 +439,15 @@ def _worker_blocks(
195
439
  parser; each is a round scored on a table that does not match the queue.
196
440
  """
197
441
  collected: list[tuple[str, dict[str, VerdictBlock]]] = []
442
+ seen_workers: set[str] = set()
198
443
  for raw in raw_results:
199
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)
200
451
  try:
201
452
  blocks = parse_verdict_blocks(path.read_text(encoding="utf-8"))
202
453
  except (OSError, UnicodeError) as exc:
@@ -334,6 +585,37 @@ def _derivations(args: argparse.Namespace) -> dict[str, Any]:
334
585
  }
335
586
 
336
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
+
337
619
  def _seed(args: argparse.Namespace) -> dict[str, Any]:
338
620
  """Create the `planBodyVerification.planItems[]` rows a round lands in.
339
621
 
@@ -374,7 +656,12 @@ def _seed(args: argparse.Namespace) -> dict[str, Any]:
374
656
  key: item[key]
375
657
  for key in ("id", "subject", "sourceSection")
376
658
  if key in item
377
- } | {"verdicts": []}
659
+ } | (
660
+ {"stageScope": item["stageScope"]}
661
+ if item.get("stageScope") else {}
662
+ ) | (
663
+ {"block": item["block"]} if item.get("block") else {}
664
+ ) | {"verdicts": []}
378
665
  for item in extracted
379
666
  if item["id"] not in known
380
667
  ]
@@ -398,6 +685,13 @@ def _seed(args: argparse.Namespace) -> dict[str, Any]:
398
685
  for item in extracted
399
686
  if item["id"] not in audited
400
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
401
695
  write_json_atomic(target, data)
402
696
  return {
403
697
  "ok": True,
@@ -408,23 +702,48 @@ def _seed(args: argparse.Namespace) -> dict[str, Any]:
408
702
  }
409
703
 
410
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
+
411
737
  def _apply_verdicts(args: argparse.Namespace) -> dict[str, Any]:
412
738
  target = args.state or args.data
413
739
  data = _load_json_object(target)
414
- incoming = _load_json_object(args.verdicts).get("planItems")
415
- if not isinstance(incoming, list):
416
- raise PlanItemContractError("verdicts envelope has no `planItems` array")
417
- rows = {
418
- item["id"]: item.get("verdicts", [])
419
- for item in incoming
420
- if isinstance(item, Mapping) and isinstance(item.get("id"), str)
421
- }
422
740
  recorded = (
423
741
  _state_plan_body_items(data, target)
424
742
  if args.state is not None
425
743
  else _plan_body_items(data, target)
426
744
  )
427
745
  known = {item.get("id") for item in recorded if isinstance(item, Mapping)}
746
+ rows = _incoming_verdict_rows(args, known)
428
747
  missing = sorted(item_id for item_id in rows if item_id not in known)
429
748
  if missing:
430
749
  raise PlanItemContractError(
@@ -434,6 +753,7 @@ def _apply_verdicts(args: argparse.Namespace) -> dict[str, Any]:
434
753
  )
435
754
  if args.round_number < 1:
436
755
  raise PlanItemContractError("--round must be 1 or greater")
756
+ project_root = _probe_project_root(getattr(args, "run_manifest", None))
437
757
  for item in recorded:
438
758
  if isinstance(item, Mapping) and item.get("id") in rows:
439
759
  # Overwrite, never merge: the contract records one round at a time,
@@ -446,19 +766,189 @@ def _apply_verdicts(args: argparse.Namespace) -> dict[str, Any]:
446
766
  # row's vintage. Stamping it here is what lets the validator tell a
447
767
  # current judgement from one two rewrites old.
448
768
  item["verdicts"] = [
449
- {**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"]]
450
771
  ]
451
772
  write_json_atomic(target, data)
452
773
  return {"ok": True, "operation": "apply-verdicts", "path": str(target)}
453
774
 
454
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
+ }
939
+
940
+
455
941
  _HANDLERS = {
456
942
  "extract": _extract,
457
943
  "validate": _validate,
944
+ "prepare": _prepare,
945
+ "prompt": _prompt,
946
+ "validate-prepared": _validate_prepared,
458
947
  "collect-verdicts": _collect_verdicts,
459
948
  "derivations": _derivations,
460
949
  "seed": _seed,
461
950
  "apply-verdicts": _apply_verdicts,
951
+ "complete-round": _complete_round,
462
952
  }
463
953
 
464
954
 
@@ -469,7 +959,12 @@ def main(argv: list[str] | None = None) -> int:
469
959
  except (PlanItemContractError, VerdictBlockError, OSError, ValueError) as exc:
470
960
  print(f"plan-items: {exc}", file=sys.stderr)
471
961
  return 2
472
- 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))
473
968
  return 0
474
969
 
475
970