okstra 0.136.0 → 0.138.0

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 (50) hide show
  1. package/docs/architecture/storage-model.md +1 -1
  2. package/docs/architecture.md +3 -2
  3. package/docs/contributor-change-matrix.md +1 -1
  4. package/docs/project-structure-overview.md +32 -5
  5. package/package.json +2 -3
  6. package/runtime/BUILD.json +2 -2
  7. package/runtime/agents/workers/antigravity-worker.md +1 -1
  8. package/runtime/agents/workers/codex-worker.md +1 -1
  9. package/runtime/bin/lib/okstra-ctl/cmd-rerun.sh +1 -1
  10. package/runtime/bin/okstra-spawn-followups.py +4 -9
  11. package/runtime/prompts/lead/adapters/claude-code.md +1 -0
  12. package/runtime/prompts/lead/adapters/codex.md +1 -0
  13. package/runtime/prompts/lead/adapters/external.md +1 -0
  14. package/runtime/prompts/lead/plan-body-verification.md +2 -2
  15. package/runtime/prompts/lead/report-writer.md +11 -10
  16. package/runtime/prompts/lead/team-contract.md +4 -2
  17. package/runtime/prompts/profiles/_implementation-diff-review.md +1 -1
  18. package/runtime/prompts/profiles/_implementation-executor.md +2 -2
  19. package/runtime/prompts/profiles/_implementation-self-check.md +1 -1
  20. package/runtime/python/okstra_ctl/codex_dispatch.py +199 -681
  21. package/runtime/python/okstra_ctl/consumers.py +7 -5
  22. package/runtime/python/okstra_ctl/context_cost.py +4 -4
  23. package/runtime/python/okstra_ctl/dispatch_core.py +146 -287
  24. package/runtime/python/okstra_ctl/dispatch_state.py +231 -0
  25. package/runtime/python/okstra_ctl/error_report.py +2 -7
  26. package/runtime/python/okstra_ctl/implementation_outcome.py +12 -23
  27. package/runtime/python/okstra_ctl/implementation_stage.py +7 -2
  28. package/runtime/python/okstra_ctl/initial_prompt_materialization.py +1053 -0
  29. package/runtime/python/okstra_ctl/path_hints.py +3 -3
  30. package/runtime/python/okstra_ctl/paths.py +17 -2
  31. package/runtime/python/okstra_ctl/recap.py +5 -12
  32. package/runtime/python/okstra_ctl/render.py +18 -19
  33. package/runtime/python/okstra_ctl/report_finalize.py +8 -4
  34. package/runtime/python/okstra_ctl/report_language.py +83 -0
  35. package/runtime/python/okstra_ctl/run.py +262 -328
  36. package/runtime/python/okstra_ctl/set_work_status.py +2 -1
  37. package/runtime/python/okstra_ctl/stage_targets.py +330 -101
  38. package/runtime/python/okstra_ctl/time_report.py +4 -9
  39. package/runtime/python/okstra_ctl/wizard.py +47 -49
  40. package/runtime/python/okstra_ctl/work_categories.py +2 -2
  41. package/runtime/python/okstra_ctl/worker_prompt_body.py +82 -0
  42. package/runtime/python/okstra_ctl/worker_prompt_contract.py +73 -13
  43. package/runtime/python/okstra_ctl/worker_prompt_headers.py +7 -0
  44. package/runtime/python/okstra_ctl/worktree.py +37 -29
  45. package/runtime/python/okstra_project/__init__.py +4 -0
  46. package/runtime/python/okstra_project/dirs.py +7 -0
  47. package/runtime/python/okstra_project/state.py +78 -8
  48. package/runtime/validators/lib/fixtures.sh +2 -0
  49. package/runtime/validators/validate-run.py +3 -21
  50. package/src/commands/inspect/stage-map.mjs +12 -19
@@ -22,92 +22,53 @@ from .final_report_paths import (
22
22
  final_report_data_path as _final_report_data_path,
23
23
  final_report_markdown_path as _final_report_markdown_path,
24
24
  )
25
+ from .initial_prompt_materialization import (
26
+ InitialPromptMaterializationError,
27
+ InitialPromptMaterializationRequest,
28
+ InitialPromptWorkerRequest,
29
+ PromptDeliveryMode,
30
+ materialize_initial_prompts,
31
+ )
25
32
  from .lead_events import LeadEvent, append_lead_event
33
+ from .dispatch_state import (
34
+ BACKEND_CLI_WRAPPER,
35
+ BACKEND_MIXED,
36
+ BACKEND_TMUX_PANE,
37
+ DispatchError,
38
+ WorkerJob,
39
+ dispatch_mode as _dispatch_mode,
40
+ load_json_object as _load_json_object,
41
+ missing_completion_paths as _missing_completion_paths,
42
+ require_string as _require_string,
43
+ resolve_project_path as _resolve_project_path,
44
+ resolve_required_path as _resolve_required_path,
45
+ set_dispatch_mode as _set_dispatch_mode,
46
+ set_worker_status as _set_worker_status,
47
+ string_list as _string_list,
48
+ string_value as _string_value,
49
+ utc_now as _utc_now,
50
+ worker_state as _worker_state,
51
+ worktree_path as _worktree_path,
52
+ write_json as _write_json,
53
+ )
26
54
  from .path_hints import hydrate_active_run_context
27
55
  from .report_finalize import (
28
56
  STEP_VALIDATE_RUN,
29
57
  FinalizeContext,
30
58
  FinalizeError,
31
- project_relative_path as _project_relative_path,
32
- require_string as _require_string,
33
59
  resolve_optional_path as _resolve_optional_path,
34
- resolve_project_path as _resolve_project_path,
35
60
  run_finalize,
36
61
  run_seq as _run_seq,
37
- string_value as _string_value,
38
62
  )
39
63
  from .wrapper_status import status_path_for_prompt
40
- from .worker_prompt_headers import WorkerPromptHeaderError, worker_prompt_headers
41
- from .worker_prompt_contract import PromptRecord, validate_initial_prompt_records
42
- from .worker_prompt_body import (
43
- analysis_input_lines as _analysis_input_lines,
44
- analysis_prompt_body as _analysis_prompt_body,
45
- existing_input_lines as _existing_input_lines,
46
- instruction_path as _instruction_path,
47
- mcp_pointer_line as _mcp_pointer_line,
48
- )
49
- from .worker_prompt_policy import (
50
- PromptPlan,
51
- resolve_prompt_plan_for_manifest,
52
- )
64
+ from .worker_prompt_body import REPORT_WRITER_WORKER_ID
53
65
 
