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
@@ -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:` 는 따옴표 없이 써야
@@ -32,9 +32,9 @@ SHIM_DIR_MARKER = "cmux-cli-shims"
32
32
 
33
33
  # The workspace is split in fifths: two for the lead, three for the workers.
34
34
  # Every worker lands in that one column and the column divides downward, so all
35
- # workers hold the same width and only one border — the lead's — is ever
36
- # computed. Splitting sideways instead would make each worker's width a
37
- # function of how many rounds preceded it.
35
+ # workers hold the same width. Splitting sideways instead would make each
36
+ # worker's width a function of how many rounds preceded it. The split itself
37
+ # halves the pane it lands on; worker heights are evened out afterwards.
38
38
  LEAD_SHARE_WITH_WORKERS = 2 / 5
39
39
 
40
40
  # Three fifths is what a worker needs to be worth watching: measured against a
@@ -46,10 +46,9 @@ LEAD_SHARE_WITH_WORKERS = 2 / 5
46
46
  # whole workspace back rather than sitting at its working width.
47
47
  LEAD_SHARE_ALONE = 1.0
48
48
 
49
- # A floor for the dimension the worker column actually divides. A pane
50
- # this short still shows a command and its first lines of output; below it the
51
- # pane stops being a window onto the worker and the next one stacks as a tab.
52
- WORKER_MIN_ROWS = 20
49
+ # Two passes per pane above the bottom. A roster of four analysers is four
50
+ # workers in the column; eight moves is that bound with one extra pass.
51
+ MAX_WORKER_HEIGHT_MOVES = 8
53
52
 
54
53
  # Sidebar entries are keyed by source so tools do not overwrite each other's.
55
54
  SIDEBAR_SOURCE = "okstra"
@@ -163,15 +162,29 @@ class PaneGeometry:
163
162
  width_points: float = 0.0
164
163
  ref: str = ""
165
164
  selected_surface_id: str = ""
165
+ height_points: float = 0.0
166
166
 
167
167
 
168
168
  @dataclass(frozen=True)
169
169
  class Placement:
170
- """Where the next worker goes: split `pane_id`, or stack a tab into it."""
170
+ """Where the next worker goes: split `pane_id` in `direction`."""
171
171
 
172
172
  pane_id: str
173
173
  direction: str
174
- stack_as_tab: bool
174
+
175
+
176
+ @dataclass(frozen=True)
177
+ class WorkerHeightResize:
178
+ """One vertical border move that equalizes the worker column.
179
+
180
+ `down` on a pane grows it into the neighbour below. `up` on a pane pulls
181
+ the shared border up, shrinking the neighbour above — the top pane has no
182
+ upper border, so shrinking it is the pane below's request.
183
+ """
184
+
185
+ pane_id: str
186
+ direction: str
187
+ amount: int
175
188
 
176
189
 
177
190
  def plan_worker_placement(
@@ -179,15 +192,14 @@ def plan_worker_placement(
179
192
  *,
180
193
  lead_pane_id: str,
181
194
  owned_surface_ids: Collection[str],
182
- min_rows: int,
183
195
  ) -> Placement:
184
196
  """Pick the next worker slot from the workspace's current geometry.
185
197
 
186
198
  One column for every worker: the first split takes it off the lead, and each
187
- one after that divides the column downward. Width is therefore decided once,
188
- by where the lead's border sits, and every worker inherits it — nothing here
189
- computes a width, and no worker's width depends on how many rounds ran
190
- before it.
199
+ one after that divides the worker next to the lead downward. Width is
200
+ therefore decided once, by where the lead's border sits, and every worker
201
+ inherits it — nothing here computes a width, and no worker's width depends
202
+ on how many rounds ran before it.
191
203
 
192
204
  Stateless by design: okstra records surface UUIDs, never a layout, so a
193
205
  resumed or crashed run cannot carry a layout model that no longer matches
@@ -207,8 +219,8 @@ def plan_worker_placement(
207
219
  and _holds_an_okstra_surface(pane, owned_surface_ids)
208
220
  ]
209
221
  if not workers:
210
- return Placement(pane_id=lead_pane_id, direction="right", stack_as_tab=False)
211
- return _extend_the_worker_column(workers, min_rows=min_rows)
222
+ return Placement(pane_id=lead_pane_id, direction="right")
223
+ return _extend_the_worker_column(workers)
212
224
 
213
225
 
214
226
  def lead_target_width(
@@ -273,28 +285,52 @@ def _holds_an_okstra_surface(
273
285
  ) -> bool:
274
286
  """Whether okstra opened anything in this pane.
275
287
 
276
- Any one recorded surface is enough: workers that stacked as tabs share a
277
- pane, and only the tab that opened it carries the UUID okstra split on.
288
+ Any one recorded surface is enough: a pane may hold more than one surface,
289
+ and only one of those UUIDs has to be in the ledger.
278
290
  """
279
291
  return any(surface_id in owned_surface_ids for surface_id in pane.surface_ids)
280
292
 
281
293
 
282
- def _extend_the_worker_column(
283
- workers: Sequence[PaneGeometry], *, min_rows: int
284
- ) -> Placement:
285
- """Divide the bottom worker, or stack when the halves would be unreadable.
294
+ def _extend_the_worker_column(workers: Sequence[PaneGeometry]) -> Placement:
295
+ """Divide the worker next to the lead downward. A tab would hide the new worker.
296
+
297
+ The first worker is split off the lead, so it stays at the lead's y. Later
298
+ workers stack below it. Splitting the bottom one parks each new pane at the
299
+ far end of the column; splitting the neighbour keeps the new pane next to
300
+ the lead.
301
+ """
302
+ neighbour = min(workers, key=lambda pane: pane.y)
303
+ return Placement(pane_id=neighbour.pane_id, direction="down")
304
+
305
+
306
+ def plan_worker_height_resize(
307
+ workers: Sequence[PaneGeometry],
308
+ ) -> WorkerHeightResize | None:
309
+ """The next vertical move that gives every worker an equal share of the column.
310
+
311
+ Only the first off-share pane above the bottom is reported. Growing or
312
+ shrinking it changes every pane below, so the caller re-reads and asks
313
+ again. The bottom pane is never the one named: it absorbs the remainder.
286
314
 
287
- A split halves the pane it lands on, so the bottom pane's own height decides
288
- whether the column can take another worker at all. Once it cannot, further
289
- workers stack as tabs into the pane holding the fewest — a background tab is
290
- worse than a visible pane, but better than two panes too short to show a
291
- command and its first lines of output.
315
+ `new-split` halves the pane it lands on and does not take a ratio, so a
316
+ third worker split off the top would otherwise stay at a quarter while
317
+ the bottom one keeps a half.
292
318
  """
293
- bottom = max(workers, key=lambda pane: pane.y)
294
- if bottom.rows // 2 >= min_rows:
295
- return Placement(pane_id=bottom.pane_id, direction="down", stack_as_tab=False)
296
- roomiest = min(workers, key=lambda pane: (len(pane.surface_ids), pane.y))
297
- return Placement(pane_id=roomiest.pane_id, direction="", stack_as_tab=True)
319
+ if len(workers) < 2:
320
+ return None
321
+ ordered = sorted(workers, key=lambda pane: pane.y)
322
+ total = sum(pane.height_points for pane in ordered)
323
+ if total <= 0:
324
+ return None
325
+ share = total / len(ordered)
326
+ for index, pane in enumerate(ordered[:-1]):
327
+ delta = round(pane.height_points - share)
328
+ if delta == 0:
329
+ continue
330
+ if delta < 0:
331
+ return WorkerHeightResize(pane.pane_id, "down", -delta)
332
+ return WorkerHeightResize(ordered[index + 1].pane_id, "up", delta)
333
+ return None
298
334
 
299
335
 
300
336
  def shim_free_login_path() -> str:
@@ -331,6 +367,17 @@ def worker_command_line(
331
367
  The `cd` is part of the command because neither `new-split` nor
332
368
  `respawn-pane` accepts a working directory the way `tmux split-window -c`
333
369
  does.
370
+
371
+ `exec` is here for termination, not for the pane's life, and removing it
372
+ does not keep a finished worker's pane on screen. Measured 2026-08-21: a
373
+ surface's life is bound to the command `respawn-pane` starts — cmux runs it
374
+ through `login … bash --noprofile --norc` and closes the surface when it
375
+ exits — and cmux's settings schema carries no option that keeps a surface
376
+ open past its process. Dropping `exec` was tried against a live dispatch and
377
+ the pane still went with the worker; what it changed was that
378
+ `close_surface` would kill an intermediate shell rather than the worker
379
+ itself. So the adapter contract's "finished workers leave their panes
380
+ behind" does not hold under cmux, and no edit to this line makes it hold.
334
381
  """
335
382
  return (
336
383
  f"cd {shlex.quote(str(cwd))} && "
@@ -366,13 +413,14 @@ def spawn_worker_surface(
366
413
  panes,
367
414
  lead_pane_id=lead.pane_id,
368
415
  owned_surface_ids=owned_surface_ids,
369
- min_rows=WORKER_MIN_ROWS,
370
416
  )
371
417
  target = _pane_by_id(panes, placement.pane_id)
372
418
  surface_uuid = _open_worker_surface(workspace, placement, target)
373
419
  run_cmux(["rename-tab", "--surface", surface_uuid, "--title", title])
374
420
  _exec_worker(surface_uuid, cwd=cwd, command=command)
375
- _size_lead_pane(workspace, (*owned_surface_ids, surface_uuid))
421
+ owned = (*owned_surface_ids, surface_uuid)
422
+ _size_lead_pane(workspace, owned)
423
+ _equalize_worker_heights(workspace, owned)
376
424
  return surface_uuid
377
425
 
378
426
 
@@ -575,21 +623,16 @@ def open_surface_ids(workspace: str) -> set[str]:
575
623
  def _create_surface(
576
624
  workspace: str, placement: Placement, target: PaneGeometry
577
625
  ) -> None:
578
- if placement.stack_as_tab:
579
- created = run_cmux(
580
- ["new-surface", "--workspace", workspace, "--pane", target.pane_id]
581
- )
582
- else:
583
- created = run_cmux(
584
- [
585
- "new-split",
586
- placement.direction,
587
- "--workspace",
588
- workspace,
589
- "--surface",
590
- target.selected_surface_id or target.surface_ids[0],
591
- ]
592
- )
626
+ created = run_cmux(
627
+ [
628
+ "new-split",
629
+ placement.direction,
630
+ "--workspace",
631
+ workspace,
632
+ "--surface",
633
+ target.selected_surface_id or target.surface_ids[0],
634
+ ]
635
+ )
593
636
  if created.returncode != 0:
594
637
  raise RuntimeError(created.stderr.strip() or "cmux could not open a pane")
595
638
 
@@ -606,9 +649,10 @@ def _exec_worker(surface_uuid: str, *, cwd: Path, command: Sequence[str]) -> Non
606
649
  def _size_lead_pane(workspace: str, owned_surface_ids: Collection[str]) -> None:
607
650
  """Move the lead's border to its share of the workspace.
608
651
 
609
- This is the only border okstra places. Workers divide their column downward
610
- and therefore all inherit whatever is left of it, so placing this one border
611
- sizes every pane on screen.
652
+ This is the horizontal border okstra places. Workers divide their column
653
+ downward and inherit the remaining width, so this one move sizes the lead
654
+ and every worker's width. Worker heights are equalized separately after
655
+ the split that just halved one of them.
612
656
 
613
657
  Which pane carries the request follows from what `pane.resize` does: it
614
658
  moves the named pane's own border in the direction given. The lead can push
@@ -659,6 +703,39 @@ def _size_lead_pane(workspace: str, owned_surface_ids: Collection[str]) -> None:
659
703
  )
660
704
 
661
705
 
706
+ def _equalize_worker_heights(
707
+ workspace: str, owned_surface_ids: Collection[str]
708
+ ) -> None:
709
+ """Give every owned worker the same height in the column.
710
+
711
+ `new-split` halves whichever pane it lands on, so the third worker of a
712
+ round would otherwise sit at a quarter with the bottom one still at a half.
713
+ Each call moves one border and re-reads: growing the top pane steals from
714
+ the one below, and that is what the next plan has to see.
715
+ """
716
+ for _ in range(MAX_WORKER_HEIGHT_MOVES):
717
+ panes = list_panes(workspace)
718
+ lead = _lead_pane(panes)
719
+ workers = [
720
+ pane
721
+ for pane in panes
722
+ if pane.pane_id != lead.pane_id
723
+ and _holds_an_okstra_surface(pane, owned_surface_ids)
724
+ ]
725
+ move = plan_worker_height_resize(workers)
726
+ if move is None:
727
+ return
728
+ rpc(
729
+ "pane.resize",
730
+ {
731
+ "workspace_id": workspace,
732
+ "pane_id": move.pane_id,
733
+ "direction": move.direction,
734
+ "amount": move.amount,
735
+ },
736
+ )
737
+
738
+
662
739
  def _lead_pane(panes: Sequence[PaneGeometry]) -> PaneGeometry:
663
740
  caller_pane_ref = identify_caller().get("pane_ref", "")
664
741
  for pane in panes:
@@ -706,6 +783,7 @@ def _pane_geometry(entry: dict[str, Any]) -> PaneGeometry:
706
783
  width_points=float(frame.get("width") or 0),
707
784
  ref=str(entry.get("ref", "")),
708
785
  selected_surface_id=str(entry.get("selected_surface_id", "")),
786
+ height_points=float(frame.get("height") or 0),
709
787
  )
710
788
 
711
789
 
@@ -24,6 +24,7 @@ from okstra_project import (
24
24
  )
25
25
 
26
26
  from .code_review_paths import branch_review_dir, next_branch_review
27
+ from .fixed_text import line
27
28
 
28
29
  DEFAULT_BRANCH_CANDIDATES = ("main", "master")
29
30
 
@@ -105,7 +106,9 @@ def _build_parser() -> argparse.ArgumentParser:
105
106
  parser.add_argument("--date", default="", help="YYYY-MM-DD for branch result naming")
106
107
  parser.add_argument("--project-root", default="", help="explicit project root")
107
108
  parser.add_argument("--cwd", default=".", help="cwd for project root resolution")
108
- parser.add_argument("--json", action="store_true", help="emit JSON (always on)")
109
+ output = parser.add_mutually_exclusive_group()
110
+ output.add_argument("--json", action="store_true", help="emit JSON (default)")
111
+ output.add_argument("--text", action="store_true", help="emit fixed text fields")
109
112
  return parser
110
113
 
111
114
 
@@ -121,6 +124,25 @@ def _resolve(args: argparse.Namespace, project_root: Path) -> dict:
121
124
  raise StateError("pass either --task-key --stage or --branch", stage="args")
122
125
 
123
126
 
127
+ def render_code_review_target_text(payload: dict) -> str:
128
+ """모델이 소비할 리뷰 범위와 결과 경로만 고정 줄로 투영한다."""
129
+ rows = ["Okstra code review target\n"]
130
+ rows.append(line("Status", "ready" if payload.get("ok") else "error"))
131
+ fields = (
132
+ ("Project root", "projectRoot"), ("Mode", "mode"),
133
+ ("Worktree path", "worktreePath"), ("Branch", "branch"),
134
+ ("Base commit", "baseCommit"), ("Head commit", "headCommit"),
135
+ ("Review path", "reviewPath"), ("Round", "round"),
136
+ ("Task key", "taskKey"), ("Task root", "taskRoot"), ("Stage", "stage"),
137
+ ("Failure stage", "stage"), ("Failure reason", "reason"),
138
+ )
139
+ for label, key in fields:
140
+ failure_field = label.startswith("Failure")
141
+ if key in payload and failure_field != bool(payload.get("ok")):
142
+ rows.append(line(label, payload.get(key)))
143
+ return "".join(rows)
144
+
145
+
124
146
  def main(argv: list[str] | None = None) -> int:
125
147
  args = _build_parser().parse_args(argv)
126
148
  try:
@@ -128,23 +150,27 @@ def main(argv: list[str] | None = None) -> int:
128
150
  explicit_root=args.project_root, cwd=args.cwd
129
151
  )
130
152
  except ResolverError as exc:
131
- print(json.dumps({"ok": False, "stage": "resolve", "reason": str(exc)}))
153
+ payload = {"ok": False, "stage": "resolve", "reason": str(exc)}
154
+ print(render_code_review_target_text(payload) if args.text else json.dumps(payload))
132
155
  return 2
133
156
 
134
157
  try:
135
158
  snapshot = _resolve(args, Path(project_root))
136
159
  except StateError as exc:
137
- print(json.dumps({
160
+ payload = {
138
161
  "ok": False,
139
162
  "stage": exc.stage or "target",
140
163
  "reason": str(exc),
141
- }))
164
+ }
165
+ print(render_code_review_target_text(payload) if args.text else json.dumps(payload))
142
166
  return 1
143
167
 
144
- print(json.dumps(
145
- {"ok": True, "projectRoot": str(project_root), **snapshot},
146
- ensure_ascii=False, indent=2,
147
- ))
168
+ payload = {"ok": True, "projectRoot": str(project_root), **snapshot}
169
+ print(
170
+ render_code_review_target_text(payload)
171
+ if args.text
172
+ else json.dumps(payload, ensure_ascii=False, indent=2)
173
+ )
148
174
  return 0
149
175
 
150
176
 
@@ -16,6 +16,8 @@ from dataclasses import dataclass
16
16
  from pathlib import Path
17
17
  from typing import Any, Dict, List, Optional
18
18
 
19
+ from .json_boundary import JsonBoundaryError, load_owned_object
20
+ from .jsonl import append_jsonl
19
21
  from .run_context import consumers_mutex
20
22
 
21
23
  CONSUMERS_FILENAME = "consumers.jsonl"
@@ -265,8 +267,7 @@ def _release_stage_occupancy_keeping_branch(
265
267
 
266
268
 
267
269
  def _append_row(plan_run_root: Path, record: Dict[str, Any]) -> None:
268
- with _path(plan_run_root).open("a", encoding="utf-8") as f:
269
- f.write(json.dumps(record, ensure_ascii=False) + "\n")
270
+ append_jsonl(_path(plan_run_root), record, ensure_ascii=False, compact=False)
270
271
 
271
272
 
272
273
  def append_verified(plan_run_root: Path, *, impl_task_key: str, stage: int,
@@ -414,8 +415,8 @@ def backfill_done_from_carry(plan_run_root: Path) -> int:
414
415
  recovered = 0
415
416
  for cf in sorted(carry_dir.glob("stage-*.json")):
416
417
  try:
417
- carry = json.loads(cf.read_text(encoding="utf-8"))
418
- except (json.JSONDecodeError, OSError):
418
+ carry = load_owned_object(cf, artifact="implementation stage carry")
419
+ except (JsonBoundaryError, OSError):
419
420
  continue
420
421
  if not isinstance(carry, dict):
421
422
  continue