okstra 0.136.0 → 0.138.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (50) hide show
  1. package/docs/architecture/storage-model.md +1 -1
  2. package/docs/architecture.md +3 -2
  3. package/docs/contributor-change-matrix.md +1 -1
  4. package/docs/project-structure-overview.md +32 -5
  5. package/package.json +2 -3
  6. package/runtime/BUILD.json +2 -2
  7. package/runtime/agents/workers/antigravity-worker.md +1 -1
  8. package/runtime/agents/workers/codex-worker.md +1 -1
  9. package/runtime/bin/lib/okstra-ctl/cmd-rerun.sh +1 -1
  10. package/runtime/bin/okstra-spawn-followups.py +4 -9
  11. package/runtime/prompts/lead/adapters/claude-code.md +1 -0
  12. package/runtime/prompts/lead/adapters/codex.md +1 -0
  13. package/runtime/prompts/lead/adapters/external.md +1 -0
  14. package/runtime/prompts/lead/plan-body-verification.md +2 -2
  15. package/runtime/prompts/lead/report-writer.md +11 -10
  16. package/runtime/prompts/lead/team-contract.md +4 -2
  17. package/runtime/prompts/profiles/_implementation-diff-review.md +1 -1
  18. package/runtime/prompts/profiles/_implementation-executor.md +2 -2
  19. package/runtime/prompts/profiles/_implementation-self-check.md +1 -1
  20. package/runtime/python/okstra_ctl/codex_dispatch.py +199 -681
  21. package/runtime/python/okstra_ctl/consumers.py +7 -5
  22. package/runtime/python/okstra_ctl/context_cost.py +4 -4
  23. package/runtime/python/okstra_ctl/dispatch_core.py +146 -287
  24. package/runtime/python/okstra_ctl/dispatch_state.py +231 -0
  25. package/runtime/python/okstra_ctl/error_report.py +2 -7
  26. package/runtime/python/okstra_ctl/implementation_outcome.py +12 -23
  27. package/runtime/python/okstra_ctl/implementation_stage.py +7 -2
  28. package/runtime/python/okstra_ctl/initial_prompt_materialization.py +1053 -0
  29. package/runtime/python/okstra_ctl/path_hints.py +3 -3
  30. package/runtime/python/okstra_ctl/paths.py +17 -2
  31. package/runtime/python/okstra_ctl/recap.py +5 -12
  32. package/runtime/python/okstra_ctl/render.py +18 -19
  33. package/runtime/python/okstra_ctl/report_finalize.py +8 -4
  34. package/runtime/python/okstra_ctl/report_language.py +83 -0
  35. package/runtime/python/okstra_ctl/run.py +262 -328
  36. package/runtime/python/okstra_ctl/set_work_status.py +2 -1
  37. package/runtime/python/okstra_ctl/stage_targets.py +330 -101
  38. package/runtime/python/okstra_ctl/time_report.py +4 -9
  39. package/runtime/python/okstra_ctl/wizard.py +47 -49
  40. package/runtime/python/okstra_ctl/work_categories.py +2 -2
  41. package/runtime/python/okstra_ctl/worker_prompt_body.py +82 -0
  42. package/runtime/python/okstra_ctl/worker_prompt_contract.py +73 -13
  43. package/runtime/python/okstra_ctl/worker_prompt_headers.py +7 -0
  44. package/runtime/python/okstra_ctl/worktree.py +37 -29
  45. package/runtime/python/okstra_project/__init__.py +4 -0
  46. package/runtime/python/okstra_project/dirs.py +7 -0
  47. package/runtime/python/okstra_project/state.py +78 -8
  48. package/runtime/validators/lib/fixtures.sh +2 -0
  49. package/runtime/validators/validate-run.py +3 -21
  50. package/src/commands/inspect/stage-map.mjs +12 -19
@@ -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)
@@ -11,81 +11,52 @@ from pathlib import Path
11
11
  from typing import Any, Mapping, Sequence
