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
@@ -34,6 +34,8 @@ from .agent_invocation import (
34
34
  verify_agent_invocation,
35
35
  v2_role_assignment_authority_errors,
36
36
  )
37
+ from .fixed_text import line
38
+ from .json_boundary import JsonBoundaryError, load_owned_object
37
39
  from .convergence_store import (
38
40
  DYNAMIC_VERIFIER_SOURCE_ROLES,
39
41
  reserve_dynamic_verifier,
@@ -48,6 +50,7 @@ from .wrapper_status import (
48
50
  prompt_derived_paths,
49
51
  status_path_for_prompt,
50
52
  )
53
+ from .report_inputs import report_narrative_path, uses_report_contract_v3
51
54
  from .worker_prompt_policy import (
52
55
  CRITIC_DUTY_BY_ASSIGNMENT_SEGMENT,
53
56
  resolve_prompt_plan_for_manifest,
@@ -73,6 +76,13 @@ class AgentPromptCliError(RuntimeError):
73
76
  def _parser() -> argparse.ArgumentParser:
74
77
  parser = argparse.ArgumentParser(prog="okstra agent-prompt")
75
78
  commands = parser.add_subparsers(dest="command", required=True)
79
+ _add_materialize_parser(commands)
80
+ _add_completion_parsers(commands)
81
+ _add_dispatch_parsers(commands)
82
+ return parser
83
+
84
+
85
+ def _add_materialize_parser(commands: argparse._SubParsersAction) -> None:
76
86
  materialize = commands.add_parser("materialize")
77
87
  _common_paths(materialize)
78
88
  materialize.add_argument("--invocation-id", required=True)
@@ -113,7 +123,10 @@ def _parser() -> argparse.ArgumentParser:
113
123
  verify.add_argument("--run-manifest")
114
124
  verify.add_argument("--metadata", required=True)
115
125
  verify.add_argument("--json", action="store_true")
126
+ verify.add_argument("--text", action="store_true")
116
127
 
128
+
129
+ def _add_completion_parsers(commands: argparse._SubParsersAction) -> None:
117
130
  materialize_result = commands.add_parser("materialize-result")
118
131
  _standalone_completion_args(materialize_result)
119
132
  materialize_result.add_argument("--returned-body-file", required=True)
@@ -127,6 +140,8 @@ def _parser() -> argparse.ArgumentParser:
127
140
  verify_completion.add_argument("--completion", required=True)
128
141
  verify_completion.add_argument("--json", action="store_true")
129
142
 
143
+
144
+ def _add_dispatch_parsers(commands: argparse._SubParsersAction) -> None:
130
145
  record_dispatch = commands.add_parser("record-dispatch")
131
146
  _common_paths(record_dispatch)
132
147
  record_dispatch.add_argument("--run-manifest", required=True)
@@ -156,7 +171,6 @@ def _parser() -> argparse.ArgumentParser:
156
171
  link_result.add_argument("--dispatch-id", required=True)
157
172
  link_result.add_argument("--result", required=True)
158
173
  link_result.add_argument("--json", action="store_true")
159
- return parser
160
174
 
161
175
 
162
176
  def _common_paths(parser: argparse.ArgumentParser) -> None:
@@ -182,6 +196,7 @@ def main(argv: list[str] | None = None) -> int:
182
196
  _emit(
183
197
  {"ok": True, "metadataPath": str(Path(args.metadata).resolve())},
184
198
  args.json,
199
+ args.text,
185
200
  )
186
201
  return 0
187
202
  if args.command == "materialize-result":
@@ -317,16 +332,32 @@ def _materialize_run(
317
332
  )
318
333
  if args.audience == "report-writer" and not args.audit_source:
319
334
  # The report writer is the one audience whose result path is not its own
320
- # worker result: it writes the report narrative, while the audit
321
- # sidecar is derived from its `.md`. With both collapsed into one value
322
- # the prompt loses its `**Worker Result Path:**` anchor and the writer
323
- # puts the report where the audit file belongs — silently, because every
324
- # header is still present and well-formed. The roster path derives both
325
- # from the manifest; a dynamic call has to name them.
335
+ # worker result: it writes the report body, while the audit sidecar is
336
+ # derived from its `.md`. With both collapsed into one value the prompt
337
+ # loses its `**Worker Result Path:**` anchor and the writer puts the
338
+ # report where the audit file belongs — silently, because every header
339
+ # is still present and well-formed. The roster path derives both from
340
+ # the manifest; a dynamic call has to name them.
341
+ #
342
+ # Which artifact `--result` names depends on the report contract, and
343
+ # `dispatch_state.dispatch_result_path` is what decides it. Naming only
344
+ # the v2 answer here sent v3 runs to the data.json, so the narrative
345
+ # `report-finalize` assembles from was never written and the phase
346
+ # failed later, at a place that does not point back here.
347
+ if uses_report_contract_v3(manifest):
348
+ expected = report_narrative_path(project_root, manifest)
349
+ raise AgentPromptCliError(
350
+ "report-writer materialization requires --audit-source: under "
351
+ f"report contract 3.0 --result is the narrative ({expected}), "
352
+ "which `report-finalize` assembles the report data.json from, "
353
+ "and --audit-source the worker-result .md the audit sidecar is "
354
+ "derived from"
355
+ )
326
356
  raise AgentPromptCliError(
327
- "report-writer materialization requires --audit-source: --result is "
328
- "the report data.json, --audit-source the worker-result .md the "
329
- "audit sidecar is derived from"
357
+ "report-writer materialization requires --audit-source: under "
358
+ "report contract 2.0 --result is the report data.json, and "
359
+ "--audit-source the worker-result .md the audit sidecar is "
360
+ "derived from"
330
361
  )
331
362
  audit_source_path = (
332
363
  _authorized_path(
@@ -636,11 +667,12 @@ def _validate_run_identity(
636
667
  expected = CRITIC_DUTY_BY_ASSIGNMENT_SEGMENT.get(scope, "")
637
668
  if not expected or dispatch_kind != "critic":
638
669
  raise AgentPromptCliError("critic assignment identity is invalid")
639
- convergence = manifest.get("convergence")
640
- critic = convergence.get("critic") if isinstance(convergence, Mapping) else None
641
- provider = critic.get("provider") if isinstance(critic, Mapping) else None
642
- if provider and provider != worker_id:
643
- raise AgentPromptCliError("critic worker does not match manifest provider")
670
+ # provider 대조는 여기 두지 않는다. critic 의 `worker_id` 는 배정 참조의
671
+ # 마지막 마디(`scope` / `acceptance`)이고 provider 이름이 아니다
672
+ # (`render.py` 의 로스터 행이 `assignment_ref.rsplit("/", 1)[-1]` 로 만든다).
673
+ # 종전에 있던 `provider != worker_id` 검사는 두 어휘를 비교해서 늘 참이 되는
674
+ # 형태였고, 설정을 run manifest 에서 찾다 못 찾아 조용히 건너뛰었기에
675
+ # 드러나지 않았다. 정본에서 읽게 고치면 모든 critic 디스패치가 거부된다.
644
676
  else:
645
677
  try:
646
678
  expected = resolve_prompt_plan_for_manifest(
@@ -1039,8 +1071,8 @@ def _relative(project_root: Path, path: Path) -> str:
1039
1071
 
1040
1072
  def _read_json_object(path: Path, label: str) -> dict[str, Any]:
1041
1073
  try:
1042
- value = json.loads(path.read_text(encoding="utf-8"))
1043
- except (OSError, UnicodeDecodeError, json.JSONDecodeError) as exc:
1074
+ value = load_owned_object(path, artifact="agent prompt metadata")
1075
+ except JsonBoundaryError as exc:
1044
1076
  raise AgentPromptCliError(f"{label} is invalid: {path}") from exc
1045
1077
  if not isinstance(value, dict):
1046
1078
  raise AgentPromptCliError(f"{label} must be a JSON object: {path}")
@@ -1098,12 +1130,27 @@ def _prepared_payload(
1098
1130
  }
1099
1131
 
1100
1132
 
1101
- def _emit(payload: Mapping[str, object], as_json: bool) -> None:
1133
+ def _emit(
1134
+ payload: Mapping[str, object], as_json: bool, as_text: bool = False
1135
+ ) -> None:
1102
1136
  if as_json:
1103
1137
  print(json.dumps(payload, ensure_ascii=False, indent=2))
1138
+ elif as_text:
1139
+ print(render_agent_prompt_text(payload), end="")
1104
1140
  else:
1105
1141
  print(payload.get("promptPath") or payload.get("metadataPath") or "ok")
1106
1142
 
1107
1143
 
1144
+ def render_agent_prompt_text(payload: Mapping[str, object]) -> str:
1145
+ """agent prompt 검증 결과의 승인된 경로만 투영한다."""
1146
+ rows = ["Okstra agent prompt\n"]
1147
+ rows.append(line("Status", "ready" if payload.get("ok", True) else "error"))
1148
+ for label, key in (("Prompt path", "promptPath"), ("Metadata path", "metadataPath"),
1149
+ ("Assignment ID", "assignmentId"), ("Worker role", "workerRole")):
1150
+ if key in payload:
1151
+ rows.append(line(label, payload.get(key)))
1152
+ return "".join(rows)
1153
+
1154
+
1108
1155
  if __name__ == "__main__":
1109
1156
  raise SystemExit(main())
@@ -11,6 +11,7 @@ from typing import Mapping, Sequence
11
11
 
12
12
  from .final_report_paths import final_report_data_path
13
13
  from .paths import task_manifest_file
14
+ from .json_boundary import JsonBoundaryError, load_owned_object
14
15
  from .user_response import (
15
16
  UserResponseError,
16
17
  load_authoritative_analysis_review,
@@ -196,8 +197,8 @@ def _task_key_from_manifest(task_root: Path, okstra_root: Path) -> str:
196
197
  task_manifest_file(task_root), okstra_root, "task manifest path"
197
198
  )
198
199
  try:
199
- manifest = json.loads(manifest_path.read_text(encoding="utf-8"))
200
- except (OSError, json.JSONDecodeError) as exc:
200
+ manifest = load_owned_object(manifest_path, artifact="task manifest")
201
+ except JsonBoundaryError as exc:
201
202
  raise AnalysisInputError(f"cannot read task manifest: {manifest_path}") from exc
202
203
  if not isinstance(manifest, dict):
203
204
  raise AnalysisInputError("task manifest must contain an object")
@@ -219,8 +220,8 @@ def load_analysis_report_candidate(
219
220
  raise AnalysisInputError("report filename is not a final report")
220
221
  data_path = _resolved_within(final_report_data_path(report), okstra_root, "data.json path")
221
222
  try:
222
- data = json.loads(data_path.read_text(encoding="utf-8"))
223
- except (OSError, json.JSONDecodeError) as exc:
223
+ data = load_owned_object(data_path, artifact="analysis report record")
224
+ except JsonBoundaryError as exc:
224
225
  raise AnalysisInputError(f"cannot read data.json: {data_path}") from exc
225
226
  if not isinstance(data, dict):
226
227
  raise AnalysisInputError("data.json must contain an object")
@@ -128,7 +128,87 @@ def build_analysis_packet(
128
128
  parts.extend(_stage_ledger_unavailable_block(stage_ledger_notice))
129
129
  parts.extend(_clarification_block(clarification_text))
130
130
  parts.extend(_directive_block(directive))
131
- return "\n".join(part.rstrip() for part in parts).rstrip() + "\n"
131
+ body = "\n".join(part.rstrip() for part in parts).rstrip() + "\n"
132
+ return _with_section_index(body)
133
+
134
+
135
+ _HEADING_RE = re.compile(r"\A#{1,2} \S")
136
+ _FENCE_RE = re.compile(r"\A\s*(```|~~~)")
137
+ # 목차 자신이 차지하는 줄 수 중 표제 행을 뺀 나머지. 행 수는 표제 개수로
138
+ # 정해지므로 삽입 전에 전체 이동량을 계산할 수 있다.
139
+ _INDEX_PREAMBLE_LINES = 5
140
+
141
+
142
+ def _with_section_index(body: str) -> str:
143
+ """Prefix the rendered packet with every section's line range.
144
+
145
+ The packet's own structure is the only thing a worker can navigate it by,
146
+ and a worker cannot navigate what it cannot see. Measured 2026-08-21: the
147
+ antigravity CLI's `view_file` truncates at 46,080 bytes per call, carries
148
+ no line-range parameter, and reports only `<n> lines, <m> bytes` — the same
149
+ string whether it returned the file or 40% of it. On a 114KB packet that
150
+ first call ended at line 510, so `## Stage Ledger` (572) and
151
+ `## Clarification Carry-In Extract` (680) never arrived and the worker
152
+ planned from the pre-migration brief that fills the first 42KB. Every
153
+ evidence line it cited was under 400. With the ranges here a truncated
154
+ reader can fetch the block it is missing by range instead of re-reading
155
+ the head; the lead had to write those ranges by hand to get a usable
156
+ retry.
157
+ """
158
+ lines = body.splitlines()
159
+ anchor = next(
160
+ (i for i, line in enumerate(lines)
161
+ if line.startswith("# OKSTRA Analysis Packet")),
162
+ None,
163
+ )
164
+ if anchor is None:
165
+ return body
166
+ headings = _heading_lines(lines)
167
+ if not headings:
168
+ return body
169
+ shift = _INDEX_PREAMBLE_LINES + len(headings)
170
+ total = len(lines) + shift
171
+ starts = [number + shift for number, _ in headings]
172
+ rows = [
173
+ f"- {start}-{end} `{text}`"
174
+ for start, end, (_, text) in zip(
175
+ starts, [nxt - 1 for nxt in starts[1:]] + [total], headings
176
+ )
177
+ ]
178
+ block = [
179
+ "",
180
+ "## Section Index",
181
+ "",
182
+ f"This packet is {total} lines. Read a section by line range with"
183
+ " `sed -n '<start>,<end>p'` rather than opening the whole file: a"
184
+ " whole-file read is truncated by some worker CLIs without reporting"
185
+ " it, and the sections that describe current state are last.",
186
+ "",
187
+ *rows,
188
+ ]
189
+ assert len(block) == shift, (len(block), shift)
190
+ return "\n".join(lines[:anchor + 1] + block + lines[anchor + 1:]) + "\n"
191
+
192
+
193
+ def _heading_lines(lines: list[str]) -> list[tuple[int, str]]:
194
+ """Every `#`/`##` heading outside a fence, as (1-based line, text).
195
+
196
+ Fences are tracked because the Stage Ledger and the carried-in
197
+ clarification rows embed JSON and shell text where a `#` starts a comment,
198
+ not a section.
199
+ """
200
+ out: list[tuple[int, str]] = []
201
+ fenced = False
202
+ for number, line in enumerate(lines, start=1):
203
+ if _FENCE_RE.match(line):
204
+ fenced = not fenced
205
+ continue
206
+ if fenced or not _HEADING_RE.match(line):
207
+ continue
208
+ if line.startswith("# OKSTRA Analysis Packet"):
209
+ continue
210
+ out.append((number, line.strip()))
211
+ return out
132
212
 
133
213
 
134
214
  def _packet_frontmatter(brief_text: str, task_key: str) -> str:
@@ -11,6 +11,7 @@ from pathlib import Path
11
11
  from typing import Any, Mapping, Sequence
12
12
 
13
13
  from .convergence_store import write_json_atomic
14
+ from .json_boundary import JsonBoundaryError, load_owned_object
14
15
 
15
16
 
16
17
  DISPOSITIONS = frozenset({"select", "accept-risk", "request-revision", "reject"})
@@ -87,8 +88,8 @@ def _new_ledger(task_key: str, task_type: str, run_seq: str) -> dict[str, Any]:
87
88
 
88
89
  def _read_ledger(path: Path) -> dict[str, Any]:
89
90
  try:
90
- value = json.loads(path.read_text(encoding="utf-8"))
91
- except (OSError, UnicodeError, json.JSONDecodeError) as exc:
91
+ value = load_owned_object(path, artifact="approval decision ledger")
92
+ except JsonBoundaryError as exc:
92
93
  raise ApprovalDecisionError(f"cannot read approval ledger {path}: {exc}") from exc
93
94
  if not isinstance(value, dict) or value.get("owner") != "lead":
94
95
  raise ApprovalDecisionError(f"approval ledger owner must be lead: {path}")
@@ -12,6 +12,7 @@ from .domain.worker_stream import host_event_row
12
12
  from .execution_identity import Attempt, ExecutionManifestError
13
13
  from .execution_manifest import read_execution_manifest
14
14
  from .execution_manifest import _replace_manifest, _write_manifest_over_existing
15
+ from .jsonl import append_jsonl
15
16
  from .run_context import task_mutex
16
17
 
17
18
 
@@ -104,8 +105,7 @@ class HostEventStreamWriter:
104
105
  payload=payload,
105
106
  timestamp=_utc_now(),
106
107
  )
107
- with self.path.open("a", encoding="utf-8") as handle:
108
- handle.write(json.dumps(row, ensure_ascii=False) + "\n")
108
+ append_jsonl(self.path, row, ensure_ascii=False, compact=False)
109
109
  return row
110
110
 
111
111
 
@@ -13,6 +13,7 @@ from .ids import build_run_id
13
13
  from .paths import runs_dir_of
14
14
  from .invocation import save_invocation
15
15
  from .jsonl import append_jsonl, read_jsonl, rotate_recent_if_needed
16
+ from .json_boundary import JsonBoundaryError, load_owned_object, write_owned_object_atomic
16
17
  from .project_meta import _project_meta_path
17
18
  from .reconcile import _now_iso, normalize_central_status
18
19
  from .run_index_row import build_run_index_row
@@ -64,8 +65,8 @@ def discover_project_roots(home: Path) -> List[tuple]:
64
65
  if not meta_file.is_file():
65
66
  continue
66
67
  try:
67
- meta = json.loads(meta_file.read_text())
68
- except (OSError, json.JSONDecodeError):
68
+ meta = load_owned_object(meta_file, artifact="project metadata")
69
+ except JsonBoundaryError:
69
70
  continue
70
71
  pid = str(meta.get("projectId") or "")
71
72
  root = str(meta.get("projectRoot") or "")
@@ -89,7 +90,7 @@ def _apply_backfill_meta(home: Path, project_id: str, project_root: Path, *,
89
90
  target = _project_meta_path(home, project_id)
90
91
  target.parent.mkdir(parents=True, exist_ok=True)
91
92
  if target.is_file():
92
- meta = json.loads(target.read_text())
93
+ meta = load_owned_object(target, artifact="project metadata")
93
94
  else:
94
95
  meta = {"projectId": project_id, "projectRoot": str(project_root),
95
96
  "firstRunAt": "", "lastRunAt": "",
@@ -108,9 +109,7 @@ def _apply_backfill_meta(home: Path, project_id: str, project_root: Path, *,
108
109
  meta["runCount"] = int(meta.get("runCount", 0)) + run_count_inc
109
110
  meta["activeCount"] = max(
110
111
  0, int(meta.get("activeCount", 0)) + active_count_inc)
111
- tmp = target.with_suffix(".json.tmp")
112
- tmp.write_text(json.dumps(meta, indent=2) + "\n")
113
- os.replace(tmp, target)
112
+ write_owned_object_atomic(target, meta, artifact="project metadata")
114
113
 
115
114
 
116
115
  def backfill_project(home: Path, project_id: str, project_root: Path) -> int:
@@ -142,8 +141,8 @@ def backfill_project(home: Path, project_id: str, project_root: Path) -> int:
142
141
  task_type = m.group("tt")
143
142
  seq = int(m.group("seq"))
144
143
  try:
145
- manifest = json.loads(mf.read_text())
146
- except (OSError, json.JSONDecodeError):
144
+ manifest = load_owned_object(mf, artifact="run manifest")
145
+ except JsonBoundaryError:
147
146
  # 부분 기록 / 손상된 매니페스트를 빈 dict 로 강등해 row 를
148
147
  # 만들면, 빈 status 가 'completed' 로 normalize 되어
149
148
  # recent 에 박히고, 같은 runId 가 existing_ids 로 인덱싱
@@ -268,6 +267,10 @@ def backfill_project(home: Path, project_id: str, project_root: Path) -> int:
268
267
 
269
268
  def mark_backfilled(home: Path) -> None:
270
269
  state_file = home / "state.json"
271
- state = json.loads(state_file.read_text()) if state_file.is_file() else {}
270
+ state = (
271
+ load_owned_object(state_file, artifact="okstra home state")
272
+ if state_file.is_file()
273
+ else {}
274
+ )
272
275
  state["backfilledAt"] = _now_iso()
273
- state_file.write_text(json.dumps(state, indent=2) + "\n")
276
+ write_owned_object_atomic(state_file, state, artifact="okstra home state")
@@ -9,6 +9,7 @@ from pathlib import Path
9
9
  from typing import List, Optional
10
10
 
11
11
  from .listing import list_runs
12
+ from .json_boundary import write_owned_object_atomic
12
13
  from .resolver import resolve_last, resolve_run_id
13
14
 
14
15
 
@@ -20,10 +21,7 @@ def make_batch_id() -> str:
20
21
 
21
22
  def write_batch_meta(home: Path, batch_id: str, payload: dict) -> Path:
22
23
  target = home / "batches" / f"{batch_id}.json"
23
- target.parent.mkdir(parents=True, exist_ok=True)
24
- tmp = target.with_suffix(".json.tmp")
25
- tmp.write_text(json.dumps(payload, indent=2) + "\n")
26
- os.replace(tmp, target)
24
+ write_owned_object_atomic(target, payload, artifact="batch metadata")
27
25
  return target
28
26
 
29
27
 
@@ -16,6 +16,8 @@ import json
16
16
  import shlex
17
17
  from pathlib import Path
18
18
 
19
+ from .json_boundary import JsonBoundaryError, load_owned_object
20
+
19
21
  DEFAULT_BUILD_TOOL_TOKENS: tuple[str, ...] = (
20
22
  "npm",
21
23
  "yarn",
@@ -49,10 +51,11 @@ def resolve_build_tool_tokens(project_root: Path) -> tuple[str, ...]:
49
51
  uses, so one rule covers both.
50
52
  """
51
53
  try:
52
- payload = json.loads(
53
- (Path(project_root) / ".okstra" / "project.json").read_text(encoding="utf-8")
54
+ payload = load_owned_object(
55
+ Path(project_root) / ".okstra" / "project.json",
56
+ artifact="project configuration",
54
57
  )
55
- except (OSError, json.JSONDecodeError):
58
+ except JsonBoundaryError:
56
59
  return DEFAULT_BUILD_TOOL_TOKENS
57
60
  configured = payload.get("buildToolTokens") if isinstance(payload, dict) else None
58
61
  if not isinstance(configured, list):
@@ -0,0 +1,101 @@
1
+ """사실 주장의 재현.
2
+
3
+ 계획 검증의 1표 차단(`kind a`, `P-Req` 의 `kind f`)에는 근거가 있다 — 명시된 두
4
+ 인용이 서로 모순이라는 것은 한 명이 실측으로 확정할 수 있는 사실이고, 사실을
5
+ 다수결로 기각하면 안 된다. 문제는 1표라는 것이 아니라 **1표에 재현 요구가 없다**는
6
+ 것이었다. 검증자가 "이 경로는 존재하지 않는다" 라고 쓰면 그대로 차단이 됐다.
7
+
8
+ 여기서 재현은 워커가 아니라 이 코드가 실행한다. 워커가 결과까지 신고하면 판정이
9
+ 자기신고 위에 서고, 같은 주장이 워커마다 다르게 결론난다.
10
+
11
+ 대가는 표현력이다. 여기서 돌릴 수 있는 형태로만 사실 주장을 쓸 수 있고, 그 밖의
12
+ 것은 `judgement` 로 내어 정족수로 판정한다. 강등이 아니라 분류다 — 기계가 확인할
13
+ 수 없는 것에 1표 차단을 주는 것이 애초에 틀렸다.
14
+ """
15
+ from __future__ import annotations
16
+
17
+ from pathlib import Path
18
+ from typing import Any, Mapping
19
+
20
+ REPRODUCED = "reproduced"
21
+ NOT_REPRODUCED = "not-reproduced"
22
+ NOT_RUNNABLE = "not-runnable"
23
+
24
+ PROBE_KINDS = (
25
+ "path-exists",
26
+ "path-absent",
27
+ "literal-present",
28
+ "literal-absent",
29
+ "citations-differ",
30
+ )
31
+
32
+ _MAX_READ_BYTES = 4_000_000
33
+
34
+
35
+ def _within(project_root: Path, value: object) -> Path | None:
36
+ """프로젝트 안의 경로. 밖으로 나가거나 모양이 아니면 ``None``.
37
+
38
+ 재현은 검증자가 쓴 문자열을 그대로 받는다. 담아 두지 않으면 그 문자열 하나로
39
+ 프로젝트 밖을 읽게 된다.
40
+ """
41
+ if not isinstance(value, str) or not value.strip():
42
+ return None
43
+ candidate = Path(value)
44
+ resolved = (
45
+ candidate if candidate.is_absolute() else project_root / candidate
46
+ )
47
+ try:
48
+ resolved = resolved.resolve()
49
+ root = project_root.resolve()
50
+ except (OSError, RuntimeError):
51
+ return None
52
+ return resolved if resolved == root or root in resolved.parents else None
53
+
54
+
55
+ def _read(path: Path) -> str | None:
56
+ try:
57
+ if not path.is_file() or path.stat().st_size > _MAX_READ_BYTES:
58
+ return None
59
+ return path.read_text(encoding="utf-8", errors="replace")
60
+ except (OSError, ValueError):
61
+ return None
62
+
63
+
64
+ def reproduce(probe: object, *, project_root: Path) -> str:
65
+ """이 프로브를 실행한 결과.
66
+
67
+ ``reproduced`` 는 주장이 성립한다는 뜻이고, ``not-reproduced`` 는 근거가
68
+ 성립하지 않는다는 뜻이다. ``not-runnable`` 은 판정하지 않았다는 뜻이며 —
69
+ 프로브의 모양이 아니거나, 경로가 프로젝트 밖이거나, 파일을 못 읽은 경우다 —
70
+ 소비처는 이것을 `judgement` 와 같게 다뤄야 한다. 못 돌린 것을 재현 실패로
71
+ 읽으면 도구의 한계가 주장의 기각이 된다.
72
+ """
73
+ if not isinstance(probe, Mapping):
74
+ return NOT_RUNNABLE
75
+ kind = str(probe.get("kind") or "")
76
+ if kind not in PROBE_KINDS:
77
+ return NOT_RUNNABLE
78
+
79
+ if kind in ("path-exists", "path-absent"):
80
+ path = _within(project_root, probe.get("path"))
81
+ if path is None:
82
+ return NOT_RUNNABLE
83
+ exists = path.exists()
84
+ return REPRODUCED if exists == (kind == "path-exists") else NOT_REPRODUCED
85
+
86
+ if kind in ("literal-present", "literal-absent"):
87
+ path = _within(project_root, probe.get("path"))
88
+ literal = probe.get("literal")
89
+ if path is None or not isinstance(literal, str) or not literal:
90
+ return NOT_RUNNABLE
91
+ text = _read(path)
92
+ if text is None:
93
+ return NOT_RUNNABLE
94
+ present = literal in text
95
+ return REPRODUCED if present == (kind == "literal-present") else NOT_REPRODUCED
96
+
97
+ left, right = probe.get("left"), probe.get("right")
98
+ if not isinstance(left, str) or not isinstance(right, str) or not left or not right:
99
+ return NOT_RUNNABLE
100
+ # 주장은 "이 둘이 서로 모순이다" 이므로, 다르면 재현된 것이다.
101
+ return REPRODUCED if left.strip() != right.strip() else NOT_REPRODUCED
@@ -37,6 +37,7 @@ from pathlib import Path
37
37
  from typing import Optional
38
38
 
39
39
  from okstra_ctl.final_report_paths import final_report_data_path
40
+ from okstra_ctl.json_boundary import load_owned_object
40
41
  from okstra_ctl.md_table import is_separator_row, split_pipe_row, to_cell_text
41
42
 
42
43
 
@@ -224,28 +225,41 @@ def parse_clarification_items(report_text: str) -> Optional[list[ClarificationIt
224
225
  # 워크는 거기서 아무것도 못 찾으므로, 게이트가 v1 파서만 쓰면 열린 항목이 있는
225
226
  # v2 리포트를 "읽을 수 없음"으로 떨어뜨린다. data.json 은 두 렌더러가 이미 읽는
226
227
  # SSOT 다 — clarification 상태도 여기서 읽는다.
227
- SCHEMA_VERSION_V2 = "2.0"
228
+ # 리포트 정본의 구조화 판본. 2.0 과 3.0 은 `clarificationItems[]` 를 같은 자리에
229
+ # 같은 모양으로 들고 있으므로 이 좁히기에는 차이가 없다. ADR-0019 가 2.0 을 계속
230
+ # 판독하라고 정했으므로 둘 다 받는다 — `stage_map.structured_report` 와 같은 규칙.
231
+ STRUCTURED_REPORT_VERSIONS = frozenset({"2.0", "3.0"})
228
232
 
229
233
 
230
234
  def _read_report_text(report_path: Path) -> str:
231
235
  return report_path.read_text(encoding="utf-8", errors="replace")
232
236
 
233
237
 
234
- def _v2_report_data(report_path: Path) -> Optional[dict]:
235
- """``report_path`` 의 schema-v2 data 사이드카, 아니면 ``None``.
238
+ def _structured_report_data(report_path: Path) -> Optional[dict]:
239
+ """``report_path`` 의 구조화 리포트 레코드, 아니면 ``None``.
236
240
 
237
- 파일이 없거나 JSON 이 깨졌거나 v2 가 아니면 ``None`` — 호출자는 v1
238
- 마크다운 경로로 폴백하고, 진짜 v2 인데 사이드카가 깨진 경우는 그 폴백이
239
- "§1 없음" fail-closed 로 잡는다.
241
+ 파일이 없거나 JSON 이 깨졌거나 구조화 판본이 아니면 ``None`` — 호출자는 v1
242
+ 마크다운 경로로 폴백하고, 진짜 구조화 리포트인데 레코드가 깨진 경우는 그
243
+ 폴백이 "§1 없음" fail-closed 로 잡는다.
244
+
245
+ 판본을 `2.0` 하나로 못박아 두던 동안 3.0 리포트가 여기서 ``None`` 이 됐고,
246
+ 그러면 호출자가 JSON 본문에서 `## 1.` 헤딩을 찾다 실패해 **리포트 전문을
247
+ 그대로 복사**했다 — 이 좁히기가 막으려던 바로 그 중복이다. 실측: 66K 짜리
248
+ 추출본이 나와야 할 자리에 693K data.json 이 스테이징됐고, 패킷의
249
+ `## Clarification Carry-In Extract` 가 0행이 되어 워커가 사용자 답변을
250
+ 전혀 받지 못했다.
240
251
  """
241
252
  data_path = final_report_data_path(report_path)
242
253
  if not data_path.is_file():
243
254
  return None
244
255
  try:
245
- data = json.loads(data_path.read_text(encoding="utf-8"))
256
+ data = load_owned_object(data_path, artifact="final report record")
246
257
  except (OSError, ValueError):
247
258
  return None
248
- if not isinstance(data, dict) or data.get("schemaVersion") != SCHEMA_VERSION_V2:
259
+ if (
260
+ not isinstance(data, dict)
261
+ or data.get("schemaVersion") not in STRUCTURED_REPORT_VERSIONS
262
+ ):
249
263
  return None
250
264
  return data
251
265
 
@@ -287,7 +301,7 @@ def _v2_row(entry: dict) -> Optional[dict]:
287
301
  def _v2_clarification_rows(report_path: Path) -> Optional[list[dict]]:
288
302
  """schema-v2 clarification 행들, 이 리포트가 v2 가 아니면 ``None``.
289
303
  필수 필드가 빠진 행은 건너뛰는 lenient 계약(§1 파서와 동일)."""
290
- data = _v2_report_data(report_path)
304
+ data = _structured_report_data(report_path)
291
305
  if data is None:
292
306
  return None
293
307
  entries = data.get("clarificationItems")
@@ -503,7 +517,7 @@ def _scan_v2_blockers(
503
517
  ) -> Optional[ClarificationScan]:
504
518
  """schema-v2 data.json 기준 스캔, 이 리포트가 v2 가 아니면 ``None``.
505
519
  필수 필드가 빠진 행은 §1 의 unparsed row 와 같이 fail-closed 로 다룬다."""
506
- data = _v2_report_data(report_path)
520
+ data = _structured_report_data(report_path)
507
521
  if data is None:
508
522
  return None
509
523
  entries = data.get("clarificationItems")
@@ -758,10 +772,10 @@ def _carry_section_1(source: Path, text: str) -> Optional[tuple[str, str]]:
758
772
  복사**됐다 — 이 좁히기가 막으려던 바로 그 중복이다. carry-in 은 파생 문서이고
759
773
  다운스트림(승인 게이트·프롬프트 빌더·검증 워커)이 §1 표 하나만 읽으므로, v2
760
774
  행도 같은 표로 렌더한다."""
761
- data = _v2_report_data(source)
775
+ data = _structured_report_data(source)
762
776
  if data is not None:
763
777
  entries = data.get("clarificationItems")
764
- return SECTION_1_HEADING, _v2_section_1_body(
778
+ return SECTION_1_HEADING, _structured_section_1_body(
765
779
  entries if isinstance(entries, list) else []
766
780
  )
767
781
  slice_ = _section_1_slice(text)
@@ -772,7 +786,7 @@ def _carry_section_1(source: Path, text: str) -> Optional[tuple[str, str]]:
772
786
  return heading.group(0), slice_.rstrip()
773
787
 
774
788
 
775
- def _v2_section_1_body(entries: list) -> str:
789
+ def _structured_section_1_body(entries: list) -> str:
776
790
  """schema-v2 `clarificationItems[]` 를 §1 표 본문으로.
777
791
 
778
792
  메타 셀은 렌더러가 쓰는 모양 그대로다 — `Status:` 는 따옴표 없이 써야