54
66
 
55
67
  SUPPORTED_CLI_WORKERS = {
56
68
  "codex": "okstra-codex-exec.sh",
57
69
  "antigravity": "okstra-antigravity-exec.sh",
58
70
  }
59
- BACKEND_CLI_WRAPPER = "cli-wrapper"
60
- BACKEND_MIXED = "mixed"
61
71
  MAX_WORKER_ATTEMPTS = 2
62
- REPORT_WRITER_WORKER_ID = "report-writer"
63
- REPORT_LANGUAGE_VALUES = {"en", "ko", "auto"}
64
-
65
-
66
- class DispatchError(Exception):
67
- """Raised when a Codex dispatch request cannot be executed."""
68
-
69
-
70
- @dataclass(frozen=True)
71
- class WorkerDispatch:
72
- worker_id: str
73
- backend: str
74
- project_root: Path
75
- model_execution_value: str
76
- wrapper_path: Path
77
- prompt_path: Path
78
- result_path: Path
79
- worker_result_path: Path
80
- completion_paths: tuple[Path, ...]
81
- worktree_path: str
82
- role: str
83
- idle_timeout_seconds: int
84
-
85
- @property
86
- def command(self) -> list[str]:
87
- return [
88
- str(self.wrapper_path),
89
- str(self.project_root),
90
- self.model_execution_value,
91
- str(self.prompt_path),
92
- self.worktree_path,
93
- self.role,
94
- str(self.idle_timeout_seconds),
95
- ]
96
-
97
- def to_payload(self) -> dict[str, Any]:
98
- return {
99
- "workerId": self.worker_id,
100
- "backend": self.backend,
101
- "modelExecutionValue": self.model_execution_value,
102
- "wrapperPath": str(self.wrapper_path),
103
- "promptPath": str(self.prompt_path),
104
- "resultPath": str(self.result_path),
105
- "workerResultPath": str(self.worker_result_path),
106
- "completionPaths": [str(path) for path in self.completion_paths],
107
- "worktreePath": self.worktree_path,
108
- "role": self.role,
109
- "command": self.command,
110
- }
111
72
 
112
73
 
113
74
  @dataclass(frozen=True)
@@ -118,7 +79,7 @@ class DispatchPlan:
118
79
  team_state_path: Path
119
80
  lead_events_path: Path
120
81
  manifest: Mapping[str, Any]
121
- workers: tuple[WorkerDispatch, ...]
82
+ workers: tuple[WorkerJob, ...]
122
83
 
123
84
  def to_payload(self, *, dry_run: bool) -> dict[str, Any]:
124
85
  return {
@@ -137,6 +98,18 @@ class DispatchPlan:
137
98
  }
138
99
 
139
100
 