12
12
 
13
13
  from . import tmux
14
+ from .dispatch_state import (
15
+ BACKEND_CLI_WRAPPER,
16
+ BACKEND_MIXED,
17
+ BACKEND_TMUX_PANE,
18
+ DispatchError,
19
+ WorkerJob,
20
+ dispatch_mode as _dispatch_mode,
21
+ load_json_object as _load_json_object,
22
+ missing_completion_paths as _missing_completion_paths,
23
+ require_string as _require_string,
24
+ resolve_project_path as _resolve_project_path,
25
+ resolve_required_path as _resolve_required_path,
26
+ set_dispatch_mode as _set_dispatch_mode,
27
+ set_worker_status as _set_worker_status,
28
+ string_list as _string_list,
29
+ string_value as _string_value,
30
+ utc_now as _utc_now,
31
+ validate_initial_prompts as _validate_initial_prompts,
32
+ worker_state as _worker_state,
33
+ worktree_path as _worktree_path,
34
+ write_json as _write_json,
35
+ )
14
36
  from .final_report_paths import (
15
37
  final_report_data_path as _final_report_data_path,
16
38
  final_report_markdown_path as _final_report_markdown_path,
17
39
  )
18
40
  from .lead_events import LeadEvent, append_lead_event
41
+ from .initial_prompt_materialization import (
42
+ InitialPromptMaterializationError,
43
+ InitialPromptMaterializationRequest,
44
+ InitialPromptWorkerRequest,
45
+ PromptDeliveryMode,
46
+ materialize_initial_prompts,
47
+ )
19
48
  from .path_hints import hydrate_active_run_context
20
- from .worker_prompt_headers import WorkerPromptHeaderError, worker_prompt_headers
21
- from .worker_prompt_body import analysis_prompt_body
22
- from .worker_prompt_contract import PromptRecord, validate_initial_prompt_records
23
- from .worker_prompt_policy import resolve_prompt_plan_for_manifest
49
+ from .worker_prompt_body import REPORT_WRITER_WORKER_ID
24
50
  from .worker_artifact_paths import audit_sidecar_rel
25
51
  from .wrapper_status import read_wrapper_status, status_path_for_prompt
26
52
 
27
53
 
28
- BACKEND_CLI_WRAPPER = "cli-wrapper"
29
- BACKEND_TMUX_PANE = "tmux-pane"
30
- BACKEND_MIXED = "mixed"
31
54
  LIVENESS_AUDIT_HEARTBEAT = "audit-heartbeat"
32
55
  LIVENESS_WRAPPER_STATUS = "wrapper-status"
33
56
  MAX_WORKER_ATTEMPTS = 2
34
- REPORT_WRITER_WORKER_ID = "report-writer"
35
57
  TERMINAL_DISPATCH_STATUSES = {"completed", "timeout", "error", "not-run"}
36
58
 
37
59
 
38
- class DispatchError(Exception):
39
- """Raised when a worker dispatch request cannot be executed."""
40
-
41
-
42
- @dataclass(frozen=True)
43
- class WorkerJob:
44
- worker_id: str
45
- provider: str
46
- backend: str
47
- project_root: Path
48
- model_execution_value: str
49
- wrapper_path: Path
50
- prompt_path: Path
51
- result_path: Path
52
- worker_result_path: Path
53
- completion_paths: tuple[Path, ...]
54
- worktree_path: str
55
- role: str
56
- idle_timeout_seconds: int
57
- dispatch_kind: str
58
-
59
- @property
60
- def command(self) -> list[str]:
61
- return [
62
- str(self.wrapper_path),
63
- str(self.project_root),
64
- self.model_execution_value,
65
- str(self.prompt_path),
66
- self.worktree_path,
67
- self.role,
68
- str(self.idle_timeout_seconds),
69
- ]
70
-
71
- def to_payload(self) -> dict[str, Any]:
72
- return {
73
- "workerId": self.worker_id,
74
- "provider": self.provider,
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
- "dispatchKind": self.dispatch_kind,
85
- "command": self.command,
86
- }
87
-
88
-
89
60
  @dataclass(frozen=True)
