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
@@ -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
@@ -28,6 +28,8 @@ from typing import Callable, Iterable
28
28
 
29
29
  from .stage_targets import PrepareError # 단일 PrepareError 타입 재노출(run.py 와 공유)
30
30
  from .stage_map import StageMapError, parse_stage_map_file, stage_map_records
31
+ from .json_boundary import JsonBoundaryError, load_owned_object, write_owned_object_atomic
32
+ from .fixed_text import line
31
33
 
32
34
  OKSTRA_LABEL_KEYS = ("okstra.task-key", "okstra.project-name", "okstra.run-trace")
33
35
 
@@ -426,13 +428,19 @@ def query_containers_by_label(project_name: str) -> list[dict]:
426
428
  out = _docker_run([
427
429
  "docker", "ps", "-a", "--filter",
428
430
  f"label=okstra.project-name={project_name}",
429
- "--format", "{{.ID}}\t{{.Names}}\t{{.State}}",
431
+ "--format", (
432
+ "{{.ID}}\t{{.Names}}\t{{.State}}\t"
433
+ '{{.Label "com.docker.compose.service"}}\t{{.Ports}}'
434
+ ),
430
435
  ])
431
436
  rows: list[dict] = []
432
437
  for line in out.splitlines():
433
438
  parts = line.split("\t")
434
- if len(parts) >= 3:
435
- rows.append({"id": parts[0], "name": parts[1], "state": parts[2]})
439
+ if len(parts) >= 5:
440
+ rows.append({
441
+ "id": parts[0], "name": parts[1], "state": parts[2],
442
+ "service": parts[3], "ports": parts[4],
443
+ })
436
444
  return rows
437
445
 
438
446
 
