okstra 0.136.0 → 0.137.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 (36) hide show
  1. package/docs/architecture/storage-model.md +1 -1
  2. package/docs/contributor-change-matrix.md +1 -1
  3. package/docs/project-structure-overview.md +8 -3
  4. package/package.json +2 -3
  5. package/runtime/BUILD.json +2 -2
  6. package/runtime/bin/lib/okstra-ctl/cmd-rerun.sh +1 -1
  7. package/runtime/bin/okstra-spawn-followups.py +4 -9
  8. package/runtime/prompts/lead/plan-body-verification.md +2 -2
  9. package/runtime/python/okstra_ctl/codex_dispatch.py +56 -331
  10. package/runtime/python/okstra_ctl/consumers.py +7 -5
  11. package/runtime/python/okstra_ctl/context_cost.py +4 -4
  12. package/runtime/python/okstra_ctl/dispatch_core.py +54 -175
  13. package/runtime/python/okstra_ctl/dispatch_state.py +231 -0
  14. package/runtime/python/okstra_ctl/error_report.py +2 -7
  15. package/runtime/python/okstra_ctl/implementation_outcome.py +12 -23
  16. package/runtime/python/okstra_ctl/implementation_stage.py +7 -2
  17. package/runtime/python/okstra_ctl/path_hints.py +3 -3
  18. package/runtime/python/okstra_ctl/paths.py +17 -2
  19. package/runtime/python/okstra_ctl/recap.py +5 -12
  20. package/runtime/python/okstra_ctl/render.py +9 -15
  21. package/runtime/python/okstra_ctl/report_finalize.py +8 -4
  22. package/runtime/python/okstra_ctl/report_language.py +83 -0
  23. package/runtime/python/okstra_ctl/run.py +222 -239
  24. package/runtime/python/okstra_ctl/set_work_status.py +2 -1
  25. package/runtime/python/okstra_ctl/stage_targets.py +98 -55
  26. package/runtime/python/okstra_ctl/time_report.py +4 -9
  27. package/runtime/python/okstra_ctl/wizard.py +45 -47
  28. package/runtime/python/okstra_ctl/work_categories.py +2 -2
  29. package/runtime/python/okstra_ctl/worker_prompt_body.py +82 -0
  30. package/runtime/python/okstra_ctl/worker_prompt_contract.py +0 -13
  31. package/runtime/python/okstra_ctl/worker_prompt_headers.py +7 -0
  32. package/runtime/python/okstra_project/__init__.py +4 -0
  33. package/runtime/python/okstra_project/dirs.py +7 -0
  34. package/runtime/python/okstra_project/state.py +78 -8
  35. package/runtime/validators/validate-run.py +3 -21
  36. package/src/commands/inspect/stage-map.mjs +12 -19
@@ -23,20 +23,41 @@ from .final_report_paths import (
23
23
  final_report_markdown_path as _final_report_markdown_path,
24
24
  )
25
25
  from .lead_events import LeadEvent, append_lead_event
26
+ from .dispatch_state import (
27
+ BACKEND_CLI_WRAPPER,
28
+ BACKEND_MIXED,
29
+ BACKEND_TMUX_PANE,
30
+ DispatchError,
31
+ WorkerJob,
32
+ dispatch_mode as _dispatch_mode,
33
+ load_json_object as _load_json_object,
34
+ missing_completion_paths as _missing_completion_paths,
35
+ require_string as _require_string,
36
+ resolve_project_path as _resolve_project_path,
37
+ resolve_required_path as _resolve_required_path,
38
+ set_dispatch_mode as _set_dispatch_mode,
39
+ set_worker_status as _set_worker_status,
40
+ string_list as _string_list,
41
+ string_value as _string_value,
42
+ utc_now as _utc_now,
43
+ validate_initial_prompts as _validate_initial_prompts,
44
+ worker_prompt_path as _worker_prompt_path,
45
+ worker_state as _worker_state,
46
+ worktree_path as _worktree_path,
47
+ write_json as _write_json,
48
+ )
26
49
  from .path_hints import hydrate_active_run_context
27
50
  from .report_finalize import (
28
51
  STEP_VALIDATE_RUN,
29
52
  FinalizeContext,
30
53
  FinalizeError,
31
54
  project_relative_path as _project_relative_path,
32
- require_string as _require_string,
33
55
  resolve_optional_path as _resolve_optional_path,
34
- resolve_project_path as _resolve_project_path,
35
56
  run_finalize,
36
57
  run_seq as _run_seq,
37
- string_value as _string_value,
38
58
  )
