okstra 0.75.0 → 0.77.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 (46) hide show
  1. package/README.kr.md +5 -3
  2. package/README.md +5 -3
  3. package/bin/okstra +10 -2
  4. package/docs/kr/architecture.md +1 -1
  5. package/docs/kr/cli.md +20 -4
  6. package/docs/pr-template-usage.md +3 -3
  7. package/docs/superpowers/plans/2026-05-25-okstra-project-root-rename.md +1 -1
  8. package/docs/superpowers/specs/2026-06-12-codex-lead-adapter-design.md +358 -0
  9. package/package.json +1 -1
  10. package/runtime/BUILD.json +2 -2
  11. package/runtime/agents/workers/codex-worker.md +1 -1
  12. package/runtime/agents/workers/gemini-worker.md +1 -1
  13. package/runtime/bin/lib/okstra/cli.sh +5 -1
  14. package/runtime/bin/lib/okstra/globals.sh +1 -0
  15. package/runtime/bin/lib/okstra/usage.sh +6 -4
  16. package/runtime/bin/okstra.sh +1 -0
  17. package/runtime/prompts/launch.template.md +4 -13
  18. package/runtime/prompts/profiles/_coding-conventions-preflight.md +21 -0
  19. package/runtime/prompts/profiles/_implementation-executor.md +3 -6
  20. package/runtime/prompts/profiles/error-analysis.md +6 -0
  21. package/runtime/prompts/profiles/improvement-discovery.md +5 -0
  22. package/runtime/prompts/profiles/release-handoff.md +2 -2
  23. package/runtime/prompts/wizard/prompts.ko.json +12 -4
  24. package/runtime/python/okstra_ctl/analysis_packet.py +17 -0
  25. package/runtime/python/okstra_ctl/codex_dispatch.py +1484 -0
  26. package/runtime/python/okstra_ctl/lead_events.py +129 -0
  27. package/runtime/python/okstra_ctl/paths.py +3 -0
  28. package/runtime/python/okstra_ctl/pr_template.py +1 -1
  29. package/runtime/python/okstra_ctl/render.py +160 -29
  30. package/runtime/python/okstra_ctl/run.py +78 -15
  31. package/runtime/python/okstra_ctl/wizard.py +23 -9
  32. package/runtime/python/okstra_token_usage/codex.py +12 -7
  33. package/runtime/python/okstra_token_usage/collect.py +243 -54
  34. package/runtime/python/okstra_token_usage/gemini.py +12 -7
  35. package/runtime/skills/okstra-setup/SKILL.md +1 -1
  36. package/runtime/validators/validate-run.py +56 -22
  37. package/runtime/validators/validate_session_conformance.py +121 -4
  38. package/src/codex-dispatch.mjs +70 -0
  39. package/src/codex-run.mjs +68 -0
  40. package/src/doctor.mjs +40 -4
  41. package/src/install.mjs +122 -26
  42. package/src/paths.mjs +17 -3
  43. package/src/render-bundle.mjs +2 -0
  44. package/src/runtime-manifest.mjs +26 -0
  45. package/src/uninstall.mjs +26 -4
  46. /package/runtime/{skills/okstra-run/templates → templates/prd}/pr-body.template.md +0 -0
