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