@@ -774,8 +782,7 @@ def _spawn_service_panes(
774
782
 
775
783
  def _write_deploy_state(cp: dict, **state) -> None:
776
784
  cp["container_dir"].mkdir(parents=True, exist_ok=True)
777
- cp["deploy_state"].write_text(
778
- json.dumps(state, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
785
+ write_owned_object_atomic(cp["deploy_state"], state, artifact="container deploy state")
779
786
 
780
787
 
781
788
  # --------------------------------------------------------------------------- #
@@ -879,8 +886,10 @@ def _read_project_id(project_json: Path) -> str:
879
886
  if not project_json.is_file():
880
887
  return ""
881
888
  try:
882
- return json.loads(project_json.read_text(encoding="utf-8")).get("projectId", "")
883
- except (OSError, json.JSONDecodeError):
889
+ return load_owned_object(project_json, artifact="project configuration").get(
890
+ "projectId", ""
891
+ )
892
+ except JsonBoundaryError:
884
893
  return ""
885
894
 
886
895
 
@@ -928,6 +937,7 @@ def main(argv: list[str] | None = None) -> int:
928
937
  sp = sub.add_parser(name)
929
938
  sp.add_argument("--project-root", required=True)
930
939
  sp.add_argument("--task-key", default="")
940
+ sp.add_argument("--text", action="store_true")
931
941
  if name == "down":
932
942
  sp.add_argument("--all", action="store_true")
933
943
  if name == "logs":
@@ -944,10 +954,95 @@ def main(argv: list[str] | None = None) -> int:
944
954
  findings_path=args.findings, scan_interval_seconds=args.scan_interval)
945
955
  return 0
946
956
  result = _dispatch(args)
947
- print(json.dumps(result, ensure_ascii=False, indent=2))
957
+ print(
958
+ render_container_text(args.command, result)
959
+ if args.text
960
+ else json.dumps(result, ensure_ascii=False, indent=2)
961
+ )
948
962
  return 0
949
963
 
950
964
 
965
+ def render_container_text(command: str, payload: dict) -> str:
966
+ """container 명령별 승인된 상태만 고정 순서로 투영한다."""
967
+ if command not in {"up", "status", "logs", "stop-watcher", "down"}:
968
+ raise ValueError("unknown container text purpose")
969
+ rows = [f"Okstra container {command}\n", line("Status", "ready")]
970
+ for label, key in (("Project name", "projectName"), ("Watchers dir", "watchersDir"),
971
+ ("Note", "note")):
972
+ if key in payload:
973
+ rows.append(line(label, payload.get(key)))
974
+ if command == "up":
975
+ rows.extend(_render_container_up(payload))
976
+ elif command in {"status", "logs"}:
977
+ rows.extend(_render_container_watchers(payload))
978
+ if command == "status":
979
+ rows.extend(_render_container_status(payload))
980
+ elif command == "stop-watcher":
981
+ rows.extend(_render_reaped_panes("Reaped pane", payload.get("reapedPanes")))
982
+ else:
983
+ rows.extend(_render_container_down(payload))
984
+ return "".join(rows)
985
+
986
+
987
+ def _render_container_up(payload: dict) -> list[str]:
988
+ rows: list[str] = []
989
+ for index, service in enumerate(payload.get("services") or [], 1):
990
+ rows.append(line(f"Service {index} name", service))
991
+ for index, warning in enumerate(payload.get("warnings") or [], 1):
992
+ rows.append(line(f"Warning {index} text", warning))
993
+ watch = payload.get("watch") if isinstance(payload.get("watch"), dict) else {}
994
+ for label, key in (("Watcher enabled", "enabled"), ("Watcher session", "session"),
995
+ ("Watcher note", "note")):
996
+ if key in watch:
997
+ rows.append(line(label, watch.get(key)))
998
+ for index, service in enumerate(watch.get("services") or [], 1):
999
+ rows.append(line(f"Watcher service {index} name", service))
1000
+ return rows
1001
+
1002
+
1003
+ def _render_container_watchers(payload: dict) -> list[str]:
1004
+ watchers = payload.get("watchers") if isinstance(payload.get("watchers"), dict) else {}
1005
+ rows: list[str] = []
1006
+ for index, (service, metadata) in enumerate(sorted(watchers.items()), 1):
1007
+ rows.append(line(f"Watcher {index} service", service))
1008
+ values = metadata if isinstance(metadata, dict) else {}
1009
+ for label, key in (("pane ID", "paneId"), ("session", "sessionName"),
1010
+ ("findings path", "findingsPath")):
1011
+ if key in values:
1012
+ rows.append(line(f"Watcher {index} {label}", values.get(key)))
1013
+ return rows
1014
+
1015
+
1016
+ def _render_container_status(payload: dict) -> list[str]:
1017
+ containers = payload.get("containers") if isinstance(payload.get("containers"), list) else []
1018
+ rows: list[str] = []
1019
+ for index, item in enumerate(containers, 1):
1020
+ values = item if isinstance(item, dict) else {}
1021
+ for label, key in (("name", "name"), ("service", "service"), ("state", "state"),
1022
+ ("status", "status"), ("ports", "ports")):
1023
+ if key in values:
1024
+ rows.append(line(f"Container {index} {label}", values.get(key)))
1025
+ return rows
1026
+
1027
+
1028
+ def _render_reaped_panes(prefix: str, raw_panes: object) -> list[str]:
1029
+ panes = raw_panes if isinstance(raw_panes, list) else []
1030
+ return [line(f"{prefix} {index}", pane) for index, pane in enumerate(panes, 1)]
1031
+
1032
+
1033
+ def _render_container_down(payload: dict) -> list[str]:
1034
+ downed = payload.get("downed") if isinstance(payload.get("downed"), list) else []
1035
+ rows: list[str] = []
1036
+ for index, item in enumerate(downed, 1):
1037
+ values = item if isinstance(item, dict) else {}
1038
+ rows.append(line(f"Downed {index} project name", values.get("projectName")))
1039
+ rows.extend(_render_reaped_panes(
1040
+ f"Downed {index} reaped pane", values.get("reapedPanes")
1041
+ ))
1042
+ rows.extend(_render_reaped_panes("Orphan pane", payload.get("orphanPanesReaped")))
1043
+ return rows
1044
+
1045
+
951
1046
  def _dispatch(args) -> dict:
952
1047
  pr = args.project_root
953
1048
  if args.command == "up":
@@ -15,6 +15,7 @@ from typing import Iterable
15
15
 
16
16
  from okstra_ctl.paths import RunRef, runs_dir_of, task_manifest_file
17
17
  from okstra_ctl.task_target import resolve_task_root, project_rel
18
+ from okstra_ctl.json_boundary import load_owned_object
18
19
 
19
20
 
20
21
  INPUT_FILES = (
@@ -83,7 +84,7 @@ def _load_json(path: Path) -> dict:
83
84
  if not path.is_file():
84
85
  return {}
85
86
  try:
86
- data = json.loads(path.read_text(encoding="utf-8"))
87
+ data = load_owned_object(path, artifact="context cost input")
87
88
  except Exception:
88
89
  return {}
89
90
  return data if isinstance(data, dict) else {}