@@ -0,0 +1,1484 @@
1
+ """Codex lead CLI-worker dispatcher.
2
+
3
+ This module is intentionally narrower than the Claude Code team dispatcher. It
4
+ reads an already-prepared Codex run manifest and dispatches only workers that
5
+ have a CLI wrapper path. `report-writer` is supported only through explicit
6
+ Codex opt-in because the prepared manifest still records a Claude-side model by
7
+ default.
8
+ """
9
+ from __future__ import annotations
10
+
11
+ import argparse
12
+ import json
13
+ import os
14
+ import subprocess
15
+ import sys
16
+ from dataclasses import dataclass
17
+ from datetime import datetime, timezone
18
+ from pathlib import Path
19
+ from typing import Any, Mapping, Sequence
20
+
21
+ from .lead_events import LeadEvent, append_lead_event
22
+ from .paths import task_dir
23
+
24
+
25
+ SUPPORTED_CLI_WORKERS = {
26
+ "codex": "okstra-codex-exec.sh",
27
+ "gemini": "okstra-gemini-exec.sh",
28
+ }
29
+ BACKEND_CLI_WRAPPER = "cli-wrapper"
30
+ BACKEND_MIXED = "mixed"
31
+ MAX_WORKER_ATTEMPTS = 2
32
+ REPORT_WRITER_WORKER_ID = "report-writer"
33
+ REPORT_LANGUAGE_VALUES = {"en", "ko", "auto"}
34
+ WORKER_PREAMBLE_PATH = Path.home() / ".okstra" / "templates" / "worker-prompt-preamble.md"
35
+ ANALYSIS_WORKER_LABELS = {
36
+ "codex": "Codex worker",
37
+ "gemini": "Gemini worker",
38
+ }
39
+
40
+
41
+ class DispatchError(Exception):
42
+ """Raised when a Codex dispatch request cannot be executed."""
43
+
44
+
45
+ @dataclass(frozen=True)
46
+ class WorkerDispatch:
47
+ worker_id: str
48
+ backend: str
49
+ project_root: Path
50
+ model_execution_value: str
51
+ wrapper_path: Path
52
+ prompt_path: Path
53
+ result_path: Path
54
+ worker_result_path: Path
55
+ completion_paths: tuple[Path, ...]
56
+ worktree_path: str
57
+ role: str
58
+ idle_timeout_seconds: int
59
+
60
+ @property
61
+ def command(self) -> list[str]:
62
+ return [
63
+ str(self.wrapper_path),
64
+ str(self.project_root),
65
+ self.model_execution_value,
66
+ str(self.prompt_path),
67
+ self.worktree_path,
68
+ self.role,
69
+ str(self.idle_timeout_seconds),
70
+ ]
71
+
72
+ def to_payload(self) -> dict[str, Any]:
73
+ return {
74
+ "workerId": self.worker_id,
75
+ "backend": self.backend,
76
+ "modelExecutionValue": self.model_execution_value,
77
+ "wrapperPath": str(self.wrapper_path),
78
+ "promptPath": str(self.prompt_path),
79
+ "resultPath": str(self.result_path),
80
+ "workerResultPath": str(self.worker_result_path),
81
+ "completionPaths": [str(path) for path in self.completion_paths],
82
+ "worktreePath": self.worktree_path,
83
+ "role": self.role,
84
+ "command": self.command,
85
+ }
86
+
87
+
88
+ @dataclass(frozen=True)
89
+ class DispatchPlan:
90
+ project_root: Path
91
+ workspace_root: Path
92
+ manifest_path: Path
93
+ team_state_path: Path
94
+ lead_events_path: Path
95
+ manifest: Mapping[str, Any]
96
+ workers: tuple[WorkerDispatch, ...]
97
+
98
+ def to_payload(self, *, dry_run: bool) -> dict[str, Any]:
99
+ return {
100
+ "ok": True,
101
+ "dryRun": dry_run,
102
+ "dispatchMode": _dispatch_mode(self.workers),
103
+ "workerBackends": {
104
+ worker.worker_id: worker.backend
105
+ for worker in self.workers
106
+ },
107
+ "runManifestPath": str(self.manifest_path),
108
+ "teamStatePath": str(self.team_state_path),
109
+ "leadEventsPath": str(self.lead_events_path),
110
+ "workspaceRoot": str(self.workspace_root),
111
+ "workers": [worker.to_payload() for worker in self.workers],
112
+ }
113
+
114
+
115
+ def build_dispatch_plan(
116
+ *,
117
+ project_root: Path,
118
+ run_manifest_path: Path,
119
+ workspace_root: Path,
120
+ okstra_bin: Path | None = None,
121
+ requested_workers: Sequence[str] = (),
122
+ idle_timeout_seconds: int = 600,
123
+ enable_codex_report_writer: bool = False,
124
+ report_writer_codex_model: str = "",
125
+ ) -> DispatchPlan:
126
+ project_root = project_root.resolve()
127
+ manifest_path = _resolve_project_path(project_root, str(run_manifest_path))
128
+ manifest = _load_json_object(manifest_path, "run manifest")
129
+ _validate_codex_manifest(manifest, manifest_path)
130
+
131
+ selected_workers = _select_workers(
132
+ manifest,
133
+ requested_workers,
134
+ enable_codex_report_writer=enable_codex_report_writer,
135
+ )
136
+ team_state_path = _resolve_required_path(
137
+ project_root, manifest, "teamStatePath", "team-state"
138
+ )
139
+ team_state = _load_json_object(team_state_path, "team-state")
140
+ _mark_skipped_workers(
141
+ team_state_path,
142
+ team_state,
143
+ _skipped_worker_reasons(
144
+ manifest,
145
+ selected_workers,
146
+ requested_workers,
147
+ enable_codex_report_writer=enable_codex_report_writer,
148
+ ),
149
+ )
150
+ active_context = _load_optional_json_object(
151
+ _resolve_optional_path(project_root, manifest.get("activeRunContextPath"))
152
+ )
153
+ lead_events_path = _resolve_required_path(
154
+ project_root, manifest, "leadEventsPath", "lead events"
155
+ )
156
+ _materialize_missing_worker_prompts(
157
+ project_root=project_root,
158
+ manifest=manifest,
159
+ team_state=team_state,
160
+ active_context=active_context,
161
+ selected_workers=selected_workers,
162
+ workspace_root=workspace_root,
163
+ report_writer_codex_model=report_writer_codex_model,
164
+ )
165
+ workers = tuple(
166
+ _build_worker_dispatch(
167
+ worker_id=worker_id,
168
+ project_root=project_root,
169
+ manifest=manifest,
170
+ team_state=team_state,
171
+ active_context=active_context,
172
+ workspace_root=workspace_root,
173
+ okstra_bin=okstra_bin,
174
+ idle_timeout_seconds=idle_timeout_seconds,
175
+ report_writer_codex_model=report_writer_codex_model,
176
+ )
177
+ for worker_id in selected_workers
178
+ )
179
+ return DispatchPlan(
180
+ project_root=project_root,
181
+ workspace_root=workspace_root,
182
+ manifest_path=manifest_path,
183
+ team_state_path=team_state_path,
184
+ lead_events_path=lead_events_path,
185
+ manifest=manifest,
186
+ workers=workers,
187
+ )
188
+
189
+
190
+ def dispatch_plan(plan: DispatchPlan) -> int:
191
+ _set_dispatch_mode(plan.team_state_path, _dispatch_mode(plan.workers))
192
+ for worker in plan.workers:
193
+ _set_worker_status(
194
+ plan.team_state_path,
195
+ worker.worker_id,
196
+ "running",
197
+ "",
198
+ model_execution_value=worker.model_execution_value,
199
+ )
200
+ completed = _dispatch_worker_with_retry(plan, worker)
201
+ if completed == 0:
202
+ continue
203
+ return completed
204
+ return 0
205
+
206
+
207
+ def _dispatch_worker_with_retry(plan: DispatchPlan, worker: WorkerDispatch) -> int:
208
+ for attempt in range(1, MAX_WORKER_ATTEMPTS + 1):
209
+ attempt_details = _attempt_event_details(worker, attempt)
210
+ _append_event(plan, "worker-dispatched", attempt_details)
211
+ result = _run_worker(plan, worker)
212
+ missing_paths = _missing_completion_paths(worker)
213
+ if result.returncode == 0 and not missing_paths:
214
+ post_process = _post_process_report_writer_result(plan, worker)
215
+ if not post_process["ok"]:
216
+ reason = _require_string(post_process, "reason")
217
+ _set_worker_status(plan.team_state_path, worker.worker_id, "failed", reason)
218
+ details = {
219
+ **attempt_details,
220
+ "exitCode": 1,
221
+ "missingCompletionPaths": [],
222
+ "reason": reason,
223
+ "postProcessing": post_process["steps"],
224
+ }
225
+ _append_event(plan, "worker-failed", details)
226
+ return 1
227
+ _set_worker_status(plan.team_state_path, worker.worker_id, "completed", "")
228
+ details = {
229
+ **attempt_details,
230
+ "missingCompletionPaths": [],
231
+ "postProcessing": post_process["steps"],
232
+ }
233
+ _append_event(plan, "worker-result-collected", details)
234
+ return 0
235
+ if _should_retry_missing_result(result, missing_paths, attempt):
236
+ retry_details = {
237
+ **attempt_details,
238
+ "missingCompletionPaths": [str(path) for path in missing_paths],
239
+ "reason": "required worker artifact was not produced",
240
+ }
241
+ _append_event(plan, "worker-retry-scheduled", retry_details)
242
+ continue
243
+
244
+ reason = _failure_reason(result.returncode, missing_paths)
245
+ _set_worker_status(plan.team_state_path, worker.worker_id, "failed", reason)
246
+ details = {
247
+ **attempt_details,
248
+ "exitCode": result.returncode,
249
+ "missingCompletionPaths": [str(path) for path in missing_paths],
250
+ "reason": reason,
251
+ }
252
+ _append_event(plan, "worker-failed", details)
253
+ return result.returncode if result.returncode else 1
254
+ return 1
255
+
256
+
257
+ def main(argv: Sequence[str] | None = None) -> int:
258
+ parser = _parser()
259
+ args = parser.parse_args(argv)
260
+ try:
261
+ idle_timeout_seconds = _parse_idle_timeout(args.idle_timeout_seconds)
262
+ plan = build_dispatch_plan(
263
+ project_root=Path(args.project_root),
264
+ run_manifest_path=Path(args.run_manifest),
265
+ workspace_root=Path(args.workspace_root),
266
+ okstra_bin=Path(args.okstra_bin) if args.okstra_bin else None,
267
+ requested_workers=_parse_workers(args.workers),
268
+ idle_timeout_seconds=idle_timeout_seconds,
269
+ enable_codex_report_writer=args.enable_codex_report_writer,
270
+ report_writer_codex_model=args.report_writer_codex_model,
271
+ )
272
+ if args.dry_run:
273
+ _print_json(plan.to_payload(dry_run=True))
274
+ return 0
275
+ code = dispatch_plan(plan)
276
+ _print_json({**plan.to_payload(dry_run=False), "exitCode": code})
277
+ return code
278
+ except DispatchError as exc:
279
+ print(f"error: {exc}", file=sys.stderr)
280
+ return 2
281
+
282
+
283
+ def _parser() -> argparse.ArgumentParser:
284
+ parser = argparse.ArgumentParser(
285
+ prog="okstra codex-dispatch",
286
+ description="Dispatch CLI-backed workers for a prepared Codex lead run.",
287
+ )
288
+ parser.add_argument("--project-root", required=True)
289
+ parser.add_argument("--run-manifest", required=True)
290
+ parser.add_argument("--workspace-root", required=True)
291
+ parser.add_argument("--okstra-bin", default="")
292
+ parser.add_argument("--workers", default="")
293
+ parser.add_argument("--dry-run", action="store_true")
294
+ parser.add_argument("--idle-timeout-seconds", default="600")
295
+ parser.add_argument("--enable-codex-report-writer", action="store_true")
296
+ parser.add_argument("--report-writer-codex-model", default="")
297
+ return parser
298
+
299
+
300
+ def _materialize_missing_worker_prompts(
301
+ *,
302
+ project_root: Path,
303
+ manifest: Mapping[str, Any],
304
+ team_state: Mapping[str, Any],
305
+ active_context: Mapping[str, Any],
306
+ selected_workers: Sequence[str],
307
+ workspace_root: Path,
308
+ report_writer_codex_model: str,
309
+ ) -> None:
310
+ for worker_id in selected_workers:
311
+ prompt_path = _resolve_project_path(
312
+ project_root, _worker_prompt_path(manifest, worker_id)
313
+ )
314
+ if prompt_path.is_file():
315
+ continue
316
+ prompt = _render_missing_worker_prompt(
317
+ project_root=project_root,
318
+ manifest=manifest,
319
+ team_state=team_state,
320
+ active_context=active_context,
321
+ worker_id=worker_id,
322
+ workspace_root=workspace_root,
323
+ report_writer_codex_model=report_writer_codex_model,
324
+ )
325
+ prompt_path.parent.mkdir(parents=True, exist_ok=True)
326
+ prompt_path.write_text(prompt, encoding="utf-8")
327
+
328
+
329
+ def _render_missing_worker_prompt(
330
+ *,
331
+ project_root: Path,
332
+ manifest: Mapping[str, Any],
333
+ team_state: Mapping[str, Any],
334
+ active_context: Mapping[str, Any],
335
+ worker_id: str,
336
+ workspace_root: Path,
337
+ report_writer_codex_model: str,
338
+ ) -> str:
339
+ if worker_id == REPORT_WRITER_WORKER_ID:
340
+ return _render_missing_report_writer_prompt(
341
+ project_root=project_root,
342
+ manifest=manifest,
343
+ team_state=team_state,
344
+ active_context=active_context,
345
+ report_writer_codex_model=report_writer_codex_model,
346
+ )
347
+ return _render_missing_analysis_worker_prompt(
348
+ project_root=project_root,
349
+ manifest=manifest,
350
+ team_state=team_state,
351
+ active_context=active_context,
352
+ worker_id=worker_id,
353
+ workspace_root=workspace_root,
354
+ )
355
+
356
+
357
+ def _render_missing_analysis_worker_prompt(
358
+ *,
359
+ project_root: Path,
360
+ manifest: Mapping[str, Any],
361
+ team_state: Mapping[str, Any],
362
+ active_context: Mapping[str, Any],
363
+ worker_id: str,
364
+ workspace_root: Path,
365
+ ) -> str:
366
+ worker_state = _worker_state(team_state, worker_id)
367
+ result_rel = _require_string(worker_state, "resultPath")
368
+ prompt_rel = _worker_prompt_path(manifest, worker_id)
369
+ model = _require_string(worker_state, "modelExecutionValue")
370
+ role = _analysis_pane_role(manifest, active_context, worker_id)
371
+ lines = _base_prompt_headers(
372
+ project_root=project_root,
373
+ manifest=manifest,
374
+ active_context=active_context,
375
+ worker_id=worker_id,
376
+ prompt_rel=prompt_rel,
377
+ result_rel=result_rel,
378
+ )
379
+ lines.extend(_worktree_headers(manifest, active_context, role))
380
+ lines.extend(_analysis_prompt_body(manifest, active_context, worker_id, model, role))
381
+ executor_tail = _implementation_executor_tail(
382
+ manifest, workspace_root, role
383
+ )
384
+ if executor_tail:
385
+ lines.extend(["", executor_tail.rstrip()])
386
+ return "\n".join(lines).rstrip() + "\n"
387
+
388
+
389
+ def _render_missing_report_writer_prompt(
390
+ *,
391
+ project_root: Path,
392
+ manifest: Mapping[str, Any],
393
+ team_state: Mapping[str, Any],
394
+ active_context: Mapping[str, Any],
395
+ report_writer_codex_model: str,
396
+ ) -> str:
397
+ worker_state = _worker_state(team_state, REPORT_WRITER_WORKER_ID)
398
+ markdown_path = _resolve_required_path(
399
+ project_root, manifest, "expectedReportPath", "report"
400
+ )
401
+ data_json_path = _final_report_data_path(markdown_path)
402
+ result_rel = _project_relative_path(project_root, data_json_path)
403
+ worker_result_rel = _require_string(worker_state, "resultPath")
404
+ model = (report_writer_codex_model or "").strip()
405
+ if not model:
406
+ raise DispatchError(
407
+ "Codex report-writer dispatch requires --report-writer-codex-model"
408
+ )
409
+ lines = _base_prompt_headers(
410
+ project_root=project_root,
411
+ manifest=manifest,
412
+ active_context=active_context,
413
+ worker_id=REPORT_WRITER_WORKER_ID,
414
+ prompt_rel=_worker_prompt_path(manifest, REPORT_WRITER_WORKER_ID),
415
+ result_rel=result_rel,
416
+ )
417
+ lines.extend([
418
+ f"**Worker Result Path:** {worker_result_rel}",
419
+ f"**Model:** Report writer worker, {model}",
420
+ f"**Report Language:** {_resolve_report_language(project_root, manifest, active_context)}",
421
+ ])
422
+ lines.extend(_report_writer_prompt_body(manifest, active_context, team_state))
423
+ return "\n".join(lines).rstrip() + "\n"
424
+
425
+
426
+ def _build_worker_dispatch(
427
+ *,
428
+ worker_id: str,
429
+ project_root: Path,
430
+ manifest: Mapping[str, Any],
431
+ team_state: Mapping[str, Any],
432
+ active_context: Mapping[str, Any],
433
+ workspace_root: Path,
434
+ okstra_bin: Path | None,
435
+ idle_timeout_seconds: int,
436
+ report_writer_codex_model: str,
437
+ ) -> WorkerDispatch:
438
+ if worker_id == REPORT_WRITER_WORKER_ID:
439
+ return _build_report_writer_dispatch(
440
+ project_root=project_root,
441
+ manifest=manifest,
442
+ team_state=team_state,
443
+ workspace_root=workspace_root,
444
+ okstra_bin=okstra_bin,
445
+ idle_timeout_seconds=idle_timeout_seconds,
446
+ report_writer_codex_model=report_writer_codex_model,
447
+ )
448
+ worker_state = _worker_state(team_state, worker_id)
449
+ prompt_rel = _worker_prompt_path(manifest, worker_id)
450
+ prompt_path = _resolve_project_path(project_root, prompt_rel)
451
+ result_path = _resolve_project_path(
452
+ project_root, _require_string(worker_state, "resultPath")
453
+ )
454
+ model_execution_value = _require_string(worker_state, "modelExecutionValue")
455
+ wrapper = _resolve_wrapper(worker_id, workspace_root, okstra_bin)
456
+ worktree_path = _worktree_path(manifest, active_context)
457
+ role = _pane_role(prompt_path)
458
+ return WorkerDispatch(
459
+ worker_id=worker_id,
460
+ backend=BACKEND_CLI_WRAPPER,
461
+ project_root=project_root,
462
+ model_execution_value=model_execution_value,
463
+ wrapper_path=wrapper,
464
+ prompt_path=prompt_path,
465
+ result_path=result_path,
466
+ worker_result_path=result_path,
467
+ completion_paths=(result_path,),
468
+ worktree_path=worktree_path,
469
+ role=role,
470
+ idle_timeout_seconds=idle_timeout_seconds,
471
+ )
472
+
473
+
474
+ def _build_report_writer_dispatch(
475
+ *,
476
+ project_root: Path,
477
+ manifest: Mapping[str, Any],
478
+ team_state: Mapping[str, Any],
479
+ workspace_root: Path,
480
+ okstra_bin: Path | None,
481
+ idle_timeout_seconds: int,
482
+ report_writer_codex_model: str,
483
+ ) -> WorkerDispatch:
484
+ model_execution_value = (report_writer_codex_model or "").strip()
485
+ if not model_execution_value:
486
+ raise DispatchError(
487
+ "Codex report-writer dispatch requires --report-writer-codex-model"
488
+ )
489
+ worker_state = _worker_state(team_state, REPORT_WRITER_WORKER_ID)
490
+ prompt_path = _resolve_project_path(
491
+ project_root, _worker_prompt_path(manifest, REPORT_WRITER_WORKER_ID)
492
+ )
493
+ data_json_path = _final_report_data_path(
494
+ _resolve_required_path(project_root, manifest, "expectedReportPath", "report")
495
+ )
496
+ markdown_path = _final_report_markdown_path(data_json_path)
497
+ worker_result_path = _resolve_project_path(
498
+ project_root, _require_string(worker_state, "resultPath")
499
+ )
500
+ _replace_report_writer_model_line(prompt_path, model_execution_value)
501
+ return WorkerDispatch(
502
+ worker_id=REPORT_WRITER_WORKER_ID,
503
+ backend=BACKEND_CLI_WRAPPER,
504
+ project_root=project_root,
505
+ model_execution_value=model_execution_value,
506
+ wrapper_path=_resolve_wrapper("codex", workspace_root, okstra_bin),
507
+ prompt_path=prompt_path,
508
+ result_path=data_json_path,
509
+ worker_result_path=worker_result_path,
510
+ completion_paths=(
511
+ data_json_path,
512
+ markdown_path,
513
+ worker_result_path,
514
+ ),
515
+ worktree_path="",
516
+ role=REPORT_WRITER_WORKER_ID,
517
+ idle_timeout_seconds=idle_timeout_seconds,
518
+ )
519
+
520
+
521
+ def _analysis_prompt_body(
522
+ manifest: Mapping[str, Any],
523
+ active_context: Mapping[str, Any],
524
+ worker_id: str,
525
+ model: str,
526
+ role: str,
527
+ ) -> list[str]:
528
+ label = ANALYSIS_WORKER_LABELS[worker_id]
529
+ return [
530
+ f"**Model:** {label}, {model}",
531
+ f"**Pane role:** {role}",
532
+ "",
533
+ f"# {label} Dispatch",
534
+ "",
535
+ "## Role",
536
+ (
537
+ f"You are the {label} for okstra cross-verification. "
538
+ "Produce an independent worker result."
539
+ ),
540
+ "",
541
+ "## Task",
542
+ f"- Task key: `{_require_string(manifest, 'taskKey')}`",
543
+ f"- Task type: `{_require_string(manifest, 'taskType')}`",
544
+ "",
545
+ "## Inputs",
546
+ *_analysis_input_lines(manifest, active_context),
547
+ "",
548
+ _mcp_pointer_line(),
549
+ "",
550
+ "## Output Contract",
551
+ "- Read the Worker Preamble Path end-to-end before analysis.",
552
+ "- Write the worker result to Result Path and no other canonical result path.",
553
+ "- Write the audit sidecar and any tool-failure entries as the preamble requires.",
554
+ "- Cite evidence with file paths and line numbers whenever you make a claim.",
555
+ ]
556
+
557
+
558
+ def _report_writer_prompt_body(
559
+ manifest: Mapping[str, Any],
560
+ active_context: Mapping[str, Any],
561
+ team_state: Mapping[str, Any],
562
+ ) -> list[str]:
563
+ return [
564
+ "",
565
+ "# Report Writer Worker Dispatch",
566
+ "",
567
+ "## Role",
568
+ (
569
+ "You are the Report writer worker. Author the final-report data.json "
570
+ "and the worker-results audit file."
571
+ ),
572
+ "",
573
+ "## Task",
574
+ f"- Task key: `{_require_string(manifest, 'taskKey')}`",
575
+ f"- Task type: `{_require_string(manifest, 'taskType')}`",
576
+ "",
577
+ "## Inputs",
578
+ *_report_writer_input_lines(manifest, active_context, team_state),
579
+ "",
580
+ _mcp_pointer_line(),
581
+ "",
582
+ "## Output Contract",
583
+ "You are the author of TWO files:",
584
+ "- The final-report data.json at Result Path.",
585
+ "- The worker-results audit file at Worker Result Path.",
586
+ (
587
+ 'After writing the data.json, invoke "okstra render-final-report '
588
+ '<Result Path>" so the markdown sibling is rendered before you return.'
589
+ ),
590
+ "Do not return the report inline.",
591
+ "Copy Report Language verbatim into data.json.meta.reportLanguage.",
592
+ ]
593
+
594
+
595
+ def _mcp_pointer_line() -> str:
596
+ return (
597
+ '**MCP servers:** follow the task brief\'s "## Available MCP Servers" '
598
+ "section (already in your Required reading)."
599
+ )
600
+
601
+
602
+ def _base_prompt_headers(
603
+ *,
604
+ project_root: Path,
605
+ manifest: Mapping[str, Any],
606
+ active_context: Mapping[str, Any],
607
+ worker_id: str,
608
+ prompt_rel: str,
609
+ result_rel: str,
610
+ ) -> list[str]:
611
+ prompt_path = _resolve_project_path(project_root, prompt_rel)
612
+ return [
613
+ f"**Project Root:** {project_root}",
614
+ f"**Prompt History Path:** {prompt_rel}",
615
+ f"**Result Path:** {result_rel}",
616
+ f"Assigned worker prompt history path: {prompt_path}",
617
+ f"**Worker Preamble Path:** {WORKER_PREAMBLE_PATH}",
618
+ f"**Errors log path:** {_errors_log_path(project_root, manifest, active_context)}",
619
+ (
620
+ f"**Errors sidecar path:** "
621
+ f"{_worker_errors_sidecar_path(project_root, manifest, active_context, worker_id)}"
622
+ ),
623
+ ]
624
+
625
+
626
+ def _analysis_input_lines(
627
+ manifest: Mapping[str, Any],
628
+ active_context: Mapping[str, Any],
629
+ ) -> list[str]:
630
+ inputs = [
631
+ ("Primary analysis packet", _instruction_path(manifest, active_context, "analysisPacketPath")),
632
+ ("Task brief", _instruction_path(manifest, active_context, "taskBriefPath")),
633
+ ("Analysis profile", _instruction_path(manifest, active_context, "analysisProfilePath")),
634
+ ("Analysis material", _instruction_path(manifest, active_context, "analysisMaterialPath")),
635
+ ("Reference expectations", _instruction_path(manifest, active_context, "referenceExpectationsPath")),
636
+ ("Clarification response", _instruction_path(manifest, active_context, "clarificationResponsePath")),
637
+ ]
638
+ return _existing_input_lines(inputs)
639
+
640
+
641
+ def _report_writer_input_lines(
642
+ manifest: Mapping[str, Any],
643
+ active_context: Mapping[str, Any],
644
+ team_state: Mapping[str, Any],
645
+ ) -> list[str]:
646
+ inputs = [
647
+ ("Task brief", _instruction_path(manifest, active_context, "taskBriefPath")),
648
+ ("Analysis profile", _instruction_path(manifest, active_context, "analysisProfilePath")),
649
+ ("Analysis material", _instruction_path(manifest, active_context, "analysisMaterialPath")),
650
+ ("Reference expectations", _instruction_path(manifest, active_context, "referenceExpectationsPath")),
651
+ ("Clarification response", _instruction_path(manifest, active_context, "clarificationResponsePath")),
652
+ ("Final report template", _instruction_path(manifest, active_context, "finalReportTemplatePath")),
653
+ ("Final report schema", _instruction_path(manifest, active_context, "finalReportSchemaPath")),
654
+ ("Worker results directory", _string_value(manifest.get("workerResultsDirectoryPath"))),
655
+ ]
656
+ lines = _existing_input_lines(inputs)
657
+ lines.extend(_analysis_worker_result_lines(team_state))
658
+ return lines or ["- No report-writer inputs were recorded in active-run-context."]
659
+
660
+
661
+ def _existing_input_lines(inputs: Sequence[tuple[str, str]]) -> list[str]:
662
+ lines = [f"- {label}: `{path}`" for label, path in inputs if path]
663
+ return lines or ["- No input paths were recorded in active-run-context."]
664
+
665
+
666
+ def _analysis_worker_result_lines(team_state: Mapping[str, Any]) -> list[str]:
667
+ lines = []
668
+ workers = team_state.get("workers")
669
+ if not isinstance(workers, list):
670
+ return lines
671
+ for worker in workers:
672
+ if not isinstance(worker, Mapping):
673
+ continue
674
+ worker_id = worker.get("workerId")
675
+ if worker_id == REPORT_WRITER_WORKER_ID:
676
+ continue
677
+ result_path = _string_value(worker.get("resultPath"))
678
+ if result_path:
679
+ lines.append(f"- Analysis result ({worker_id}): `{result_path}`")
680
+ return lines
681
+
682
+
683
+ def _instruction_path(
684
+ manifest: Mapping[str, Any],
685
+ active_context: Mapping[str, Any],
686
+ key: str,
687
+ ) -> str:
688
+ instruction_set = active_context.get("instructionSet")
689
+ if isinstance(instruction_set, Mapping):
690
+ value = _string_value(instruction_set.get(key))
691
+ if value:
692
+ return value
693
+ return _string_value(manifest.get(key))
694
+
695
+
696
+ def _analysis_pane_role(
697
+ manifest: Mapping[str, Any],
698
+ active_context: Mapping[str, Any],
699
+ worker_id: str,
700
+ ) -> str:
701
+ if _require_string(manifest, "taskType") != "implementation":
702
+ return "worker"
703
+ if _executor_provider(manifest) != worker_id:
704
+ return "worker"
705
+ worktree = _worktree_path(manifest, active_context)
706
+ if not worktree:
707
+ raise DispatchError("implementation executor prompt generation requires a worktree path")
708
+ return "executor"
709
+
710
+
711
+ def _executor_provider(manifest: Mapping[str, Any]) -> str:
712
+ team_contract = manifest.get("teamContract")
713
+ if not isinstance(team_contract, Mapping):
714
+ return ""
715
+ executor = team_contract.get("executor")
716
+ if not isinstance(executor, Mapping):
717
+ return ""
718
+ return _string_value(executor.get("provider"))
719
+
720
+
721
+ def _worktree_headers(
722
+ manifest: Mapping[str, Any],
723
+ active_context: Mapping[str, Any],
724
+ role: str,
725
+ ) -> list[str]:
726
+ task_type = _require_string(manifest, "taskType")
727
+ worktree = _worktree_path(manifest, active_context)
728
+ if task_type not in {"implementation", "final-verification"} or not worktree:
729
+ return []
730
+ headers = [f"**Worktree:** {worktree}"]
731
+ if role == "executor":
732
+ headers.append(f"cwd for every mutating command: {worktree}")
733
+ return headers
734
+
735
+
736
+ def _implementation_executor_tail(
737
+ manifest: Mapping[str, Any],
738
+ workspace_root: Path,
739
+ role: str,
740
+ ) -> str:
741
+ if role != "executor" or _require_string(manifest, "taskType") != "implementation":
742
+ return ""
743
+ preflight_path = workspace_root / "prompts" / "profiles" / "_coding-conventions-preflight.md"
744
+ if not preflight_path.is_file():
745
+ return "# Coding-conventions preflight\n\nApply project-local conventions before editing."
746
+ return preflight_path.read_text(encoding="utf-8")
747
+
748
+
749
+ def _errors_log_path(
750
+ project_root: Path,
751
+ manifest: Mapping[str, Any],
752
+ active_context: Mapping[str, Any],
753
+ ) -> Path:
754
+ error_logs = active_context.get("errorLogs")
755
+ if isinstance(error_logs, Mapping):
756
+ value = _string_value(error_logs.get("runErrorsLogPath"))
757
+ if value:
758
+ return _resolve_project_path(project_root, value)
759
+ run_dir = _run_directory_path(project_root, manifest)
760
+ return run_dir / "logs" / f"errors-{_require_string(manifest, 'taskType')}-{_sequence(manifest, 'state')}.jsonl"
761
+
762
+
763
+ def _worker_errors_sidecar_path(
764
+ project_root: Path,
765
+ manifest: Mapping[str, Any],
766
+ active_context: Mapping[str, Any],
767
+ worker_id: str,
768
+ ) -> Path:
769
+ error_logs = active_context.get("errorLogs")
770
+ if isinstance(error_logs, Mapping):
771
+ sidecars = error_logs.get("sidecarsByWorkerId")
772
+ if isinstance(sidecars, Mapping):
773
+ value = _string_value(sidecars.get(worker_id))
774
+ if value:
775
+ return _resolve_project_path(project_root, value)
776
+ run_dir = _run_directory_path(project_root, manifest)
777
+ task_type = _require_string(manifest, "taskType")
778
+ seq = _sequence(manifest, "workerResults")
779
+ return run_dir / "worker-results" / f"{worker_id}-worker-errors-{task_type}-{seq}.json"
780
+
781
+
782
+ def _run_directory_path(project_root: Path, manifest: Mapping[str, Any]) -> Path:
783
+ value = _string_value(manifest.get("runDirectoryPath"))
784
+ if value:
785
+ return _resolve_project_path(project_root, value)
786
+ return _resolve_required_path(project_root, manifest, "teamStatePath", "team-state").parent.parent
787
+
788
+
789
+ def _sequence(manifest: Mapping[str, Any], key: str) -> str:
790
+ seqs = manifest.get("runSequencesByCategory")
791
+ if not isinstance(seqs, Mapping):
792
+ return _run_seq(manifest)
793
+ return _string_value(seqs.get(key)) or _run_seq(manifest)
794
+
795
+
796
+ def _project_relative_path(project_root: Path, path: Path) -> str:
797
+ try:
798
+ return path.relative_to(project_root).as_posix()
799
+ except ValueError:
800
+ return str(path)
801
+
802
+
803
+ def _resolve_report_language(
804
+ project_root: Path,
805
+ manifest: Mapping[str, Any],
806
+ active_context: Mapping[str, Any],
807
+ ) -> str:
808
+ for value in _report_language_candidates(project_root):
809
+ if value == "auto":
810
+ return _infer_report_language(project_root, manifest, active_context)
811
+ if value:
812
+ return value
813
+ return _infer_report_language(project_root, manifest, active_context)
814
+
815
+
816
+ def _report_language_candidates(project_root: Path) -> list[str]:
817
+ okstra_home = Path(os.environ.get("OKSTRA_HOME") or Path.home() / ".okstra")
818
+ return [
819
+ _read_report_language(project_root / ".okstra" / "project.json", "project"),
820
+ _read_report_language(okstra_home / "config.json", "global"),
821
+ ]
822
+
823
+
824
+ def _read_report_language(path: Path, label: str) -> str:
825
+ if not path.is_file():
826
+ return ""
827
+ value = _load_json_object(path, f"{label} config").get("reportLanguage")
828
+ if value in (None, ""):
829
+ return ""
830
+ if isinstance(value, str) and value in REPORT_LANGUAGE_VALUES:
831
+ return value
832
+ raise DispatchError(f"{label} config reportLanguage must be en, ko, or auto: {path}")
833
+
834
+
835
+ def _infer_report_language(
836
+ project_root: Path,
837
+ manifest: Mapping[str, Any],
838
+ active_context: Mapping[str, Any],
839
+ ) -> str:
840
+ task_brief = _instruction_path(manifest, active_context, "taskBriefPath")
841
+ if not task_brief:
842
+ return "en"
843
+ path = _resolve_project_path(project_root, task_brief)
844
+ if not path.is_file():
845
+ return "en"
846
+ text = path.read_text(encoding="utf-8", errors="replace")[:16000]
847
+ hangul_count = sum(1 for char in text if "\uac00" <= char <= "\ud7a3")
848
+ return "ko" if hangul_count >= 10 else "en"
849
+
850
+
851
+ def _string_value(value: Any) -> str:
852
+ if not isinstance(value, str):
853
+ return ""
854
+ return value.strip()
855
+
856
+
857
+ def _validate_codex_manifest(manifest: Mapping[str, Any], path: Path) -> None:
858
+ if manifest.get("leadRuntime") != "codex":
859
+ raise DispatchError(f"run manifest is not a Codex lead run: {path}")
860
+ if not isinstance(manifest.get("leadEventsPath"), str):
861
+ raise DispatchError(f"run manifest has no leadEventsPath: {path}")
862
+
863
+
864
+ def _select_workers(
865
+ manifest: Mapping[str, Any],
866
+ requested_workers: Sequence[str],
867
+ *,
868
+ enable_codex_report_writer: bool,
869
+ ) -> list[str]:
870
+ recommended = _string_list(manifest.get("recommendedWorkers"))
871
+ if not recommended:
872
+ raise DispatchError("run manifest has no recommendedWorkers entries")
873
+ supported_workers = _supported_codex_dispatch_workers(
874
+ enable_codex_report_writer=enable_codex_report_writer
875
+ )
876
+ if not requested_workers:
877
+ selected = [worker for worker in recommended if worker in supported_workers]
878
+ if selected:
879
+ return selected
880
+ if REPORT_WRITER_WORKER_ID in recommended and not enable_codex_report_writer:
881
+ raise DispatchError(
882
+ "report-writer requires --enable-codex-report-writer because it "
883
+ "runs through the Codex wrapper instead of Claude Agent"
884
+ )
885
+ supported = ", ".join(sorted(supported_workers))
886
+ raise DispatchError(
887
+ "run roster has no Codex-dispatch supported worker(s) "
888
+ f"(CLI dispatcher supports: {supported})"
889
+ )
890
+
891
+ selected = list(requested_workers)
892
+ unknown = [worker for worker in selected if worker not in recommended]
893
+ if unknown:
894
+ raise DispatchError(
895
+ "requested worker(s) are not in this run roster: " + ", ".join(unknown)
896
+ )
897
+ if REPORT_WRITER_WORKER_ID in selected and not enable_codex_report_writer:
898
+ raise DispatchError(
899
+ "report-writer requires --enable-codex-report-writer because it "
900
+ "runs through the Codex wrapper instead of Claude Agent"
901
+ )
902
+ unsupported = [
903
+ worker for worker in selected
904
+ if worker not in supported_workers
905
+ ]
906
+ if unsupported:
907
+ supported = ", ".join(sorted(supported_workers))
908
+ raise DispatchError(
909
+ "unsupported Codex lead worker(s): "
910
+ + ", ".join(unsupported)
911
+ + f" (CLI dispatcher supports: {supported})"
912
+ )
913
+ return selected
914
+
915
+
916
+ def _supported_codex_dispatch_workers(*, enable_codex_report_writer: bool) -> set[str]:
917
+ supported = set(SUPPORTED_CLI_WORKERS)
918
+ if enable_codex_report_writer:
919
+ supported.add(REPORT_WRITER_WORKER_ID)
920
+ return supported
921
+
922
+
923
+ def _skipped_worker_reasons(
924
+ manifest: Mapping[str, Any],
925
+ selected_workers: Sequence[str],
926
+ requested_workers: Sequence[str],
927
+ *,
928
+ enable_codex_report_writer: bool,
929
+ ) -> dict[str, str]:
930
+ recommended = _string_list(manifest.get("recommendedWorkers"))
931
+ selected = set(selected_workers)
932
+ if requested_workers:
933
+ return {
934
+ worker_id: "skipped by Codex dispatch: worker was not requested in this invocation"
935
+ for worker_id in recommended
936
+ if worker_id not in selected
937
+ }
938
+
939
+ supported = _supported_codex_dispatch_workers(
940
+ enable_codex_report_writer=enable_codex_report_writer
941
+ )
942
+ reasons = {}
943
+ for worker_id in recommended:
944
+ if worker_id in selected:
945
+ continue
946
+ if worker_id == REPORT_WRITER_WORKER_ID and not enable_codex_report_writer:
947
+ reasons[worker_id] = (
948
+ "skipped by Codex dispatch default: report-writer requires "
949
+ "--enable-codex-report-writer and --report-writer-codex-model"
950
+ )
951
+ elif worker_id not in supported:
952
+ reasons[worker_id] = (
953
+ "skipped by Codex dispatch default: worker is not supported by "
954
+ "the Codex CLI dispatcher"
955
+ )
956
+ else:
957
+ reasons[worker_id] = "skipped by Codex dispatch default"
958
+ return reasons
959
+
960
+
961
+ def _mark_skipped_workers(
962
+ team_state_path: Path,
963
+ team_state: Mapping[str, Any],
964
+ reasons_by_worker_id: Mapping[str, str],
965
+ ) -> None:
966
+ if not reasons_by_worker_id:
967
+ return
968
+ workers = team_state.get("workers")
969
+ if not isinstance(workers, list):
970
+ raise DispatchError(f"team-state workers must be an array: {team_state_path}")
971
+ changed = False
972
+ for worker in workers:
973
+ if not isinstance(worker, dict):
974
+ continue
975
+ worker_id = _string_value(worker.get("workerId"))
976
+ reason = reasons_by_worker_id.get(worker_id)
977
+ if not reason:
978
+ continue
979
+ status = _string_value(worker.get("status")) or "not-run"
980
+ current_reason = _string_value(worker.get("reason"))
981
+ if status == "not-run" and not current_reason:
982
+ worker["status"] = "not-run"
983
+ worker["reason"] = reason
984
+ changed = True
985
+ if changed:
986
+ _write_json(team_state_path, dict(team_state))
987
+
988
+
989
+ def _resolve_wrapper(
990
+ worker_id: str,
991
+ workspace_root: Path,
992
+ okstra_bin: Path | None,
993
+ ) -> Path:
994
+ script = SUPPORTED_CLI_WORKERS[worker_id]
995
+ candidates = []
996
+ if okstra_bin is not None:
997
+ candidates.append(okstra_bin / script)
998
+ candidates.extend([
999
+ workspace_root / "bin" / script,
1000
+ workspace_root / "scripts" / script,
1001
+ ])
1002
+ for candidate in candidates:
1003
+ if candidate.is_file():
1004
+ return candidate.resolve()
1005
+ searched = ", ".join(str(candidate) for candidate in candidates)
1006
+ raise DispatchError(f"{script} not found (searched: {searched})")
1007
+
1008
+
1009
+ def _run_worker(
1010
+ plan: DispatchPlan,
1011
+ worker: WorkerDispatch,
1012
+ ) -> subprocess.CompletedProcess[str]:
1013
+ if worker.backend == BACKEND_CLI_WRAPPER:
1014
+ return _run_cli_wrapper_worker(plan, worker)
1015
+ raise DispatchError(f"unsupported Codex worker backend: {worker.backend}")
1016
+
1017
+
1018
+ def _run_cli_wrapper_worker(
1019
+ plan: DispatchPlan,
1020
+ worker: WorkerDispatch,
1021
+ ) -> subprocess.CompletedProcess[str]:
1022
+ env = {
1023
+ **os.environ,
1024
+ "OKSTRA_WORKER_ID": worker.worker_id,
1025
+ "OKSTRA_WORKER_RESULT_PATH": str(worker.result_path),
1026
+ "OKSTRA_WORKER_AUDIT_PATH": str(worker.worker_result_path),
1027
+ "OKSTRA_RUN_MANIFEST_PATH": str(plan.manifest_path),
1028
+ }
1029
+ if worker.worker_id == REPORT_WRITER_WORKER_ID:
1030
+ env["OKSTRA_REPORT_WRITER_MARKDOWN_PATH"] = str(
1031
+ _final_report_markdown_path(worker.result_path)
1032
+ )
1033
+ return subprocess.run(worker.command, cwd=plan.project_root, env=env, text=True)
1034
+
1035
+
1036
+ def _post_process_report_writer_result(
1037
+ plan: DispatchPlan,
1038
+ worker: WorkerDispatch,
1039
+ ) -> dict[str, Any]:
1040
+ if worker.worker_id != REPORT_WRITER_WORKER_ID:
1041
+ return {"ok": True, "reason": "", "steps": []}
1042
+ steps = []
1043
+ try:
1044
+ commands = _report_post_process_commands(plan, worker)
1045
+ except DispatchError as exc:
1046
+ return {"ok": False, "reason": str(exc), "steps": steps}
1047
+ for name, command in commands:
1048
+ if name == "validate-run":
1049
+ _set_worker_status(plan.team_state_path, worker.worker_id, "completed", "")
1050
+ result = subprocess.run(
1051
+ command,
1052
+ cwd=plan.project_root,
1053
+ text=True,
1054
+ capture_output=True,
1055
+ )
1056
+ step = _post_process_step_payload(name, command, result)
1057
+ steps.append(step)
1058
+ if result.returncode != 0:
1059
+ return {
1060
+ "ok": False,
1061
+ "reason": f"{name} failed with exit code {result.returncode}",
1062
+ "steps": steps,
1063
+ }
1064
+ return {"ok": True, "reason": "", "steps": steps}
1065
+
1066
+
1067
+ def _report_post_process_commands(
1068
+ plan: DispatchPlan,
1069
+ worker: WorkerDispatch,
1070
+ ) -> list[tuple[str, list[str]]]:
1071
+ markdown_path = _final_report_markdown_path(worker.result_path)
1072
+ commands = [
1073
+ (
1074
+ "token-usage",
1075
+ [
1076
+ sys.executable,
1077
+ str(_resolve_workspace_script(plan.workspace_root, "okstra-token-usage.py")),
1078
+ str(plan.team_state_path),
1079
+ "--project-root",
1080
+ str(plan.project_root),
1081
+ "--write",
1082
+ "--substitute-data",
1083
+ str(worker.result_path),
1084
+ ],
1085
+ ),
1086
+ (
1087
+ "render-views",
1088
+ [
1089
+ sys.executable,
1090
+ str(_resolve_workspace_script(plan.workspace_root, "okstra-render-report-views.py")),
1091
+ str(markdown_path),
1092
+ "--task-key",
1093
+ _require_string(plan.manifest, "taskKey"),
1094
+ "--task-type",
1095
+ _require_string(plan.manifest, "taskType"),
1096
+ "--seq",
1097
+ _run_seq(plan.manifest),
1098
+ "--source-report",
1099
+ _project_relative_path(plan.project_root, markdown_path),
1100
+ ],
1101
+ ),
1102
+ (
1103
+ "spawn-followups",
1104
+ [
1105
+ sys.executable,
1106
+ str(_resolve_workspace_script(plan.workspace_root, "okstra-spawn-followups.py")),
1107
+ str(worker.result_path),
1108
+ "--project-root",
1109
+ str(plan.project_root),
1110
+ "--task-group",
1111
+ _task_group(plan.manifest),
1112
+ "--parent-task-key",
1113
+ _require_string(plan.manifest, "taskKey"),
1114
+ ],
1115
+ ),
1116
+ ]
1117
+ commands.append(("validate-run", _validate_run_command(plan, markdown_path)))
1118
+ return commands
1119
+
1120
+
1121
+ def _validate_run_command(plan: DispatchPlan, markdown_path: Path) -> list[str]:
1122
+ task_manifest_path = _task_manifest_path(plan.project_root, plan.manifest)
1123
+ command = [
1124
+ sys.executable,
1125
+ str(_resolve_workspace_validator(plan.workspace_root, "validate-run.py")),
1126
+ "--team-state",
1127
+ str(plan.team_state_path),
1128
+ "--report",
1129
+ str(markdown_path),
1130
+ "--run-manifest",
1131
+ str(plan.manifest_path),
1132
+ "--task-manifest",
1133
+ str(task_manifest_path),
1134
+ ]
1135
+ final_status_path = _resolve_optional_path(
1136
+ plan.project_root, plan.manifest.get("finalStatusPath")
1137
+ )
1138
+ if final_status_path is not None:
1139
+ command.extend(["--final-status", str(final_status_path)])
1140
+ return command
1141
+
1142
+
1143
+ def _task_manifest_path(project_root: Path, manifest: Mapping[str, Any]) -> Path:
1144
+ value = _string_value(manifest.get("taskManifestPath"))
1145
+ if value:
1146
+ return _resolve_project_path(project_root, value)
1147
+ task_root = _string_value(manifest.get("taskRootPath"))
1148
+ if task_root:
1149
+ return _resolve_project_path(project_root, task_root) / "task-manifest.json"
1150
+ return (
1151
+ task_dir(project_root, _task_group(manifest), _task_id(manifest))
1152
+ / "task-manifest.json"
1153
+ )
1154
+
1155
+
1156
+ def _post_process_step_payload(
1157
+ name: str,
1158
+ command: Sequence[str],
1159
+ result: subprocess.CompletedProcess[str],
1160
+ ) -> dict[str, Any]:
1161
+ return {
1162
+ "name": name,
1163
+ "command": list(command),
1164
+ "exitCode": result.returncode,
1165
+ "stdoutTail": _tail(result.stdout),
1166
+ "stderrTail": _tail(result.stderr),
1167
+ }
1168
+
1169
+
1170
+ def _resolve_workspace_script(workspace_root: Path, script_name: str) -> Path:
1171
+ candidates = [
1172
+ workspace_root / "scripts" / script_name,
1173
+ workspace_root / script_name,
1174
+ ]
1175
+ for candidate in candidates:
1176
+ if candidate.is_file():
1177
+ return candidate.resolve()
1178
+ searched = ", ".join(str(candidate) for candidate in candidates)
1179
+ raise DispatchError(f"{script_name} not found (searched: {searched})")
1180
+
1181
+
1182
+ def _resolve_workspace_validator(workspace_root: Path, validator_name: str) -> Path:
1183
+ candidates = [
1184
+ workspace_root / "validators" / validator_name,
1185
+ workspace_root / validator_name,
1186
+ ]
1187
+ for candidate in candidates:
1188
+ if candidate.is_file():
1189
+ return candidate.resolve()
1190
+ searched = ", ".join(str(candidate) for candidate in candidates)
1191
+ raise DispatchError(f"{validator_name} not found (searched: {searched})")
1192
+
1193
+
1194
+ def _task_group(manifest: Mapping[str, Any]) -> str:
1195
+ value = _string_value(manifest.get("taskGroup"))
1196
+ if value:
1197
+ return value
1198
+ task_key = _require_string(manifest, "taskKey")
1199
+ colon_parts = task_key.split(":")
1200
+ if len(colon_parts) >= 3 and colon_parts[-2].strip():
1201
+ return colon_parts[-2].strip()
1202
+ slash_parts = task_key.split("/")
1203
+ if len(slash_parts) >= 2 and slash_parts[0].strip():
1204
+ return slash_parts[0].strip()
1205
+ raise DispatchError(f"cannot infer task group from taskKey: {task_key}")
1206
+
1207
+
1208
+ def _task_id(manifest: Mapping[str, Any]) -> str:
1209
+ value = _string_value(manifest.get("taskId"))
1210
+ if value:
1211
+ return value
1212
+ task_key = _require_string(manifest, "taskKey")
1213
+ colon_parts = task_key.split(":")
1214
+ if len(colon_parts) >= 3 and colon_parts[-1].strip():
1215
+ return colon_parts[-1].strip()
1216
+ slash_parts = task_key.split("/")
1217
+ if len(slash_parts) >= 2 and slash_parts[-1].strip():
1218
+ return slash_parts[-1].strip()
1219
+ raise DispatchError(f"cannot infer task id from taskKey: {task_key}")
1220
+
1221
+
1222
+ def _tail(text: str, *, limit: int = 4000) -> str:
1223
+ if len(text) <= limit:
1224
+ return text
1225
+ return text[-limit:]
1226
+
1227
+
1228
+ def _append_event(
1229
+ plan: DispatchPlan,
1230
+ event_type: str,
1231
+ details: Mapping[str, Any],
1232
+ ) -> None:
1233
+ append_lead_event(
1234
+ plan.lead_events_path,
1235
+ LeadEvent(
1236
+ event_type=event_type,
1237
+ lead_runtime="codex",
1238
+ task_key=_require_string(plan.manifest, "taskKey"),
1239
+ task_type=_require_string(plan.manifest, "taskType"),
1240
+ run_seq=_run_seq(plan.manifest),
1241
+ timestamp=_utc_now(),
1242
+ details=details,
1243
+ ),
1244
+ )
1245
+
1246
+
1247
+ def _dispatch_mode(workers: Sequence[WorkerDispatch]) -> str:
1248
+ backends = {worker.backend for worker in workers}
1249
+ if len(backends) == 1:
1250
+ return next(iter(backends))
1251
+ return BACKEND_MIXED
1252
+
1253
+
1254
+ def _event_details(worker: WorkerDispatch) -> dict[str, Any]:
1255
+ return {
1256
+ "dispatchMode": worker.backend,
1257
+ "workerId": worker.worker_id,
1258
+ "promptPath": str(worker.prompt_path),
1259
+ "resultPath": str(worker.result_path),
1260
+ "workerResultPath": str(worker.worker_result_path),
1261
+ "wrapperPath": str(worker.wrapper_path),
1262
+ "role": worker.role,
1263
+ }
1264
+
1265
+
1266
+ def _attempt_event_details(worker: WorkerDispatch, attempt: int) -> dict[str, Any]:
1267
+ return {
1268
+ **_event_details(worker),
1269
+ "attempt": attempt,
1270
+ "maxAttempts": MAX_WORKER_ATTEMPTS,
1271
+ }
1272
+
1273
+
1274
+ def _should_retry_missing_result(
1275
+ result: subprocess.CompletedProcess[str],
1276
+ missing_paths: Sequence[Path],
1277
+ attempt: int,
1278
+ ) -> bool:
1279
+ return (
1280
+ result.returncode == 0
1281
+ and bool(missing_paths)
1282
+ and attempt < MAX_WORKER_ATTEMPTS
1283
+ )
1284
+
1285
+
1286
+ def _missing_completion_paths(worker: WorkerDispatch) -> list[Path]:
1287
+ return [path for path in worker.completion_paths if not path.is_file()]
1288
+
1289
+
1290
+ def _set_worker_status(
1291
+ team_state_path: Path,
1292
+ worker_id: str,
1293
+ status: str,
1294
+ reason: str,
1295
+ *,
1296
+ model_execution_value: str = "",
1297
+ ) -> None:
1298
+ payload = _load_json_object(team_state_path, "team-state")
1299
+ workers = payload.get("workers")
1300
+ if not isinstance(workers, list):
1301
+ raise DispatchError(f"team-state workers must be an array: {team_state_path}")
1302
+ for worker in workers:
1303
+ if isinstance(worker, dict) and worker.get("workerId") == worker_id:
1304
+ worker["status"] = status
1305
+ worker["reason"] = reason
1306
+ if model_execution_value:
1307
+ worker["model"] = model_execution_value
1308
+ worker["modelExecutionValue"] = model_execution_value
1309
+ _write_json(team_state_path, payload)
1310
+ return
1311
+ raise DispatchError(f"team-state has no workerId={worker_id}: {team_state_path}")
1312
+
1313
+
1314
+ def _set_dispatch_mode(team_state_path: Path, dispatch_mode: str) -> None:
1315
+ payload = _load_json_object(team_state_path, "team-state")
1316
+ payload["dispatchMode"] = dispatch_mode
1317
+ _write_json(team_state_path, payload)
1318
+
1319
+
1320
+ def _worker_state(team_state: Mapping[str, Any], worker_id: str) -> Mapping[str, Any]:
1321
+ workers = team_state.get("workers")
1322
+ if not isinstance(workers, list):
1323
+ raise DispatchError("team-state workers must be an array")
1324
+ for worker in workers:
1325
+ if isinstance(worker, Mapping) and worker.get("workerId") == worker_id:
1326
+ return worker
1327
+ raise DispatchError(f"team-state has no workerId={worker_id}")
1328
+
1329
+
1330
+ def _worker_prompt_path(manifest: Mapping[str, Any], worker_id: str) -> str:
1331
+ paths = manifest.get("workerPromptPathByWorkerId")
1332
+ if not isinstance(paths, Mapping):
1333
+ raise DispatchError("run manifest has no workerPromptPathByWorkerId object")
1334
+ return _require_string(paths, worker_id)
1335
+
1336
+
1337
+ def _worktree_path(
1338
+ manifest: Mapping[str, Any],
1339
+ active_context: Mapping[str, Any],
1340
+ ) -> str:
1341
+ if manifest.get("taskType") not in {"implementation", "final-verification"}:
1342
+ return ""
1343
+ worktree = active_context.get("executorWorktree")
1344
+ if not isinstance(worktree, Mapping):
1345
+ return ""
1346
+ return str(worktree.get("path") or "")
1347
+
1348
+
1349
+ def _pane_role(prompt_path: Path) -> str:
1350
+ if not prompt_path.is_file():
1351
+ raise DispatchError(f"worker prompt not found: {prompt_path}")
1352
+ marker = "**Pane role:**"
1353
+ for line in prompt_path.read_text(encoding="utf-8", errors="replace").splitlines():
1354
+ if line.startswith(marker):
1355
+ role = line[len(marker):].strip()
1356
+ return role or "worker"
1357
+ return "worker"
1358
+
1359
+
1360
+ def _load_json_object(path: Path, label: str) -> dict[str, Any]:
1361
+ if not path.is_file():
1362
+ raise DispatchError(f"{label} not found: {path}")
1363
+ try:
1364
+ payload = json.loads(path.read_text(encoding="utf-8"))
1365
+ except json.JSONDecodeError as exc:
1366
+ raise DispatchError(f"{label} is invalid JSON: {path}: {exc}") from exc
1367
+ if not isinstance(payload, dict):
1368
+ raise DispatchError(f"{label} must be a JSON object: {path}")
1369
+ return payload
1370
+
1371
+
1372
+ def _load_optional_json_object(path: Path | None) -> dict[str, Any]:
1373
+ if path is None or not path.is_file():
1374
+ return {}
1375
+ return _load_json_object(path, "active-run-context")
1376
+
1377
+
1378
+ def _resolve_required_path(
1379
+ project_root: Path,
1380
+ manifest: Mapping[str, Any],
1381
+ key: str,
1382
+ label: str,
1383
+ ) -> Path:
1384
+ return _resolve_project_path(project_root, _require_string(manifest, key))
1385
+
1386
+
1387
+ def _resolve_optional_path(project_root: Path, value: Any) -> Path | None:
1388
+ if not isinstance(value, str) or not value.strip():
1389
+ return None
1390
+ return _resolve_project_path(project_root, value)
1391
+
1392
+
1393
+ def _resolve_project_path(project_root: Path, value: str) -> Path:
1394
+ path = Path(value)
1395
+ return path if path.is_absolute() else project_root / path
1396
+
1397
+
1398
+ def _require_string(payload: Mapping[str, Any], key: str) -> str:
1399
+ value = payload.get(key)
1400
+ if not isinstance(value, str) or not value.strip():
1401
+ raise DispatchError(f"missing required string field: {key}")
1402
+ return value
1403
+
1404
+
1405
+ def _string_list(value: Any) -> list[str]:
1406
+ if not isinstance(value, list):
1407
+ return []
1408
+ return [str(item).strip() for item in value if str(item).strip()]
1409
+
1410
+
1411
+ def _run_seq(manifest: Mapping[str, Any]) -> str:
1412
+ seqs = manifest.get("runSequencesByCategory")
1413
+ if not isinstance(seqs, Mapping):
1414
+ raise DispatchError("run manifest has no runSequencesByCategory object")
1415
+ return _require_string(seqs, "manifests")
1416
+
1417
+
1418
+ def _failure_reason(exit_code: int, missing_paths: Sequence[Path]) -> str:
1419
+ if exit_code != 0:
1420
+ return f"wrapper exited with code {exit_code}"
1421
+ if missing_paths:
1422
+ missing = ", ".join(str(path) for path in missing_paths)
1423
+ return f"required worker artifact was not produced: {missing}"
1424
+ return "worker dispatch failed"
1425
+
1426
+
1427
+ def _final_report_data_path(markdown_path: Path) -> Path:
1428
+ if markdown_path.name.endswith(".md"):
1429
+ return markdown_path.with_name(markdown_path.name[:-3] + ".data.json")
1430
+ return markdown_path.with_suffix(".data.json")
1431
+
1432
+
1433
+ def _final_report_markdown_path(data_json_path: Path) -> Path:
1434
+ if data_json_path.name.endswith(".data.json"):
1435
+ return data_json_path.with_name(data_json_path.name[:-10] + ".md")
1436
+ return data_json_path.with_suffix(".md")
1437
+
1438
+
1439
+ def _replace_report_writer_model_line(
1440
+ prompt_path: Path,
1441
+ model_execution_value: str,
1442
+ ) -> None:
1443
+ if not prompt_path.is_file():
1444
+ raise DispatchError(f"report-writer prompt not found: {prompt_path}")
1445
+ lines = prompt_path.read_text(encoding="utf-8").splitlines()
1446
+ marker = "**Model:** Report writer worker,"
1447
+ replacement = f"{marker} {model_execution_value}"
1448
+ updated = [
1449
+ replacement if line.startswith(marker) else line
1450
+ for line in lines
1451
+ ]
1452
+ if updated != lines:
1453
+ prompt_path.write_text("\n".join(updated) + "\n", encoding="utf-8")
1454
+
1455
+
1456
+ def _parse_workers(raw: str) -> list[str]:
1457
+ return [item.strip() for item in raw.split(",") if item.strip()]
1458
+
1459
+
1460
+ def _parse_idle_timeout(raw: str) -> int:
1461
+ try:
1462
+ value = int(raw)
1463
+ except ValueError as exc:
1464
+ raise DispatchError("--idle-timeout-seconds must be an integer") from exc
1465
+ if value < 0:
1466
+ raise DispatchError("--idle-timeout-seconds must be non-negative")
1467
+ return value
1468
+
1469
+
1470
+ def _utc_now() -> str:
1471
+ return datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
1472
+
1473
+
1474
+ def _write_json(path: Path, payload: Mapping[str, Any]) -> None:
1475
+ path.write_text(json.dumps(payload, ensure_ascii=False, indent=2) + "\n",
1476
+ encoding="utf-8")
1477
+
1478
+
1479
+ def _print_json(payload: Mapping[str, Any]) -> None:
1480
+ print(json.dumps(payload, ensure_ascii=False, indent=2))
1481
+
1482
+
1483
+ if __name__ == "__main__":
1484
+ raise SystemExit(main(sys.argv[1:]))