90
61
  class WorkerHandle:
91
62
  job: WorkerJob
@@ -146,6 +117,15 @@ class _BuildOptions:
146
117
  dispatch_kind: str
147
118
 
148
119
 
120
+ @dataclass(frozen=True)
121
+ class _RosterWorkerFacts:
122
+ worker_id: str
123
+ model: str
124
+ provider: str
125
+ result_path: str
126
+ role: str
127
+
128
+
149
129
  def build_dispatch_plan(
150
130
  *,
151
131
  project_root: Path,
@@ -178,18 +158,22 @@ def build_dispatch_plan(
178
158
  unsupported_worker_label=unsupported_worker_label,
179
159
  dispatch_kind=dispatch_kind,
180
160
  )
181
- jobs = _jobs_from_file(project_root, workspace_root, jobs_file, options) if jobs_file else _jobs_from_roster(
182
- project_root,
183
- workspace_root,
184
- manifest,
185
- team_state,
186
- active_context,
187
- requested_workers,
188
- enable_report_writer,
189
- report_writer_model,
190
- options,
191
- )
192
- _validate_initial_prompts(manifest, jobs)
161
+ if jobs_file:
162
+ jobs = _jobs_from_file(project_root, workspace_root, jobs_file, options)
163
+ _validate_initial_prompts(manifest, jobs)
164
+ else:
165
+ jobs = _jobs_from_roster(
166
+ project_root,
167
+ workspace_root,
168
+ manifest_path,
169
+ manifest,
170
+ team_state,
171
+ active_context,
172
+ requested_workers,
173
+ enable_report_writer,
174
+ report_writer_model,
175
+ options,
176
+ )
193
177
  return DispatchPlan(
194
178
  project_root=project_root,
195
179
  workspace_root=workspace_root.resolve(),
@@ -202,20 +186,6 @@ def build_dispatch_plan(
202
186
  )
203
187
 
204
188
 
205
- def _validate_initial_prompts(
206
- manifest: Mapping[str, Any],
207
- jobs: Sequence[WorkerJob],
208
- ) -> None:
209
- records = [
210
- PromptRecord(job.worker_id, job.dispatch_kind, job.prompt_path)
211
- for job in jobs
212
- ]
213
- errors = validate_initial_prompt_records(manifest=manifest, records=records)
214
- if errors:
215
- task_type = _require_string(manifest, "taskType")
216
- raise DispatchError(f"{task_type} prompt contract: " + "; ".join(errors))
217
-
218
-
219
189
  def dispatch_plan(plan: DispatchPlan, *, wait: bool = True) -> int:
220
190
  if wait:
221
191
  if any(job.backend == BACKEND_TMUX_PANE for job in plan.jobs):
@@ -264,6 +234,7 @@ def await_dispatches(
264
234
  def _jobs_from_roster(
265
235
  project_root: Path,
266
236
  workspace_root: Path,
237
+ manifest_path: Path,
267
238
  manifest: Mapping[str, Any],
268
239
  team_state: Mapping[str, Any],
269
240
  active_context: Mapping[str, Any],
@@ -274,12 +245,27 @@ def _jobs_from_roster(
274
245
  ) -> list[WorkerJob]:
275
246
  selected = _select_workers(manifest, requested_workers, enable_report_writer, options)
276
247
  _mark_roster_skips(project_root, manifest, team_state, selected, requested_workers, options)
248
+ worker_facts, prompt_paths = _materialize_roster_prompts(
249
+ project_root,
250
+ workspace_root,
251
+ manifest_path,
252
+ manifest,
253
+ team_state,
254
+ selected,
255
+ report_writer_model,
256
+ options,
257
+ )
277
258
  return [
278
259
  _job_from_roster_worker(
279
- project_root, workspace_root, manifest, team_state, active_context,
280
- worker_id, report_writer_model, options,
260
+ project_root,
261
+ workspace_root,
262
+ manifest,
263
+ active_context,
264
+ fact,
265
+ prompt_path,
266
+ options,
281
267
  )
282
- for worker_id in selected
268
+ for fact, prompt_path in zip(worker_facts, prompt_paths, strict=True)
283
269
  ]
284
270
 
285
271
 
@@ -314,49 +300,87 @@ def _job_from_roster_worker(
314
300
  project_root: Path,
315
301
  workspace_root: Path,
316
302
  manifest: Mapping[str, Any],
317
- team_state: Mapping[str, Any],
318
303
  active_context: Mapping[str, Any],
319
- worker_id: str,
320
- report_writer_model: str,
304
+ fact: _RosterWorkerFacts,
305
+ prompt_path: Path,
321
306
  options: _BuildOptions,
322
307
  ) -> WorkerJob:
323
- state = _worker_state(team_state, worker_id)
324
- provider = _provider_for_worker(worker_id)
325
- model = _model_for_worker(worker_id, state, report_writer_model)
326
- prompt_rel = _worker_prompt_path(manifest, worker_id)
327
- result_rel = _require_string(state, "resultPath")
328
- prompt_path = _resolve_project_path(project_root, prompt_rel)
329
- result_path = _resolve_project_path(project_root, result_rel)
330
- _materialize_prompt_if_missing(
331
- prompt_path=prompt_path,
332
- prompt_rel=prompt_rel,
333
- worker_id=worker_id,
334
- result_rel=result_rel,
335
- project_root=project_root,
336
- manifest=manifest,
337
- active_context=active_context,
338
- dispatch_kind=options.dispatch_kind,
339
- model=model,
340
- role=_string_value(state.get("role")) or "worker",
341
- )
308
+ result_path = _resolve_project_path(project_root, fact.result_path)
342
309
  return WorkerJob(
343
- worker_id=worker_id,
344
- provider=provider,
310
+ worker_id=fact.worker_id,
311
+ provider=fact.provider,
345
312
  backend=options.default_backend,
346
313
  project_root=project_root,
347
- model_execution_value=model,
348
- wrapper_path=_resolve_wrapper(provider, workspace_root, options),
314
+ model_execution_value=fact.model,
315
+ wrapper_path=_resolve_wrapper(fact.provider, workspace_root, options),
349
316
  prompt_path=prompt_path,
350
- result_path=_result_path_for_worker(worker_id, result_path, manifest, project_root),
317
+ result_path=_result_path_for_worker(
318
+ fact.worker_id, result_path, manifest, project_root
319
+ ),
351
320
  worker_result_path=result_path,
352
- completion_paths=_completion_paths(worker_id, result_path, manifest, project_root),
353
- worktree_path=_worktree_path(active_context),
354
- role=_string_value(state.get("role")) or "worker",
321
+ completion_paths=_completion_paths(
322
+ fact.worker_id, result_path, manifest, project_root
323
+ ),
324
+ worktree_path=_worktree_path(manifest, active_context),
325
+ role=fact.role,
355
326
  idle_timeout_seconds=options.idle_timeout_seconds,
356
327
  dispatch_kind=options.dispatch_kind,
357
328
  )
358
329
 
359
330
 
331
+ def _materialize_roster_prompts(
332
+ project_root: Path,
333
+ workspace_root: Path,
334
+ manifest_path: Path,
335
+ manifest: Mapping[str, Any],
336
+ team_state: Mapping[str, Any],
337
+ selected: Sequence[str],
338
+ report_writer_model: str,
339
+ options: _BuildOptions,
340
+ ) -> tuple[tuple[_RosterWorkerFacts, ...], tuple[Path, ...]]:
341
+ worker_facts = tuple(
342
+ _roster_worker_facts(team_state, worker_id, report_writer_model)
343
+ for worker_id in selected
344
+ )
345
+ try:
346
+ prompt_paths = materialize_initial_prompts(
347
+ InitialPromptMaterializationRequest(
348
+ project_root=project_root,
349
+ run_manifest_path=manifest_path,
350
+ runtime_root=workspace_root,
351
+ delivery_mode=_delivery_mode(options.default_backend),
352
+ workers=tuple(
353
+ InitialPromptWorkerRequest(fact.worker_id, fact.model)
354
+ for fact in worker_facts
355
+ ),
356
+ )
357
+ )
358
+ except InitialPromptMaterializationError as exc:
359
+ raise DispatchError(f"{exc.reason}: {exc}") from exc
360
+ return worker_facts, prompt_paths
361
+
362
+
363
+ def _roster_worker_facts(
364
+ team_state: Mapping[str, Any],
365
+ worker_id: str,
366
+ report_writer_model: str,
367
+ ) -> _RosterWorkerFacts:
368
+ state = _worker_state(team_state, worker_id)
369
+ return _RosterWorkerFacts(
370
+ worker_id=worker_id,
371
+ model=_model_for_worker(worker_id, state, report_writer_model),
372
+ provider=_provider_for_worker(worker_id),
373
+ result_path=_require_string(state, "resultPath"),
374
+ role=_string_value(state.get("role")) or "worker",
375
+ )
376
+
377
+
378
+ def _delivery_mode(default_backend: str) -> PromptDeliveryMode:
379
+ if default_backend == BACKEND_TMUX_PANE:
380
+ return PromptDeliveryMode.LAZY_PATH_REFERENCE
381
+ return PromptDeliveryMode.EAGER_INCLUDE
382
+
383
+
360
384
  def _jobs_from_file(
361
385
  project_root: Path,
362
386
  workspace_root: Path,
@@ -412,7 +436,10 @@ def _job_from_file_worker(
412
436
 
413
437
 
414
438
  def _spawn_job(plan: DispatchPlan, job: WorkerJob, attempt: int) -> WorkerHandle:
415
- _set_worker_status(plan.team_state_path, job.worker_id, "running", "", job.model_execution_value)
439
+ _set_worker_status(
440
+ plan.team_state_path, job.worker_id, "running", "",
441
+ model_execution_value=job.model_execution_value,
442
+ )
416
443
  handle = _start_job(plan, job)
417
444
  _record_dispatch(plan.team_state_path, handle, attempt, "running", "")
418
445
  _append_event(plan, "worker-dispatched", _attempt_details(job, attempt, handle))
@@ -595,30 +622,6 @@ def _record_matches(record: object, worker_id: str, kind: str, attempt: int) ->
595
622
  return isinstance(record, dict) and record.get("workerId") == worker_id and record.get("kind") == kind and record.get("attempt") == attempt
596
623
 
597
624
 
598
- def _set_worker_status(
599
- team_state_path: Path, worker_id: str, status: str, reason: str, model: str = ""
600
- ) -> None:
601
- payload = _load_json_object(team_state_path, "team-state")
602
- workers = payload.get("workers")
603
- if not isinstance(workers, list):
604
- raise DispatchError(f"team-state workers must be an array: {team_state_path}")
605
- for worker in workers:
606
- if isinstance(worker, dict) and worker.get("workerId") == worker_id:
607
- worker["status"] = status
608
- worker["reason"] = reason
609
- if model:
610
- worker["model"] = model
611
- worker["modelExecutionValue"] = model
612
- _write_json(team_state_path, payload)
613
- return
614
-
615
-
616
- def _set_dispatch_mode(team_state_path: Path, dispatch_mode: str) -> None:
617
- payload = _load_json_object(team_state_path, "team-state")
618
- payload["dispatchMode"] = dispatch_mode
619
- _write_json(team_state_path, payload)
620
-
621
-
622
625
  def _running_dispatches(team_state_path: Path) -> list[Mapping[str, Any]]:
623
626
  payload = _load_json_object(team_state_path, "team-state")
624
627
  dispatches = payload.get("workerDispatches")
@@ -764,10 +767,6 @@ def _failure_reason(outcome: WorkerOutcome) -> str:
764
767
  return "worker dispatch failed"
765
768
 
766
769
 
767
- def _missing_completion_paths(job: WorkerJob) -> tuple[Path, ...]:
768
- return tuple(path for path in job.completion_paths if not path.is_file())
769
-
770
-
771
770
  def _mode_from_handles(handles: Sequence[WorkerHandle]) -> str:
772
771
  modes = {BACKEND_CLI_WRAPPER if h.degraded_from else h.job.backend for h in handles}
773
772
  if len(modes) == 1:
@@ -775,13 +774,6 @@ def _mode_from_handles(handles: Sequence[WorkerHandle]) -> str:
775
774
  return BACKEND_MIXED
776
775
 
777
776
 
778
- def _dispatch_mode(jobs: Sequence[WorkerJob]) -> str:
779
- backends = {job.backend for job in jobs}
780
- if len(backends) == 1:
781
- return next(iter(backends))
782
- return BACKEND_MIXED
783
-
784
-
785
777
  def _validate_manifest(manifest: Mapping[str, Any], path: Path, required: str | None) -> None:
786
778
  if required is not None and manifest.get("leadRuntime") != required:
787
779
  raise DispatchError(f"run manifest is not a {required} lead run: {path}")
@@ -803,71 +795,6 @@ def _resolve_wrapper(provider: str, workspace_root: Path, options: _BuildOptions
803
795
  raise DispatchError(f"{script} not found (searched: {', '.join(str(c) for c in candidates)})")
804
796
 
805
797
 
806
- def _materialize_prompt_if_missing(
807
- *,
808
- prompt_path: Path,
809
- prompt_rel: str,
810
- worker_id: str,
811
- result_rel: str,
812
- project_root: Path,
813
- manifest: Mapping[str, Any],
814
- active_context: Mapping[str, Any],
815
- dispatch_kind: str,
816
- model: str,
817
- role: str,
818
- ) -> None:
819
- if prompt_path.is_file():
820
- return
821
- prompt_path.parent.mkdir(parents=True, exist_ok=True)
822
- try:
823
- headers = worker_prompt_headers(
824
- project_root=project_root,
825
- prompt_rel=prompt_rel,
826
- result_rel=result_rel,
827
- worker_id=worker_id,
828
- dispatch_kind=dispatch_kind,
829
- manifest=manifest,
830
- active_context=active_context,
831
- )
832
- except WorkerPromptHeaderError as exc:
833
- raise DispatchError(str(exc)) from exc
834
- try:
835
- plan = resolve_prompt_plan_for_manifest(
836
- manifest=manifest,
837
- worker_id=worker_id,
838
- dispatch_kind=dispatch_kind,
839
- )
840
- except ValueError as exc:
841
- raise DispatchError(str(exc)) from exc
842
- lines = list(headers)
843
- if plan.audience in {
844
- "analysis",
845
- "implementation-executor",
846
- "implementation-verifier",
847
- }:
848
- if _string_value(manifest.get("taskType")) == "implementation":
849
- worktree = _worktree_path(active_context)
850
- if not worktree:
851
- raise DispatchError(
852
- "implementation prompt generation requires a worktree path"
853
- )
854
- lines.append(f"**Worktree:** {worktree}")
855
- try:
856
- lines.extend(
857
- analysis_prompt_body(
858
- manifest,
859
- active_context,
860
- worker_id,
861
- model,
862
- role,
863
- plan,
864
- )
865
- )
866
- except ValueError as exc:
867
- raise DispatchError(str(exc)) from exc
868
- prompt_path.write_text("\n".join([*lines, ""]), encoding="utf-8")
869
-
870
-
871
798
  def _provider_for_worker(worker_id: str) -> str:
872
799
  if worker_id == REPORT_WRITER_WORKER_ID:
873
800
  return "claude"
@@ -905,38 +832,6 @@ def _run_dir(plan: DispatchPlan) -> Path:
905
832
  return _resolve_project_path(plan.project_root, value) if value else plan.team_state_path.parent.parent
906
833
 
907
834
 
908
- def _worktree_path(active_context: Mapping[str, Any]) -> str:
909
- worktree = active_context.get("executorWorktree")
910
- if isinstance(worktree, dict):
911
- return _string_value(worktree.get("path"))
912
- return ""
913
-
914
-
915
- def _worker_state(team_state: Mapping[str, Any], worker_id: str) -> Mapping[str, Any]:
916
- workers = team_state.get("workers")
917
- if isinstance(workers, list):
918
- for worker in workers:
919
- if isinstance(worker, dict) and worker.get("workerId") == worker_id:
920
- return worker
921
- raise DispatchError(f"team-state has no workerId={worker_id}")
922
-
923
-
924
- def _worker_prompt_path(manifest: Mapping[str, Any], worker_id: str) -> str:
925
- paths = manifest.get("workerPromptPathByWorkerId")
926
- if not isinstance(paths, Mapping):
927
- raise DispatchError("run manifest has no workerPromptPathByWorkerId")
928
- return _require_string(paths, worker_id)
929
-
930
-
931
- def _load_json_object(path: Path, label: str) -> dict[str, Any]:
932
- if not path.is_file():
933
- raise DispatchError(f"{label} not found: {path}")
934
- payload = json.loads(path.read_text(encoding="utf-8"))
935
- if not isinstance(payload, dict):
936
- raise DispatchError(f"{label} must be a JSON object: {path}")
937
- return payload
938
-
939
-
940
835
  def _load_optional_json(project_root: Path, value: Any) -> dict[str, Any]:
941
836
  if not isinstance(value, str) or not value.strip():
942
837
  return {}
@@ -946,50 +841,14 @@ def _load_optional_json(project_root: Path, value: Any) -> dict[str, Any]:
946
841
  return hydrate_active_run_context(_load_json_object(path, "active-run-context"))
947
842
 
948
843
 
949
- def _resolve_required_path(project_root: Path, manifest: Mapping[str, Any], key: str) -> Path:
950
- return _resolve_project_path(project_root, _require_string(manifest, key))
951
-
952
-
953
- def _resolve_project_path(project_root: Path, value: str) -> Path:
954
- path = Path(value)
955
- return path if path.is_absolute() else project_root / path
956
-
957
-
958
844
  def _optional_path(value: object) -> Path | None:
959
845
  if not isinstance(value, str) or not value.strip():
960
846
  return None
961
847
  return Path(value)
962
848
 
963
849
 
964
- def _require_string(payload: Mapping[str, Any], key: str) -> str:
965
- value = payload.get(key)
966
- if not isinstance(value, str) or not value.strip():
967
- raise DispatchError(f"required string missing: {key}")
968
- return value.strip()
969
-
970
-
971
- def _string_value(value: Any) -> str:
972
- return value.strip() if isinstance(value, str) else ""
973
-
974
-
975
- def _string_list(value: Any) -> list[str]:
976
- if not isinstance(value, list):
977
- return []
978
- return [str(item).strip() for item in value if str(item).strip()]
979
-
980
-
981
850
  def _run_seq(manifest: Mapping[str, Any]) -> str:
982
851
  seqs = manifest.get("runSequencesByCategory")
983
852
  if isinstance(seqs, Mapping):
984
853
  return _string_value(seqs.get("manifests")) or "001"
985
854
  return "001"
986
-
987
-
988
- def _utc_now() -> str:
989
- return datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
990
-
991
-
992
- def _write_json(path: Path, payload: Mapping[str, Any]) -> None:
993
- tmp = path.with_suffix(path.suffix + ".tmp")
994
- tmp.write_text(json.dumps(payload, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
995
- os.replace(tmp, path)