101
+ @dataclass(frozen=True)
102
+ class _DispatchInputs:
103
+ project_root: Path
104
+ manifest_path: Path
105
+ manifest: Mapping[str, Any]
106
+ selected_workers: tuple[str, ...]
107
+ team_state_path: Path
108
+ team_state: Mapping[str, Any]
109
+ active_context: Mapping[str, Any]
110
+ lead_events_path: Path
111
+
112
+
140
113
  def build_dispatch_plan(
141
114
  *,
142
115
  project_root: Path,
@@ -148,19 +121,62 @@ def build_dispatch_plan(
148
121
  enable_codex_report_writer: bool = False,
149
122
  report_writer_codex_model: str = "",
150
123
  ) -> DispatchPlan:
124
+ inputs = _load_dispatch_inputs(
125
+ project_root,
126
+ run_manifest_path,
127
+ requested_workers,
128
+ enable_codex_report_writer=enable_codex_report_writer,
129
+ )
130
+ worker_requests, prompt_paths = _materialize_selected_worker_prompts(
131
+ project_root=inputs.project_root,
132
+ manifest_path=inputs.manifest_path,
133
+ workspace_root=workspace_root,
134
+ team_state=inputs.team_state,
135
+ selected_workers=inputs.selected_workers,
136
+ report_writer_codex_model=report_writer_codex_model,
137
+ )
138
+ workers = _build_selected_worker_dispatches(
139
+ project_root=inputs.project_root,
140
+ manifest=inputs.manifest,
141
+ team_state=inputs.team_state,
142
+ active_context=inputs.active_context,
143
+ workspace_root=workspace_root,
144
+ okstra_bin=okstra_bin,
145
+ idle_timeout_seconds=idle_timeout_seconds,
146
+ selected_workers=inputs.selected_workers,
147
+ worker_requests=worker_requests,
148
+ prompt_paths=prompt_paths,
149
+ )
150
+ return DispatchPlan(
151
+ project_root=inputs.project_root,
152
+ workspace_root=workspace_root,
153
+ manifest_path=inputs.manifest_path,
154
+ team_state_path=inputs.team_state_path,
155
+ lead_events_path=inputs.lead_events_path,
156
+ manifest=inputs.manifest,
157
+ workers=workers,
158
+ )
159
+
160
+
161
+ def _load_dispatch_inputs(
162
+ project_root: Path,
163
+ run_manifest_path: Path,
164
+ requested_workers: Sequence[str],
165
+ *,
166
+ enable_codex_report_writer: bool,
167
+ ) -> _DispatchInputs:
151
168
  project_root = project_root.resolve()
152
169
  manifest_path = _resolve_project_path(project_root, str(run_manifest_path))
153
170
  manifest = _load_json_object(manifest_path, "run manifest")
154
171
  _validate_codex_manifest(manifest, manifest_path)
155
-
156
- selected_workers = _select_workers(
157
- manifest,
158
- requested_workers,
159
- enable_codex_report_writer=enable_codex_report_writer,
160
- )
161
- team_state_path = _resolve_required_path(
162
- project_root, manifest, "teamStatePath", "team-state"
172
+ selected_workers = tuple(
173
+ _select_workers(
174
+ manifest,
175
+ requested_workers,
176
+ enable_codex_report_writer=enable_codex_report_writer,
177
+ )
163
178
  )
179
+ team_state_path = _resolve_required_path(project_root, manifest, "teamStatePath")
164
180
  team_state = _load_json_object(team_state_path, "team-state")
165
181
  _mark_skipped_workers(
166
182
  team_state_path,
@@ -175,19 +191,67 @@ def build_dispatch_plan(
175
191
  active_context = _load_optional_json_object(
176
192
  _resolve_optional_path(project_root, manifest.get("activeRunContextPath"))
177
193
  )
178
- lead_events_path = _resolve_required_path(
179
- project_root, manifest, "leadEventsPath", "lead events"
180
- )
181
- _materialize_missing_worker_prompts(
194
+ return _DispatchInputs(
182
195
  project_root=project_root,
196
+ manifest_path=manifest_path,
183
197
  manifest=manifest,
198
+ selected_workers=selected_workers,
199
+ team_state_path=team_state_path,
184
200
  team_state=team_state,
185
201
  active_context=active_context,
186
- selected_workers=selected_workers,
187
- workspace_root=workspace_root,
188
- report_writer_codex_model=report_writer_codex_model,
202
+ lead_events_path=_resolve_required_path(project_root, manifest, "leadEventsPath"),
203
+ )
204
+
205
+
206
+ def _materialize_selected_worker_prompts(
207
+ *,
208
+ project_root: Path,
209
+ manifest_path: Path,
210
+ workspace_root: Path,
211
+ team_state: Mapping[str, Any],
212
+ selected_workers: Sequence[str],
213
+ report_writer_codex_model: str,
214
+ ) -> tuple[tuple[InitialPromptWorkerRequest, ...], tuple[Path, ...]]:
215
+ worker_requests = tuple(
216
+ InitialPromptWorkerRequest(
217
+ worker_id=worker_id,
218
+ model=_requested_worker_model(
219
+ worker_id,
220
+ team_state,
221
+ report_writer_codex_model,
222
+ ),
223
+ )
224
+ for worker_id in selected_workers
189
225
  )
190
- workers = tuple(
226
+ try:
227
+ prompt_paths = materialize_initial_prompts(
228
+ InitialPromptMaterializationRequest(
229
+ project_root=project_root,
230
+ run_manifest_path=manifest_path,
231
+ runtime_root=workspace_root,
232
+ delivery_mode=PromptDeliveryMode.EAGER_INCLUDE,
233
+ workers=worker_requests,
234
+ )
235
+ )
236
+ except InitialPromptMaterializationError as exc:
237
+ raise DispatchError(f"{exc.reason}: {exc}") from exc
238
+ return worker_requests, prompt_paths
239
+
240
+
241
+ def _build_selected_worker_dispatches(
242
+ *,
243
+ project_root: Path,
244
+ manifest: Mapping[str, Any],
245
+ team_state: Mapping[str, Any],
246
+ active_context: Mapping[str, Any],
247
+ workspace_root: Path,
248
+ okstra_bin: Path | None,
249
+ idle_timeout_seconds: int,
250
+ selected_workers: Sequence[str],
251
+ worker_requests: Sequence[InitialPromptWorkerRequest],
252
+ prompt_paths: Sequence[Path],
253
+ ) -> tuple[WorkerJob, ...]:
254
+ return tuple(
191
255
  _build_worker_dispatch(
192
256
  worker_id=worker_id,
193
257
  project_root=project_root,
@@ -197,36 +261,18 @@ def build_dispatch_plan(
197
261
  workspace_root=workspace_root,
198
262
  okstra_bin=okstra_bin,
199
263
  idle_timeout_seconds=idle_timeout_seconds,
200
- report_writer_codex_model=report_writer_codex_model,
264
+ model_execution_value=worker_request.model,
265
+ prompt_path=prompt_path,
266
+ )
267
+ for worker_id, worker_request, prompt_path in zip(
268
+ selected_workers,
269
+ worker_requests,
270
+ prompt_paths,
271
+ strict=True,
201
272
  )
202
- for worker_id in selected_workers
203
- )
204
- _validate_initial_prompts(manifest, workers)
205
- return DispatchPlan(
206
- project_root=project_root,
207
- workspace_root=workspace_root,
208
- manifest_path=manifest_path,
209
- team_state_path=team_state_path,
210
- lead_events_path=lead_events_path,
211
- manifest=manifest,
212
- workers=workers,
213
273
  )
214
274
 
215
275
 
216
- def _validate_initial_prompts(
217
- manifest: Mapping[str, Any],
218
- workers: Sequence[WorkerDispatch],
219
- ) -> None:
220
- records = [
221
- PromptRecord(worker.worker_id, "initial", worker.prompt_path)
222
- for worker in workers
223
- ]
224
- errors = validate_initial_prompt_records(manifest=manifest, records=records)
225
- if errors:
226
- task_type = _require_string(manifest, "taskType")
227
- raise DispatchError(f"{task_type} prompt contract: " + "; ".join(errors))
228
-
229
-
230
276
  def dispatch_plan(plan: DispatchPlan) -> int:
231
277
  _set_dispatch_mode(plan.team_state_path, _dispatch_mode(plan.workers))
232
278
  for worker in plan.workers:
@@ -244,7 +290,7 @@ def dispatch_plan(plan: DispatchPlan) -> int:
244
290
  return 0
245
291
 
246
292
 
247
- def _dispatch_worker_with_retry(plan: DispatchPlan, worker: WorkerDispatch) -> int:
293
+ def _dispatch_worker_with_retry(plan: DispatchPlan, worker: WorkerJob) -> int:
248
294
  for attempt in range(1, MAX_WORKER_ATTEMPTS + 1):
249
295
  _record_worker_attempt(plan, worker, attempt, "running")
250
296
  attempt_details = _attempt_event_details(worker, attempt)
@@ -305,7 +351,7 @@ def _dispatch_worker_with_retry(plan: DispatchPlan, worker: WorkerDispatch) -> i
305
351
  return 1
306
352
  def _record_worker_attempt(
307
353
  plan: DispatchPlan,
308
- worker: WorkerDispatch,
354
+ worker: WorkerJob,
309
355
  attempt: int,
310
356
  status: str,
311
357
  reason: str = "",
@@ -327,7 +373,7 @@ def _record_worker_attempt(
327
373
 
328
374
 
329
375
  def _matches_worker_attempt(
330
- record: object, worker: WorkerDispatch, attempt: int
376
+ record: object, worker: WorkerJob, attempt: int
331
377
  ) -> bool:
332
378
  return (
333
379
  isinstance(record, dict)
@@ -338,7 +384,7 @@ def _matches_worker_attempt(
338
384
 
339
385
 
340
386
  def _worker_dispatch_record(
341
- worker: WorkerDispatch, attempt: int, status: str, reason: str
387
+ worker: WorkerJob, attempt: int, status: str, reason: str
342
388
  ) -> dict[str, Any]:
343
389
  provider = "codex" if worker.worker_id == REPORT_WRITER_WORKER_ID else worker.worker_id
344
390
  return {
@@ -403,147 +449,6 @@ def _parser() -> argparse.ArgumentParser:
403
449
  return parser
404
450
 
405
451
 
406
- def _materialize_missing_worker_prompts(
407
- *,
408
- project_root: Path,
409
- manifest: Mapping[str, Any],
410
- team_state: Mapping[str, Any],
411
- active_context: Mapping[str, Any],
412
- selected_workers: Sequence[str],
413
- workspace_root: Path,
414
- report_writer_codex_model: str,
415
- ) -> None:
416
- for worker_id in selected_workers:
417
- prompt_path = _resolve_project_path(
418
- project_root, _worker_prompt_path(manifest, worker_id)
419
- )
420
- if prompt_path.is_file():
421
- continue
422
- prompt = _render_missing_worker_prompt(
423
- project_root=project_root,
424
- manifest=manifest,
425
- team_state=team_state,
426
- active_context=active_context,
427
- worker_id=worker_id,
428
- workspace_root=workspace_root,
429
- report_writer_codex_model=report_writer_codex_model,
430
- )
431
- prompt_path.parent.mkdir(parents=True, exist_ok=True)
432
- prompt_path.write_text(prompt, encoding="utf-8")
433
-
434
-
435
- def _render_missing_worker_prompt(
436
- *,
437
- project_root: Path,
438
- manifest: Mapping[str, Any],
439
- team_state: Mapping[str, Any],
440
- active_context: Mapping[str, Any],
441
- worker_id: str,
442
- workspace_root: Path,
443
- report_writer_codex_model: str,
444
- ) -> str:
445
- if worker_id == REPORT_WRITER_WORKER_ID:
446
- return _render_missing_report_writer_prompt(
447
- project_root=project_root,
448
- manifest=manifest,
449
- team_state=team_state,
450
- active_context=active_context,
451
- report_writer_codex_model=report_writer_codex_model,
452
- )
453
- return _render_missing_analysis_worker_prompt(
454
- project_root=project_root,
455
- manifest=manifest,
456
- team_state=team_state,
457
- active_context=active_context,
458
- worker_id=worker_id,
459
- workspace_root=workspace_root,
460
- )
461
-
462
-
463
- def _render_missing_analysis_worker_prompt(
464
- *,
465
- project_root: Path,
466
- manifest: Mapping[str, Any],
467
- team_state: Mapping[str, Any],
468
- active_context: Mapping[str, Any],
469
- worker_id: str,
470
- workspace_root: Path,
471
- ) -> str:
472
- worker_state = _worker_state(team_state, worker_id)
473
- result_rel = _require_string(worker_state, "resultPath")
474
- prompt_rel = _worker_prompt_path(manifest, worker_id)
475
- model = _require_string(worker_state, "modelExecutionValue")
476
- role = _analysis_pane_role(manifest, active_context, worker_id)
477
- plan = _initial_prompt_plan(manifest, worker_id)
478
- lines = _base_prompt_headers(
479
- project_root=project_root,
480
- manifest=manifest,
481
- active_context=active_context,
482
- worker_id=worker_id,
483
- prompt_rel=prompt_rel,
484
- result_rel=result_rel,
485
- )
486
- lines.extend(_worktree_headers(manifest, active_context, role))
487
- lines.extend(
488
- _analysis_prompt_body(
489
- manifest,
490
- active_context,
491
- worker_id,
492
- model,
493
- role,
494
- plan,
495
- )
496
- )
497
- executor_tail = _implementation_executor_tail(
498
- manifest,
499
- active_context,
500
- project_root,
501
- workspace_root,
502
- role,
503
- )
504
- if executor_tail:
505
- lines.extend(["", executor_tail.rstrip()])
506
- return "\n".join(lines).rstrip() + "\n"
507
-
508
-
509
- def _render_missing_report_writer_prompt(
510
- *,
511
- project_root: Path,
512
- manifest: Mapping[str, Any],
513
- team_state: Mapping[str, Any],
514
- active_context: Mapping[str, Any],
515
- report_writer_codex_model: str,
516
- ) -> str:
517
- worker_state = _worker_state(team_state, REPORT_WRITER_WORKER_ID)
518
- markdown_path = _resolve_required_path(
519
- project_root, manifest, "expectedReportPath", "report"
520
- )
521
- data_json_path = _final_report_data_path(markdown_path)
522
- result_rel = _project_relative_path(project_root, data_json_path)
523
- worker_result_rel = _require_string(worker_state, "resultPath")
524
- model = (report_writer_codex_model or "").strip()
525
- if not model:
526
- raise DispatchError(
527
- "Codex report-writer dispatch requires --report-writer-codex-model"
528
- )
529
- lines = _base_prompt_headers(
530
- project_root=project_root,
531
- manifest=manifest,
532
- active_context=active_context,
533
- worker_id=REPORT_WRITER_WORKER_ID,
534
- prompt_rel=_worker_prompt_path(manifest, REPORT_WRITER_WORKER_ID),
535
- result_rel=result_rel,
536
- audit_source_rel=worker_result_rel,
537
- )
538
- lines.extend([
539
- f"**Worker Result Path:** {worker_result_rel}",
540
- f"**Model:** Report writer worker, {model}",
541
- f"**Report Language:** {_resolve_report_language(project_root, manifest, active_context)}",
542
- ])
543
- lines.extend(_report_writer_prompt_body(manifest, active_context, team_state))
544
- return "\n".join(lines).rstrip() + "\n"
545
-
546
-
547
452
  def _build_worker_dispatch(
548
453
  *,
549
454
  worker_id: str,
@@ -554,8 +459,9 @@ def _build_worker_dispatch(
554
459
  workspace_root: Path,
555
460
  okstra_bin: Path | None,
556
461
  idle_timeout_seconds: int,
557
- report_writer_codex_model: str,
558
- ) -> WorkerDispatch:
462
+ model_execution_value: str,
463
+ prompt_path: Path,
464
+ ) -> WorkerJob:
559
465
  if worker_id == REPORT_WRITER_WORKER_ID:
560
466
  return _build_report_writer_dispatch(
561
467
  project_root=project_root,
@@ -564,20 +470,19 @@ def _build_worker_dispatch(
564
470
  workspace_root=workspace_root,
565
471
  okstra_bin=okstra_bin,
566
472
  idle_timeout_seconds=idle_timeout_seconds,
567
- report_writer_codex_model=report_writer_codex_model,
473
+ model_execution_value=model_execution_value,
474
+ prompt_path=prompt_path,
568
475
  )
569
476
  worker_state = _worker_state(team_state, worker_id)
570
- prompt_rel = _worker_prompt_path(manifest, worker_id)
571
- prompt_path = _resolve_project_path(project_root, prompt_rel)
572
477
  result_path = _resolve_project_path(
573
478
  project_root, _require_string(worker_state, "resultPath")
574
479
  )
575
- model_execution_value = _require_string(worker_state, "modelExecutionValue")
576
480
  wrapper = _resolve_wrapper(worker_id, workspace_root, okstra_bin)
577
481
  worktree_path = _worktree_path(manifest, active_context)
578
482
  role = _pane_role(prompt_path)
579
- return WorkerDispatch(
483
+ return WorkerJob(
580
484
  worker_id=worker_id,
485
+ provider=worker_id,
581
486
  backend=BACKEND_CLI_WRAPPER,
582
487
  project_root=project_root,
583
488
  model_execution_value=model_execution_value,
@@ -589,6 +494,7 @@ def _build_worker_dispatch(
589
494
  worktree_path=worktree_path,
590
495
  role=role,
591
496
  idle_timeout_seconds=idle_timeout_seconds,
497
+ dispatch_kind="initial",
592
498
  )
593
499
 
594
500
 
@@ -600,27 +506,21 @@ def _build_report_writer_dispatch(
600
506
  workspace_root: Path,
601
507
  okstra_bin: Path | None,
602
508
  idle_timeout_seconds: int,
603
- report_writer_codex_model: str,
604
- ) -> WorkerDispatch:
605
- model_execution_value = (report_writer_codex_model or "").strip()
606
- if not model_execution_value:
607
- raise DispatchError(
608
- "Codex report-writer dispatch requires --report-writer-codex-model"
609
- )
509
+ model_execution_value: str,
510
+ prompt_path: Path,
511
+ ) -> WorkerJob:
610
512
  worker_state = _worker_state(team_state, REPORT_WRITER_WORKER_ID)
611
- prompt_path = _resolve_project_path(
612
- project_root, _worker_prompt_path(manifest, REPORT_WRITER_WORKER_ID)
613
- )
614
513
  data_json_path = _final_report_data_path(
615
- _resolve_required_path(project_root, manifest, "expectedReportPath", "report")
514
+ _resolve_required_path(project_root, manifest, "expectedReportPath")
616
515
  )
617
516
  markdown_path = _final_report_markdown_path(data_json_path)
618
517
  worker_result_path = _resolve_project_path(
619
518
  project_root, _require_string(worker_state, "resultPath")
620
519
  )
621
- _replace_report_writer_model_line(prompt_path, model_execution_value)
622
- return WorkerDispatch(
520
+ return WorkerJob(
623
521
  worker_id=REPORT_WRITER_WORKER_ID,
522
+ # codex 경로의 report-writer 는 claude 가 아니라 codex wrapper 로 돈다.
523
+ provider="codex",
624
524
  backend=BACKEND_CLI_WRAPPER,
625
525
  project_root=project_root,
626
526
  model_execution_value=model_execution_value,
@@ -636,287 +536,33 @@ def _build_report_writer_dispatch(
636
536
  worktree_path="",
637
537
  role=REPORT_WRITER_WORKER_ID,
638
538
  idle_timeout_seconds=idle_timeout_seconds,
539
+ dispatch_kind="initial",
639
540
  )
640
541
 
641
542
 
642
- def _report_writer_prompt_body(
643
- manifest: Mapping[str, Any],
644
- active_context: Mapping[str, Any],
645
- team_state: Mapping[str, Any],
646
- ) -> list[str]:
647
- return [
648
- "",
649
- "# Report Writer Worker Dispatch",
650
- "",
651
- "## Role",
652
- (
653
- "You are the Report writer worker. Author the final-report data.json "
654
- "and the worker-results audit file."
655
- ),
656
- "",
657
- "## Task",
658
- f"- Task key: `{_require_string(manifest, 'taskKey')}`",
659
- f"- Task type: `{_require_string(manifest, 'taskType')}`",
660
- "",
661
- "## Inputs",
662
- *_report_writer_input_lines(manifest, active_context, team_state),
663
- "",
664
- _mcp_pointer_line(),
665
- "",
666
- "## Output Contract",
667
- "You are the author of TWO files:",
668
- "- The final-report data.json at Result Path.",
669
- "- The worker-results audit file at Audit sidecar path.",
670
- (
671
- 'After writing the data.json, invoke "okstra render-final-report '
672
- '<Result Path>" so the markdown sibling is rendered before you return.'
673
- ),
674
- "Do not return the report inline.",
675
- "Copy Report Language verbatim into data.json.meta.reportLanguage.",
676
- ]
677
-
678
-
679
- def _base_prompt_headers(
680
- *,
681
- project_root: Path,
682
- manifest: Mapping[str, Any],
683
- active_context: Mapping[str, Any],
684
- worker_id: str,
685
- prompt_rel: str,
686
- result_rel: str,
687
- audit_source_rel: str | None = None,
688
- ) -> list[str]:
689
- try:
690
- return worker_prompt_headers(
691
- project_root=project_root,
692
- prompt_rel=prompt_rel,
693
- result_rel=result_rel,
694
- audit_source_rel=audit_source_rel,
695
- worker_id=worker_id,
696
- dispatch_kind="initial",
697
- manifest=manifest,
698
- active_context=active_context,
699
- )
700
- except WorkerPromptHeaderError as exc:
701
- raise DispatchError(str(exc)) from exc
543
+ def _validate_codex_manifest(manifest: Mapping[str, Any], path: Path) -> None:
544
+ if manifest.get("leadRuntime") != "codex":
545
+ raise DispatchError(f"run manifest is not a Codex lead run: {path}")
546
+ if not isinstance(manifest.get("leadEventsPath"), str):
547
+ raise DispatchError(f"run manifest has no leadEventsPath: {path}")
702
548
 
703
549
 
704
- def _initial_prompt_plan(
705
- manifest: Mapping[str, Any],
550
+ def _requested_worker_model(
706
551
  worker_id: str,
707
- ) -> PromptPlan:
708
- try:
709
- return resolve_prompt_plan_for_manifest(
710
- manifest=manifest,
711
- worker_id=worker_id,
712
- dispatch_kind="initial",
713
- )
714
- except ValueError as exc:
715
- raise DispatchError(str(exc)) from exc
716
-
717
-
718
- def _report_writer_input_lines(
719
- manifest: Mapping[str, Any],
720
- active_context: Mapping[str, Any],
721
552
  team_state: Mapping[str, Any],
722
- ) -> list[str]:
723
- inputs = [
724
- ("Task brief", _instruction_path(manifest, active_context, "taskBriefPath")),
725
- ("Analysis profile", _instruction_path(manifest, active_context, "analysisProfilePath")),
726
- ("Analysis material", _instruction_path(manifest, active_context, "analysisMaterialPath")),
727
- ("Reference expectations", _instruction_path(manifest, active_context, "referenceExpectationsPath")),
728
- ("Clarification response", _instruction_path(manifest, active_context, "clarificationResponsePath")),
729
- ("Final report template", _instruction_path(manifest, active_context, "finalReportTemplatePath")),
730
- ("Final report schema", _instruction_path(manifest, active_context, "finalReportSchemaPath")),
731
- ("Worker results directory", _string_value(manifest.get("workerResultsDirectoryPath"))),
732
- ]
733
- lines = _existing_input_lines(inputs)
734
- lines.extend(_analysis_worker_result_lines(team_state))
735
- return lines or ["- No report-writer inputs were recorded in active-run-context."]
736
-
737
-
738
- def _analysis_worker_result_lines(team_state: Mapping[str, Any]) -> list[str]:
739
- lines = []
740
- workers = team_state.get("workers")
741
- if not isinstance(workers, list):
742
- return lines
743
- for worker in workers:
744
- if not isinstance(worker, Mapping):
745
- continue
746
- worker_id = worker.get("workerId")
747
- if worker_id == REPORT_WRITER_WORKER_ID:
748
- continue
749
- result_path = _string_value(worker.get("resultPath"))
750
- if result_path:
751
- lines.append(f"- Analysis result ({worker_id}): `{result_path}`")
752
- return lines
753
-
754
-
755
- def _analysis_pane_role(
756
- manifest: Mapping[str, Any],
757
- active_context: Mapping[str, Any],
758
- worker_id: str,
759
- ) -> str:
760
- if _require_string(manifest, "taskType") != "implementation":
761
- return "worker"
762
- if _executor_provider(manifest) != worker_id:
763
- return "worker"
764
- worktree = _worktree_path(manifest, active_context)
765
- if not worktree:
766
- raise DispatchError("implementation executor prompt generation requires a worktree path")
767
- return "executor"
768
-
769
-
770
- def _executor_provider(manifest: Mapping[str, Any]) -> str:
771
- team_contract = manifest.get("teamContract")
772
- if not isinstance(team_contract, Mapping):
773
- return ""
774
- executor = team_contract.get("executor")
775
- if not isinstance(executor, Mapping):
776
- return ""
777
- return _string_value(executor.get("provider"))
778
-
779
-
780
- def _worktree_headers(
781
- manifest: Mapping[str, Any],
782
- active_context: Mapping[str, Any],
783
- role: str,
784
- ) -> list[str]:
785
- task_type = _require_string(manifest, "taskType")
786
- worktree = _worktree_path(manifest, active_context)
787
- if task_type != "implementation" or not worktree:
788
- return []
789
- headers = [f"**Worktree:** {worktree}"]
790
- if role == "executor":
791
- headers.append(f"cwd for every mutating command: {worktree}")
792
- return headers
793
-
794
-
795
- def _implementation_executor_tail(
796
- manifest: Mapping[str, Any],
797
- active_context: Mapping[str, Any],
798
- project_root: Path,
799
- workspace_root: Path,
800
- role: str,
553
+ report_writer_codex_model: str,
801
554
  ) -> str:
802
- if role != "executor" or _require_string(manifest, "taskType") != "implementation":
803
- return ""
804
- profiles = workspace_root / "prompts" / "profiles"
805
- parts: list[str] = []
806
- analysis_profile = _instruction_path(
807
- manifest,
808
- active_context,
809
- "analysisProfilePath",
810
- )
811
- if not analysis_profile:
812
- raise DispatchError("implementation executor profile path is missing")
813
- executor_profile = (
814
- _resolve_project_path(project_root, analysis_profile).parent
815
- / "implementation-executor.md"
816
- )
817
- if not executor_profile.is_file():
818
- raise DispatchError(
819
- f"resolved implementation executor profile not found: {executor_profile}"
555
+ if worker_id != REPORT_WRITER_WORKER_ID:
556
+ return _require_string(
557
+ _worker_state(team_state, worker_id),
558
+ "modelExecutionValue",
820
559
  )
821
- parts.append(executor_profile.read_text(encoding="utf-8"))
822
- preflight_path = profiles / "_coding-conventions-preflight.md"
823
- if preflight_path.is_file():
824
- parts.append(preflight_path.read_text(encoding="utf-8"))
825
- else:
826
- parts.append("# Coding-conventions preflight\n\nApply project-local conventions before editing.")
827
- diff_review_path = profiles / "_implementation-diff-review.md"
828
- if diff_review_path.is_file():
829
- parts.append(diff_review_path.read_text(encoding="utf-8"))
830
- selfcheck_path = profiles / "_implementation-self-check.md"
831
- if selfcheck_path.is_file():
832
- parts.append(selfcheck_path.read_text(encoding="utf-8"))
833
- parts.extend(_clarification_response_section(manifest, active_context, project_root))
834
- return "\n\n".join(parts)
835
-
836
-
837
- def _clarification_response_section(
838
- manifest: Mapping[str, Any],
839
- active_context: Mapping[str, Any],
840
- project_root: Path,
841
- ) -> list[str]:
842
- """The user's answers, inlined for a CLI executor.
843
-
844
- A CLI worker runs in its own process and receives only the prompt text, so
845
- a path reference never reaches it. Passing the clarification response as a
846
- path meant the executor implemented the pre-answer plan while the report
847
- described the answers as carried in — the user answered and nothing
848
- downstream acted on it.
849
- """
850
- relative = _instruction_path(manifest, active_context, "clarificationResponsePath")
851
- if not relative:
852
- return []
853
- path = _resolve_project_path(project_root, relative)
854
- if not path.is_file():
855
- return []
856
- body = path.read_text(encoding="utf-8").strip()
857
- if not body:
858
- return []
859
- return [
860
- "# Clarification answers carried in (authoritative)\n\n"
861
- "The user answered these before this run. Where an answer conflicts "
862
- "with the approved plan text, the answer wins — implement the answer "
863
- f"and say so in your result.\n\nSource: `{relative}`\n\n{body}"
864
- ]
865
-
866
-
867
- def _resolve_report_language(
868
- project_root: Path,
869
- manifest: Mapping[str, Any],
870
- active_context: Mapping[str, Any],
871
- ) -> str:
872
- for value in _report_language_candidates(project_root):
873
- if value == "auto":
874
- return _infer_report_language(project_root, manifest, active_context)
875
- if value:
876
- return value
877
- return _infer_report_language(project_root, manifest, active_context)
878
-
879
-
880
- def _report_language_candidates(project_root: Path) -> list[str]:
881
- okstra_home = Path(os.environ.get("OKSTRA_HOME") or Path.home() / ".okstra")
882
- return [
883
- _read_report_language(project_root / ".okstra" / "project.json", "project"),
884
- _read_report_language(okstra_home / "config.json", "global"),
885
- ]
886
-
887
-
888
- def _read_report_language(path: Path, label: str) -> str:
889
- if not path.is_file():
890
- return ""
891
- value = _load_json_object(path, f"{label} config").get("reportLanguage")
892
- if value in (None, ""):
893
- return ""
894
- if isinstance(value, str) and value in REPORT_LANGUAGE_VALUES:
895
- return value
896
- raise DispatchError(f"{label} config reportLanguage must be en, ko, or auto: {path}")
897
-
898
-
899
- def _infer_report_language(
900
- project_root: Path,
901
- manifest: Mapping[str, Any],
902
- active_context: Mapping[str, Any],
903
- ) -> str:
904
- task_brief = _instruction_path(manifest, active_context, "taskBriefPath")
905
- if not task_brief:
906
- return "en"
907
- path = _resolve_project_path(project_root, task_brief)
908
- if not path.is_file():
909
- return "en"
910
- text = path.read_text(encoding="utf-8", errors="replace")[:16000]
911
- hangul_count = sum(1 for char in text if "\uac00" <= char <= "\ud7a3")
912
- return "ko" if hangul_count >= 10 else "en"
913
-
914
-
915
- def _validate_codex_manifest(manifest: Mapping[str, Any], path: Path) -> None:
916
- if manifest.get("leadRuntime") != "codex":
917
- raise DispatchError(f"run manifest is not a Codex lead run: {path}")
918
- if not isinstance(manifest.get("leadEventsPath"), str):
919
- raise DispatchError(f"run manifest has no leadEventsPath: {path}")
560
+ model = report_writer_codex_model.strip()
561
+ if model:
562
+ return model
563
+ raise DispatchError(
564
+ "Codex report-writer dispatch requires --report-writer-codex-model"
565
+ )
920
566
 
921
567
 
922
568
  def _select_workers(
@@ -1066,7 +712,7 @@ def _resolve_wrapper(
1066
712
 
1067
713
  def _run_worker(
1068
714
  plan: DispatchPlan,
1069
- worker: WorkerDispatch,
715
+ worker: WorkerJob,
1070
716
  ) -> subprocess.CompletedProcess[str]:
1071
717
  if worker.backend == BACKEND_CLI_WRAPPER:
1072
718
  return _run_cli_wrapper_worker(plan, worker)
@@ -1075,7 +721,7 @@ def _run_worker(
1075
721
 
1076
722
  def _run_cli_wrapper_worker(
1077
723
  plan: DispatchPlan,
1078
- worker: WorkerDispatch,
724
+ worker: WorkerJob,
1079
725
  ) -> subprocess.CompletedProcess[str]:
1080
726
  env = {
1081
727
  **os.environ,
@@ -1093,7 +739,7 @@ def _run_cli_wrapper_worker(
1093
739
 
1094
740
  def _post_process_report_writer_result(
1095
741
  plan: DispatchPlan,
1096
- worker: WorkerDispatch,
742
+ worker: WorkerJob,
1097
743
  ) -> dict[str, Any]:
1098
744
  if worker.worker_id != REPORT_WRITER_WORKER_ID:
1099
745
  return {"ok": True, "reason": "", "steps": []}
@@ -1137,14 +783,7 @@ def _append_event(
1137
783
  )
1138
784
 
1139
785
 
1140
- def _dispatch_mode(workers: Sequence[WorkerDispatch]) -> str:
1141
- backends = {worker.backend for worker in workers}
1142
- if len(backends) == 1:
1143
- return next(iter(backends))
1144
- return BACKEND_MIXED
1145
-
1146
-
1147
- def _event_details(worker: WorkerDispatch) -> dict[str, Any]:
786
+ def _event_details(worker: WorkerJob) -> dict[str, Any]:
1148
787
  return {
1149
788
  "dispatchMode": worker.backend,
1150
789
  "workerId": worker.worker_id,
@@ -1156,7 +795,7 @@ def _event_details(worker: WorkerDispatch) -> dict[str, Any]:
1156
795
  }
1157
796
 
1158
797
 
1159
- def _attempt_event_details(worker: WorkerDispatch, attempt: int) -> dict[str, Any]:
798
+ def _attempt_event_details(worker: WorkerJob, attempt: int) -> dict[str, Any]:
1160
799
  return {
1161
800
  **_event_details(worker),
1162
801
  "attempt": attempt,
@@ -1176,69 +815,6 @@ def _should_retry_missing_result(
1176
815
  )
1177
816
 
1178
817
 
1179
- def _missing_completion_paths(worker: WorkerDispatch) -> list[Path]:
1180
- return [path for path in worker.completion_paths if not path.is_file()]
1181
-
1182
-
1183
- def _set_worker_status(
1184
- team_state_path: Path,
1185
- worker_id: str,
1186
- status: str,
1187
- reason: str,
1188
- *,
1189
- model_execution_value: str = "",
1190
- ) -> None:
1191
- payload = _load_json_object(team_state_path, "team-state")
1192
- workers = payload.get("workers")
1193
- if not isinstance(workers, list):
1194
- raise DispatchError(f"team-state workers must be an array: {team_state_path}")
1195
- for worker in workers:
1196
- if isinstance(worker, dict) and worker.get("workerId") == worker_id:
1197
- worker["status"] = status
1198
- worker["reason"] = reason
1199
- if model_execution_value:
1200
- worker["model"] = model_execution_value
1201
- worker["modelExecutionValue"] = model_execution_value
1202
- _write_json(team_state_path, payload)
1203
- return
1204
- raise DispatchError(f"team-state has no workerId={worker_id}: {team_state_path}")
1205
-
1206
-
1207
- def _set_dispatch_mode(team_state_path: Path, dispatch_mode: str) -> None:
1208
- payload = _load_json_object(team_state_path, "team-state")
1209
- payload["dispatchMode"] = dispatch_mode
1210
- _write_json(team_state_path, payload)
1211
-
1212
-
1213
- def _worker_state(team_state: Mapping[str, Any], worker_id: str) -> Mapping[str, Any]:
1214
- workers = team_state.get("workers")
1215
- if not isinstance(workers, list):
1216
- raise DispatchError("team-state workers must be an array")
1217
- for worker in workers:
1218
- if isinstance(worker, Mapping) and worker.get("workerId") == worker_id:
1219
- return worker
1220
- raise DispatchError(f"team-state has no workerId={worker_id}")
1221
-
1222
-
1223
- def _worker_prompt_path(manifest: Mapping[str, Any], worker_id: str) -> str:
1224
- paths = manifest.get("workerPromptPathByWorkerId")
1225
- if not isinstance(paths, Mapping):
1226
- raise DispatchError("run manifest has no workerPromptPathByWorkerId object")
1227
- return _require_string(paths, worker_id)
1228
-
1229
-
1230
- def _worktree_path(
1231
- manifest: Mapping[str, Any],
1232
- active_context: Mapping[str, Any],
1233
- ) -> str:
1234
- if manifest.get("taskType") not in {"implementation", "final-verification"}:
1235
- return ""
1236
- worktree = active_context.get("executorWorktree")
1237
- if not isinstance(worktree, Mapping):
1238
- return ""
1239
- return str(worktree.get("path") or "")
1240
-
1241
-
1242
818
  def _pane_role(prompt_path: Path) -> str:
1243
819
  if not prompt_path.is_file():
1244
820
  raise DispatchError(f"worker prompt not found: {prompt_path}")
@@ -1250,39 +826,12 @@ def _pane_role(prompt_path: Path) -> str:
1250
826
  return "worker"
1251
827
 
1252
828
 
1253
- def _load_json_object(path: Path, label: str) -> dict[str, Any]:
1254
- if not path.is_file():
1255
- raise DispatchError(f"{label} not found: {path}")
1256
- try:
1257
- payload = json.loads(path.read_text(encoding="utf-8"))
1258
- except json.JSONDecodeError as exc:
1259
- raise DispatchError(f"{label} is invalid JSON: {path}: {exc}") from exc
1260
- if not isinstance(payload, dict):
1261
- raise DispatchError(f"{label} must be a JSON object: {path}")
1262
- return payload
1263
-
1264
-
1265
829
  def _load_optional_json_object(path: Path | None) -> dict[str, Any]:
1266
830
  if path is None or not path.is_file():
1267
831
  return {}
1268
832
  return hydrate_active_run_context(_load_json_object(path, "active-run-context"))
1269
833
 
1270
834
 
1271
- def _resolve_required_path(
1272
- project_root: Path,
1273
- manifest: Mapping[str, Any],
1274
- key: str,
1275
- label: str,
1276
- ) -> Path:
1277
- return _resolve_project_path(project_root, _require_string(manifest, key))
1278
-
1279
-
1280
- def _string_list(value: Any) -> list[str]:
1281
- if not isinstance(value, list):
1282
- return []
1283
- return [str(item).strip() for item in value if str(item).strip()]
1284
-
1285
-
1286
835
  def _failure_reason(exit_code: int, missing_paths: Sequence[Path]) -> str:
1287
836
  if exit_code != 0:
1288
837
  return f"wrapper exited with code {exit_code}"
@@ -1292,23 +841,6 @@ def _failure_reason(exit_code: int, missing_paths: Sequence[Path]) -> str:
1292
841
  return "worker dispatch failed"
1293
842
 
1294
843
 
1295
- def _replace_report_writer_model_line(
1296
- prompt_path: Path,
1297
- model_execution_value: str,
1298
- ) -> None:
1299
- if not prompt_path.is_file():
1300
- raise DispatchError(f"report-writer prompt not found: {prompt_path}")
1301
- lines = prompt_path.read_text(encoding="utf-8").splitlines()
1302
- marker = "**Model:** Report writer worker,"
1303
- replacement = f"{marker} {model_execution_value}"
1304
- updated = [
1305
- replacement if line.startswith(marker) else line
1306
- for line in lines
1307
- ]
1308
- if updated != lines:
1309
- prompt_path.write_text("\n".join(updated) + "\n", encoding="utf-8")
1310
-
1311
-
1312
844
  def _parse_workers(raw: str) -> list[str]:
1313
845
  return [item.strip() for item in raw.split(",") if item.strip()]
1314
846
 
@@ -1323,20 +855,6 @@ def _parse_idle_timeout(raw: str) -> int:
1323
855
  return value
1324
856
 
1325
857
 
1326
- def _utc_now() -> str:
1327
- return datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
1328
-
1329
-
1330
- def _write_json(path: Path, payload: Mapping[str, Any]) -> None:
1331
- # Write via temp file + os.replace so a crash mid-write cannot leave a
1332
- # truncated team-state.json that every later _load_json_object rejects;
1333
- # os.replace is atomic on POSIX, matching run_context._atomic_write_json.
1334
- tmp = path.with_suffix(path.suffix + ".tmp")
1335
- tmp.write_text(json.dumps(payload, ensure_ascii=False, indent=2) + "\n",
1336
- encoding="utf-8")
1337
- os.replace(tmp, path)
1338
-
1339
-
1340
858
  def _print_json(payload: Mapping[str, Any]) -> None:
1341
859
  print(json.dumps(payload, ensure_ascii=False, indent=2))
1342
860