39
59
  from .wrapper_status import status_path_for_prompt
60
+ from .report_language import resolve_report_language
40
61
  from .worker_prompt_headers import WorkerPromptHeaderError, worker_prompt_headers
41
62
  from .worker_prompt_contract import PromptRecord, validate_initial_prompt_records
42
63
  from .worker_prompt_body import (
@@ -45,6 +66,8 @@ from .worker_prompt_body import (
45
66
  existing_input_lines as _existing_input_lines,
46
67
  instruction_path as _instruction_path,
47
68
  mcp_pointer_line as _mcp_pointer_line,
69
+ report_writer_prompt_body,
70
+ REPORT_WRITER_WORKER_ID,
48
71
  )
49
72
  from .worker_prompt_policy import (
50
73
  PromptPlan,
@@ -56,58 +79,7 @@ SUPPORTED_CLI_WORKERS = {
56
79
  "codex": "okstra-codex-exec.sh",
57
80
  "antigravity": "okstra-antigravity-exec.sh",
58
81
  }
59
- BACKEND_CLI_WRAPPER = "cli-wrapper"
60
- BACKEND_MIXED = "mixed"
61
82
  MAX_WORKER_ATTEMPTS = 2
62
- REPORT_WRITER_WORKER_ID = "report-writer"
63
- REPORT_LANGUAGE_VALUES = {"en", "ko", "auto"}
64
-
65
-
66
- class DispatchError(Exception):
67
- """Raised when a Codex dispatch request cannot be executed."""
68
-
69
-
70
- @dataclass(frozen=True)
71
- class WorkerDispatch:
72
- worker_id: str
73
- backend: str
74
- project_root: Path
75
- model_execution_value: str
76
- wrapper_path: Path
77
- prompt_path: Path
78
- result_path: Path
79
- worker_result_path: Path
80
- completion_paths: tuple[Path, ...]
81
- worktree_path: str
82
- role: str
83
- idle_timeout_seconds: int
84
-
85
- @property
86
- def command(self) -> list[str]:
87
- return [
88
- str(self.wrapper_path),
89
- str(self.project_root),
90
- self.model_execution_value,
91
- str(self.prompt_path),
92
- self.worktree_path,
93
- self.role,
94
- str(self.idle_timeout_seconds),
95
- ]
96
-
97
- def to_payload(self) -> dict[str, Any]:
98
- return {
99
- "workerId": self.worker_id,
100
- "backend": self.backend,
101
- "modelExecutionValue": self.model_execution_value,
102
- "wrapperPath": str(self.wrapper_path),
103
- "promptPath": str(self.prompt_path),
104
- "resultPath": str(self.result_path),
105
- "workerResultPath": str(self.worker_result_path),
106
- "completionPaths": [str(path) for path in self.completion_paths],
107
- "worktreePath": self.worktree_path,
108
- "role": self.role,
109
- "command": self.command,
110
- }
111
83
 
112
84
 
113
85
  @dataclass(frozen=True)
@@ -118,7 +90,7 @@ class DispatchPlan:
118
90
  team_state_path: Path
119
91
  lead_events_path: Path
120
92
  manifest: Mapping[str, Any]
121
- workers: tuple[WorkerDispatch, ...]
93
+ workers: tuple[WorkerJob, ...]
122
94
 
123
95
  def to_payload(self, *, dry_run: bool) -> dict[str, Any]:
124
96
  return {
@@ -158,9 +130,7 @@ def build_dispatch_plan(
158
130
  requested_workers,
159
131
  enable_codex_report_writer=enable_codex_report_writer,
160
132
  )
161
- team_state_path = _resolve_required_path(
162
- project_root, manifest, "teamStatePath", "team-state"
163
- )
133
+ team_state_path = _resolve_required_path(project_root, manifest, "teamStatePath")
164
134
  team_state = _load_json_object(team_state_path, "team-state")
165
135
  _mark_skipped_workers(
166
136
  team_state_path,
@@ -175,9 +145,7 @@ def build_dispatch_plan(
175
145
  active_context = _load_optional_json_object(
176
146
  _resolve_optional_path(project_root, manifest.get("activeRunContextPath"))
177
147
  )
178
- lead_events_path = _resolve_required_path(
179
- project_root, manifest, "leadEventsPath", "lead events"
180
- )
148
+ lead_events_path = _resolve_required_path(project_root, manifest, "leadEventsPath")
181
149
  _materialize_missing_worker_prompts(
182
150
  project_root=project_root,
183
151
  manifest=manifest,
@@ -213,20 +181,6 @@ def build_dispatch_plan(
213
181
  )
214
182
 
215
183
 
216
- def _validate_initial_prompts(
217
- manifest: Mapping[str, Any],
218
- workers: Sequence[WorkerDispatch],
219
- ) -> None:
220
- records = [
221
- PromptRecord(worker.worker_id, "initial", worker.prompt_path)
222
- for worker in workers
223
- ]
224
- errors = validate_initial_prompt_records(manifest=manifest, records=records)
225
- if errors:
226
- task_type = _require_string(manifest, "taskType")
227
- raise DispatchError(f"{task_type} prompt contract: " + "; ".join(errors))
228
-
229
-
230
184
  def dispatch_plan(plan: DispatchPlan) -> int:
231
185
  _set_dispatch_mode(plan.team_state_path, _dispatch_mode(plan.workers))
232
186
  for worker in plan.workers:
@@ -244,7 +198,7 @@ def dispatch_plan(plan: DispatchPlan) -> int:
244
198
  return 0
245
199
 
246
200
 
247
- def _dispatch_worker_with_retry(plan: DispatchPlan, worker: WorkerDispatch) -> int:
201
+ def _dispatch_worker_with_retry(plan: DispatchPlan, worker: WorkerJob) -> int:
248
202
  for attempt in range(1, MAX_WORKER_ATTEMPTS + 1):
249
203
  _record_worker_attempt(plan, worker, attempt, "running")
250
204
  attempt_details = _attempt_event_details(worker, attempt)
@@ -305,7 +259,7 @@ def _dispatch_worker_with_retry(plan: DispatchPlan, worker: WorkerDispatch) -> i
305
259
  return 1
306
260
  def _record_worker_attempt(
307
261
  plan: DispatchPlan,
308
- worker: WorkerDispatch,
262
+ worker: WorkerJob,
309
263
  attempt: int,
310
264
  status: str,
311
265
  reason: str = "",
@@ -327,7 +281,7 @@ def _record_worker_attempt(
327
281
 
328
282
 
329
283
  def _matches_worker_attempt(
330
- record: object, worker: WorkerDispatch, attempt: int
284
+ record: object, worker: WorkerJob, attempt: int
331
285
  ) -> bool:
332
286
  return (
333
287
  isinstance(record, dict)
@@ -338,7 +292,7 @@ def _matches_worker_attempt(
338
292
 
339
293
 
340
294
  def _worker_dispatch_record(
341
- worker: WorkerDispatch, attempt: int, status: str, reason: str
295
+ worker: WorkerJob, attempt: int, status: str, reason: str
342
296
  ) -> dict[str, Any]:
343
297
  provider = "codex" if worker.worker_id == REPORT_WRITER_WORKER_ID else worker.worker_id
344
298
  return {
@@ -515,9 +469,7 @@ def _render_missing_report_writer_prompt(
515
469
  report_writer_codex_model: str,
516
470
  ) -> str:
517
471
  worker_state = _worker_state(team_state, REPORT_WRITER_WORKER_ID)
518
- markdown_path = _resolve_required_path(
519
- project_root, manifest, "expectedReportPath", "report"
520
- )
472
+ markdown_path = _resolve_required_path(project_root, manifest, "expectedReportPath")
521
473
  data_json_path = _final_report_data_path(markdown_path)
522
474
  result_rel = _project_relative_path(project_root, data_json_path)
523
475
  worker_result_rel = _require_string(worker_state, "resultPath")
@@ -535,12 +487,13 @@ def _render_missing_report_writer_prompt(
535
487
  result_rel=result_rel,
536
488
  audit_source_rel=worker_result_rel,
537
489
  )
538
- lines.extend([
539
- f"**Worker Result Path:** {worker_result_rel}",
540
- f"**Model:** Report writer worker, {model}",
541
- f"**Report Language:** {_resolve_report_language(project_root, manifest, active_context)}",
542
- ])
543
- lines.extend(_report_writer_prompt_body(manifest, active_context, team_state))
490
+ lines.extend(report_writer_prompt_body(
491
+ manifest,
492
+ active_context,
493
+ team_state,
494
+ model,
495
+ resolve_report_language(project_root, manifest, active_context),
496
+ ))
544
497
  return "\n".join(lines).rstrip() + "\n"
545
498
 
546
499
 
@@ -555,7 +508,7 @@ def _build_worker_dispatch(
555
508
  okstra_bin: Path | None,
556
509
  idle_timeout_seconds: int,
557
510
  report_writer_codex_model: str,
558
- ) -> WorkerDispatch:
511
+ ) -> WorkerJob:
559
512
  if worker_id == REPORT_WRITER_WORKER_ID:
560
513
  return _build_report_writer_dispatch(
561
514
  project_root=project_root,
@@ -576,8 +529,9 @@ def _build_worker_dispatch(
576
529
  wrapper = _resolve_wrapper(worker_id, workspace_root, okstra_bin)
577
530
  worktree_path = _worktree_path(manifest, active_context)
578
531
  role = _pane_role(prompt_path)
579
- return WorkerDispatch(
532
+ return WorkerJob(
580
533
  worker_id=worker_id,
534
+ provider=worker_id,
581
535
  backend=BACKEND_CLI_WRAPPER,
582
536
  project_root=project_root,
583
537
  model_execution_value=model_execution_value,
@@ -589,6 +543,7 @@ def _build_worker_dispatch(
589
543
  worktree_path=worktree_path,
590
544
  role=role,
591
545
  idle_timeout_seconds=idle_timeout_seconds,
546
+ dispatch_kind="initial",
592
547
  )
593
548
 
594
549
 
@@ -601,7 +556,7 @@ def _build_report_writer_dispatch(
601
556
  okstra_bin: Path | None,
602
557
  idle_timeout_seconds: int,
603
558
  report_writer_codex_model: str,
604
- ) -> WorkerDispatch:
559
+ ) -> WorkerJob:
605
560
  model_execution_value = (report_writer_codex_model or "").strip()
606
561
  if not model_execution_value:
607
562
  raise DispatchError(
@@ -612,15 +567,17 @@ def _build_report_writer_dispatch(
612
567
  project_root, _worker_prompt_path(manifest, REPORT_WRITER_WORKER_ID)
613
568
  )
614
569
  data_json_path = _final_report_data_path(
615
- _resolve_required_path(project_root, manifest, "expectedReportPath", "report")
570
+ _resolve_required_path(project_root, manifest, "expectedReportPath")
616
571
  )
617
572
  markdown_path = _final_report_markdown_path(data_json_path)
618
573
  worker_result_path = _resolve_project_path(
619
574
  project_root, _require_string(worker_state, "resultPath")
620
575
  )
621
576
  _replace_report_writer_model_line(prompt_path, model_execution_value)
622
- return WorkerDispatch(
577
+ return WorkerJob(
623
578
  worker_id=REPORT_WRITER_WORKER_ID,
579
+ # codex 경로의 report-writer 는 claude 가 아니라 codex wrapper 로 돈다.
580
+ provider="codex",
624
581
  backend=BACKEND_CLI_WRAPPER,
625
582
  project_root=project_root,
626
583
  model_execution_value=model_execution_value,
@@ -636,46 +593,10 @@ def _build_report_writer_dispatch(
636
593
  worktree_path="",
637
594
  role=REPORT_WRITER_WORKER_ID,
638
595
  idle_timeout_seconds=idle_timeout_seconds,
596
+ dispatch_kind="initial",
639
597
  )
640
598
 
641
599
 
642
- def _report_writer_prompt_body(
643
- manifest: Mapping[str, Any],
644
- active_context: Mapping[str, Any],
645
- team_state: Mapping[str, Any],
646
- ) -> list[str]:
647
- return [
648
- "",
649
- "# Report Writer Worker Dispatch",
650
- "",
651
- "## Role",
652
- (
653
- "You are the Report writer worker. Author the final-report data.json "
654
- "and the worker-results audit file."
655
- ),
656
- "",
657
- "## Task",
658
- f"- Task key: `{_require_string(manifest, 'taskKey')}`",
659
- f"- Task type: `{_require_string(manifest, 'taskType')}`",
660
- "",
661
- "## Inputs",
662
- *_report_writer_input_lines(manifest, active_context, team_state),
663
- "",
664
- _mcp_pointer_line(),
665
- "",
666
- "## Output Contract",
667
- "You are the author of TWO files:",
668
- "- The final-report data.json at Result Path.",
669
- "- The worker-results audit file at Audit sidecar path.",
670
- (
671
- 'After writing the data.json, invoke "okstra render-final-report '
672
- '<Result Path>" so the markdown sibling is rendered before you return.'
673
- ),
674
- "Do not return the report inline.",
675
- "Copy Report Language verbatim into data.json.meta.reportLanguage.",
676
- ]
677
-
678
-
679
600
  def _base_prompt_headers(
680
601
  *,
681
602
  project_root: Path,
@@ -715,43 +636,6 @@ def _initial_prompt_plan(
715
636
  raise DispatchError(str(exc)) from exc
716
637
 
717
638
 
718
- def _report_writer_input_lines(
719
- manifest: Mapping[str, Any],
720
- active_context: Mapping[str, Any],
721
- team_state: Mapping[str, Any],
722
- ) -> list[str]:
723
- inputs = [
724
- ("Task brief", _instruction_path(manifest, active_context, "taskBriefPath")),
725
- ("Analysis profile", _instruction_path(manifest, active_context, "analysisProfilePath")),
726
- ("Analysis material", _instruction_path(manifest, active_context, "analysisMaterialPath")),
727
- ("Reference expectations", _instruction_path(manifest, active_context, "referenceExpectationsPath")),
728
- ("Clarification response", _instruction_path(manifest, active_context, "clarificationResponsePath")),
729
- ("Final report template", _instruction_path(manifest, active_context, "finalReportTemplatePath")),
730
- ("Final report schema", _instruction_path(manifest, active_context, "finalReportSchemaPath")),
731
- ("Worker results directory", _string_value(manifest.get("workerResultsDirectoryPath"))),
732
- ]
733
- lines = _existing_input_lines(inputs)
734
- lines.extend(_analysis_worker_result_lines(team_state))
735
- return lines or ["- No report-writer inputs were recorded in active-run-context."]
736
-
737
-
738
- def _analysis_worker_result_lines(team_state: Mapping[str, Any]) -> list[str]:
739
- lines = []
740
- workers = team_state.get("workers")
741
- if not isinstance(workers, list):
742
- return lines
743
- for worker in workers:
744
- if not isinstance(worker, Mapping):
745
- continue
746
- worker_id = worker.get("workerId")
747
- if worker_id == REPORT_WRITER_WORKER_ID:
748
- continue
749
- result_path = _string_value(worker.get("resultPath"))
750
- if result_path:
751
- lines.append(f"- Analysis result ({worker_id}): `{result_path}`")
752
- return lines
753
-
754
-
755
639
  def _analysis_pane_role(
756
640
  manifest: Mapping[str, Any],
757
641
  active_context: Mapping[str, Any],
@@ -864,54 +748,6 @@ def _clarification_response_section(
864
748
  ]
865
749
 
866
750
 
867
- def _resolve_report_language(
868
- project_root: Path,
869
- manifest: Mapping[str, Any],
870
- active_context: Mapping[str, Any],
871
- ) -> str:
872
- for value in _report_language_candidates(project_root):
873
- if value == "auto":
874
- return _infer_report_language(project_root, manifest, active_context)
875
- if value:
876
- return value
877
- return _infer_report_language(project_root, manifest, active_context)
878
-
879
-
880
- def _report_language_candidates(project_root: Path) -> list[str]:
881
- okstra_home = Path(os.environ.get("OKSTRA_HOME") or Path.home() / ".okstra")
882
- return [
883
- _read_report_language(project_root / ".okstra" / "project.json", "project"),
884
- _read_report_language(okstra_home / "config.json", "global"),
885
- ]
886
-
887
-
888
- def _read_report_language(path: Path, label: str) -> str:
889
- if not path.is_file():
890
- return ""
891
- value = _load_json_object(path, f"{label} config").get("reportLanguage")
892
- if value in (None, ""):
893
- return ""
894
- if isinstance(value, str) and value in REPORT_LANGUAGE_VALUES:
895
- return value
896
- raise DispatchError(f"{label} config reportLanguage must be en, ko, or auto: {path}")
897
-
898
-
899
- def _infer_report_language(
900
- project_root: Path,
901
- manifest: Mapping[str, Any],
902
- active_context: Mapping[str, Any],
903
- ) -> str:
904
- task_brief = _instruction_path(manifest, active_context, "taskBriefPath")
905
- if not task_brief:
906
- return "en"
907
- path = _resolve_project_path(project_root, task_brief)
908
- if not path.is_file():
909
- return "en"
910
- text = path.read_text(encoding="utf-8", errors="replace")[:16000]
911
- hangul_count = sum(1 for char in text if "\uac00" <= char <= "\ud7a3")
912
- return "ko" if hangul_count >= 10 else "en"
913
-
914
-
915
751
  def _validate_codex_manifest(manifest: Mapping[str, Any], path: Path) -> None:
916
752
  if manifest.get("leadRuntime") != "codex":
917
753
  raise DispatchError(f"run manifest is not a Codex lead run: {path}")
@@ -1066,7 +902,7 @@ def _resolve_wrapper(
1066
902
 
1067
903
  def _run_worker(
1068
904
  plan: DispatchPlan,
1069
- worker: WorkerDispatch,
905
+ worker: WorkerJob,
1070
906
  ) -> subprocess.CompletedProcess[str]:
1071
907
  if worker.backend == BACKEND_CLI_WRAPPER:
1072
908
  return _run_cli_wrapper_worker(plan, worker)
@@ -1075,7 +911,7 @@ def _run_worker(
1075
911
 
1076
912
  def _run_cli_wrapper_worker(
1077
913
  plan: DispatchPlan,
1078
- worker: WorkerDispatch,
914
+ worker: WorkerJob,
1079
915
  ) -> subprocess.CompletedProcess[str]:
1080
916
  env = {
1081
917
  **os.environ,
@@ -1093,7 +929,7 @@ def _run_cli_wrapper_worker(
1093
929
 
1094
930
  def _post_process_report_writer_result(
1095
931
  plan: DispatchPlan,
1096
- worker: WorkerDispatch,
932
+ worker: WorkerJob,
1097
933
  ) -> dict[str, Any]:
1098
934
  if worker.worker_id != REPORT_WRITER_WORKER_ID:
1099
935
  return {"ok": True, "reason": "", "steps": []}
@@ -1137,14 +973,7 @@ def _append_event(
1137
973
  )
1138
974
 
1139
975
 
1140
- def _dispatch_mode(workers: Sequence[WorkerDispatch]) -> str:
1141
- backends = {worker.backend for worker in workers}
1142
- if len(backends) == 1:
1143
- return next(iter(backends))
1144
- return BACKEND_MIXED
1145
-
1146
-
1147
- def _event_details(worker: WorkerDispatch) -> dict[str, Any]:
976
+ def _event_details(worker: WorkerJob) -> dict[str, Any]:
1148
977
  return {
1149
978
  "dispatchMode": worker.backend,
1150
979
  "workerId": worker.worker_id,
@@ -1156,7 +985,7 @@ def _event_details(worker: WorkerDispatch) -> dict[str, Any]:
1156
985
  }
1157
986
 
1158
987
 
1159
- def _attempt_event_details(worker: WorkerDispatch, attempt: int) -> dict[str, Any]:
988
+ def _attempt_event_details(worker: WorkerJob, attempt: int) -> dict[str, Any]:
1160
989
  return {
1161
990
  **_event_details(worker),
1162
991
  "attempt": attempt,
@@ -1176,69 +1005,6 @@ def _should_retry_missing_result(
1176
1005
  )
1177
1006
 
1178
1007
 
1179
- def _missing_completion_paths(worker: WorkerDispatch) -> list[Path]:
1180
- return [path for path in worker.completion_paths if not path.is_file()]
1181
-
1182
-
1183
- def _set_worker_status(
1184
- team_state_path: Path,
1185
- worker_id: str,
1186
- status: str,
1187
- reason: str,
1188
- *,
1189
- model_execution_value: str = "",
1190
- ) -> None:
1191
- payload = _load_json_object(team_state_path, "team-state")
1192
- workers = payload.get("workers")
1193
- if not isinstance(workers, list):
1194
- raise DispatchError(f"team-state workers must be an array: {team_state_path}")
1195
- for worker in workers:
1196
- if isinstance(worker, dict) and worker.get("workerId") == worker_id:
1197
- worker["status"] = status
1198
- worker["reason"] = reason
1199
- if model_execution_value:
1200
- worker["model"] = model_execution_value
1201
- worker["modelExecutionValue"] = model_execution_value
1202
- _write_json(team_state_path, payload)
1203
- return
1204
- raise DispatchError(f"team-state has no workerId={worker_id}: {team_state_path}")
1205
-
1206
-
1207
- def _set_dispatch_mode(team_state_path: Path, dispatch_mode: str) -> None:
1208
- payload = _load_json_object(team_state_path, "team-state")
1209
- payload["dispatchMode"] = dispatch_mode
1210
- _write_json(team_state_path, payload)
1211
-
1212
-
1213
- def _worker_state(team_state: Mapping[str, Any], worker_id: str) -> Mapping[str, Any]:
1214
- workers = team_state.get("workers")
1215
- if not isinstance(workers, list):
1216
- raise DispatchError("team-state workers must be an array")
1217
- for worker in workers:
1218
- if isinstance(worker, Mapping) and worker.get("workerId") == worker_id:
1219
- return worker
1220
- raise DispatchError(f"team-state has no workerId={worker_id}")
1221
-
1222
-
1223
- def _worker_prompt_path(manifest: Mapping[str, Any], worker_id: str) -> str:
1224
- paths = manifest.get("workerPromptPathByWorkerId")
1225
- if not isinstance(paths, Mapping):
1226
- raise DispatchError("run manifest has no workerPromptPathByWorkerId object")
1227
- return _require_string(paths, worker_id)
1228
-
1229
-
1230
- def _worktree_path(
1231
- manifest: Mapping[str, Any],
1232
- active_context: Mapping[str, Any],
1233
- ) -> str:
1234
- if manifest.get("taskType") not in {"implementation", "final-verification"}:
1235
- return ""
1236
- worktree = active_context.get("executorWorktree")
1237
- if not isinstance(worktree, Mapping):
1238
- return ""
1239
- return str(worktree.get("path") or "")
1240
-
1241
-
1242
1008
  def _pane_role(prompt_path: Path) -> str:
1243
1009
  if not prompt_path.is_file():
1244
1010
  raise DispatchError(f"worker prompt not found: {prompt_path}")
@@ -1250,39 +1016,12 @@ def _pane_role(prompt_path: Path) -> str:
1250
1016
  return "worker"
1251
1017
 
1252
1018
 
1253
- def _load_json_object(path: Path, label: str) -> dict[str, Any]:
1254
- if not path.is_file():
1255
- raise DispatchError(f"{label} not found: {path}")
1256
- try:
1257
- payload = json.loads(path.read_text(encoding="utf-8"))
1258
- except json.JSONDecodeError as exc:
1259
- raise DispatchError(f"{label} is invalid JSON: {path}: {exc}") from exc
1260
- if not isinstance(payload, dict):
1261
- raise DispatchError(f"{label} must be a JSON object: {path}")
1262
- return payload
1263
-
1264
-
1265
1019
  def _load_optional_json_object(path: Path | None) -> dict[str, Any]:
1266
1020
  if path is None or not path.is_file():
1267
1021
  return {}
1268
1022
  return hydrate_active_run_context(_load_json_object(path, "active-run-context"))
1269
1023
 
1270
1024
 
1271
- def _resolve_required_path(
1272
- project_root: Path,
1273
- manifest: Mapping[str, Any],
1274
- key: str,
1275
- label: str,
1276
- ) -> Path:
1277
- return _resolve_project_path(project_root, _require_string(manifest, key))
1278
-
1279
-
1280
- def _string_list(value: Any) -> list[str]:
1281
- if not isinstance(value, list):
1282
- return []
1283
- return [str(item).strip() for item in value if str(item).strip()]
1284
-
1285
-
1286
1025
  def _failure_reason(exit_code: int, missing_paths: Sequence[Path]) -> str:
1287
1026
  if exit_code != 0:
1288
1027
  return f"wrapper exited with code {exit_code}"
@@ -1323,20 +1062,6 @@ def _parse_idle_timeout(raw: str) -> int:
1323
1062
  return value
1324
1063
 
1325
1064
 
1326
- def _utc_now() -> str:
1327
- return datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
1328
-
1329
-
1330
- def _write_json(path: Path, payload: Mapping[str, Any]) -> None:
1331
- # Write via temp file + os.replace so a crash mid-write cannot leave a
1332
- # truncated team-state.json that every later _load_json_object rejects;
1333
- # os.replace is atomic on POSIX, matching run_context._atomic_write_json.
1334
- tmp = path.with_suffix(path.suffix + ".tmp")
1335
- tmp.write_text(json.dumps(payload, ensure_ascii=False, indent=2) + "\n",
1336
- encoding="utf-8")
1337
- os.replace(tmp, path)
1338
-
1339
-
1340
1065
  def _print_json(payload: Mapping[str, Any]) -> None:
1341
1066
  print(json.dumps(payload, ensure_ascii=False, indent=2))
1342
1067
 
@@ -202,7 +202,7 @@ def pr_covered_stages(rows: List[Dict[str, Any]]) -> set:
202
202
  # `runs/implementation/carry/stage-<N>.json`. The `done` row in consumers.jsonl
203
203
  # is a derived index that the lead appends by hand (per the implementation
204
204
  # profile) — so it can be missing even when the stage actually finished. The
205
- # dependency gate (`_resolve_stage_base_commit`) reads `done.head_commit`, so a
205
+ # dependency gate (`stage_targets.resolve_stage_base_commit`) reads `done.head_commit`, so a
206
206
  # missing `done` row wrongly blocks downstream stages. We treat the carry file
207
207
  # as the source of truth and backfill the missing `done` rows from it before
208
208
  # the gate runs. A stage with no carry, or an unfinished carry, is left blocked
@@ -218,7 +218,9 @@ def _carry_stage_number(carry: Dict[str, Any], filename: str) -> Optional[int]:
218
218
  return int(m.group(1)) if m else None
219
219
 
220
220
 
221
- _FAILED_CARRY_STATUSES = ("fail", "failed", "blocked", "error", "aborted")
221
+ FAILED_CARRY_STATUSES = ("fail", "failed", "blocked", "error", "aborted")
222
+ """carry 가 실패를 명시하는 status 값. 이 목록은 하나의 business fact 다 —
223
+ implementation_outcome 의 pass-grade 판정도 같은 목록을 본다."""
222
224
 
223
225
 
224
226
  def _carry_is_complete(carry: Dict[str, Any]) -> bool:
@@ -227,12 +229,12 @@ def _carry_is_complete(carry: Dict[str, Any]) -> bool:
227
229
  # Treat it as complete unless it explicitly records a failure status. The
228
230
  # real backfill guard is whether a head commit can be extracted.
229
231
  status = carry.get("status")
230
- if status is not None and str(status).lower() in _FAILED_CARRY_STATUSES:
232
+ if status is not None and str(status).lower() in FAILED_CARRY_STATUSES:
231
233
  return False
232
234
  return True
233
235
 
234
236
 
235
- def _carry_head_commit(carry: Dict[str, Any]) -> str:
237
+ def carry_head_commit(carry: Dict[str, Any]) -> str:
236
238
  rng = carry.get("stageCommitRange")
237
239
  if isinstance(rng, dict) and rng.get("head"):
238
240
  return str(rng["head"])
@@ -288,7 +290,7 @@ def backfill_done_from_carry(plan_run_root: Path) -> int:
288
290
  continue
289
291
  if not _carry_is_complete(carry):
290
292
  continue
291
- head = _carry_head_commit(carry)
293
+ head = carry_head_commit(carry)
292
294
  if not head:
293
295
  continue
294
296
  impl_key = key_by_stage.get(stage) or carry.get("impl_task_key") or fallback_key
@@ -13,7 +13,7 @@ import sys
13
13
  from pathlib import Path
14
14
  from typing import Iterable
15
15
 
16
- from okstra_ctl.paths import RunRef, runs_dir_of
16
+ from okstra_ctl.paths import RunRef, runs_dir_of, task_manifest_file
17
17
  from okstra_ctl.task_target import resolve_task_root, project_rel
18
18
 
19
19
 
@@ -245,7 +245,7 @@ def _lead_phase1_metric(
245
245
  instruction_set = task_root / "instruction-set"
246
246
  packet = instruction_set / "analysis-packet.md"
247
247
  files = [
248
- task_root / "task-manifest.json",
248
+ task_manifest_file(task_root),
249
249
  active_path,
250
250
  instruction_set / "analysis-profile.md",
251
251
  packet if packet.is_file() else instruction_set / "task-brief.md",
@@ -265,7 +265,7 @@ def _lead_phase1_metric(
265
265
  state_dir, f"team-state-{current_phase}-*.json"
266
266
  )
267
267
  files = [
268
- task_root / "task-manifest.json",
268
+ task_manifest_file(task_root),
269
269
  task_root / "instruction-set" / "task-brief.md",
270
270
  task_root / "instruction-set" / "analysis-profile.md",
271
271
  ]
@@ -402,7 +402,7 @@ def _report_writer_metric(run_dir: Path | None, task_root: Path, project_root: P
402
402
 
403
403
 
404
404
  def analyze_task_bundle(task_root: Path, project_root: Path) -> dict:
405
- manifest = _load_json(task_root / "task-manifest.json")
405
+ manifest = _load_json(task_manifest_file(task_root))
406
406
  run_dir = _find_current_run_dir(task_root, manifest, project_root)
407
407
  all_task_files = _all_files(task_root)
408
408
  task_file_count, task_bytes = _count_files(all_task_files)