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
@@ -0,0 +1,231 @@
1
+ """Worker dispatch plumbing shared by both lead dispatchers.
2
+
3
+ `dispatch_core` (claude / external lead) and `codex_dispatch` (codex lead) differ
4
+ in capability — only the former has tmux panes, blocking waits, and retry from a
5
+ persisted record. What they do *not* differ in is the job value object and the
6
+ run's state files: the same run-manifest keys, the same team-state document, the
7
+ same wrapper argv.
8
+
9
+ Those used to be two copies that had already drifted apart: one dispatcher
10
+ wrapped a truncated team-state in `DispatchError` while the other let
11
+ `json.JSONDecodeError` escape, one raised on an unknown `workerId` while the
12
+ other silently did nothing, and one gated the worktree argument on task type
13
+ while the other handed it to every phase. This module is the single reference
14
+ point so a fix cannot land on one lead runtime only.
15
+
16
+ Enforced by `tests/contract/test_dispatcher_shared_helpers.py`.
17
+ """
18
+ from __future__ import annotations
19
+
20
+ import json
21
+ import os
22
+ from dataclasses import dataclass
23
+ from datetime import datetime, timezone
24
+ from pathlib import Path
25
+ from typing import Any, Mapping, Sequence
26
+
27
+ from .worker_prompt_contract import PromptRecord, validate_initial_prompt_records
28
+
29
+ BACKEND_CLI_WRAPPER = "cli-wrapper"
30
+ BACKEND_TMUX_PANE = "tmux-pane"
31
+ BACKEND_MIXED = "mixed"
32
+
33
+ # The worktree argument grants the wrapper `--add-dir` write access outside
34
+ # project-root. Only the phases whose workers mutate a stage worktree get it;
35
+ # analysis phases write their artifacts under project-root (see the contract in
36
+ # scripts/okstra-codex-exec.sh).
37
+ WORKTREE_TASK_TYPES = frozenset({"implementation", "final-verification"})
38
+
39
+
40
+ class DispatchError(Exception):
41
+ """Raised when a worker dispatch request cannot be executed."""
42
+
43
+
44
+ @dataclass(frozen=True)
45
+ class WorkerJob:
46
+ worker_id: str
47
+ provider: str
48
+ backend: str
49
+ project_root: Path
50
+ model_execution_value: str
51
+ wrapper_path: Path
52
+ prompt_path: Path
53
+ result_path: Path
54
+ worker_result_path: Path
55
+ completion_paths: tuple[Path, ...]
56
+ worktree_path: str
57
+ role: str
58
+ idle_timeout_seconds: int
59
+ dispatch_kind: str
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
+ "provider": self.provider,
77
+ "backend": self.backend,
78
+ "modelExecutionValue": self.model_execution_value,
79
+ "wrapperPath": str(self.wrapper_path),
80
+ "promptPath": str(self.prompt_path),
81
+ "resultPath": str(self.result_path),
82
+ "workerResultPath": str(self.worker_result_path),
83
+ "completionPaths": [str(path) for path in self.completion_paths],
84
+ "worktreePath": self.worktree_path,
85
+ "role": self.role,
86
+ "dispatchKind": self.dispatch_kind,
87
+ "command": self.command,
88
+ }
89
+
90
+
91
+ # --- run-manifest reads -------------------------------------------------------
92
+
93
+ def string_value(value: Any) -> str:
94
+ return value.strip() if isinstance(value, str) else ""
95
+
96
+
97
+ def require_string(payload: Mapping[str, Any], key: str) -> str:
98
+ value = payload.get(key)
99
+ if not isinstance(value, str) or not value.strip():
100
+ raise DispatchError(f"missing required string field: {key}")
101
+ return value.strip()
102
+
103
+
104
+ def string_list(value: Any) -> list[str]:
105
+ if not isinstance(value, list):
106
+ return []
107
+ return [str(item).strip() for item in value if str(item).strip()]
108
+
109
+
110
+ def resolve_project_path(project_root: Path, value: str) -> Path:
111
+ path = Path(value)
112
+ return path if path.is_absolute() else project_root / path
113
+
114
+
115
+ def resolve_required_path(
116
+ project_root: Path, manifest: Mapping[str, Any], key: str
117
+ ) -> Path:
118
+ return resolve_project_path(project_root, require_string(manifest, key))
119
+
120
+
121
+ def worker_prompt_path(manifest: Mapping[str, Any], worker_id: str) -> str:
122
+ paths = manifest.get("workerPromptPathByWorkerId")
123
+ if not isinstance(paths, Mapping):
124
+ raise DispatchError("run manifest has no workerPromptPathByWorkerId object")
125
+ return require_string(paths, worker_id)
126
+
127
+
128
+ def worktree_path(
129
+ manifest: Mapping[str, Any], active_context: Mapping[str, Any]
130
+ ) -> str:
131
+ if manifest.get("taskType") not in WORKTREE_TASK_TYPES:
132
+ return ""
133
+ worktree = active_context.get("executorWorktree")
134
+ if not isinstance(worktree, Mapping):
135
+ return ""
136
+ return str(worktree.get("path") or "")
137
+
138
+
139
+ # --- team-state document ------------------------------------------------------
140
+
141
+ def load_json_object(path: Path, label: str) -> dict[str, Any]:
142
+ if not path.is_file():
143
+ raise DispatchError(f"{label} not found: {path}")
144
+ try:
145
+ payload = json.loads(path.read_text(encoding="utf-8"))
146
+ except json.JSONDecodeError as exc:
147
+ raise DispatchError(f"{label} is invalid JSON: {path}: {exc}") from exc
148
+ if not isinstance(payload, dict):
149
+ raise DispatchError(f"{label} must be a JSON object: {path}")
150
+ return payload
151
+
152
+
153
+ def write_json(path: Path, payload: Mapping[str, Any]) -> None:
154
+ # Write via temp file + os.replace so a crash mid-write cannot leave a
155
+ # truncated team-state.json that every later load_json_object rejects;
156
+ # os.replace is atomic on POSIX, matching run_context._atomic_write_json.
157
+ tmp = path.with_suffix(path.suffix + ".tmp")
158
+ tmp.write_text(
159
+ json.dumps(payload, ensure_ascii=False, indent=2) + "\n", encoding="utf-8"
160
+ )
161
+ os.replace(tmp, path)
162
+
163
+
164
+ def worker_state(team_state: Mapping[str, Any], worker_id: str) -> Mapping[str, Any]:
165
+ workers = team_state.get("workers")
166
+ if not isinstance(workers, list):
167
+ raise DispatchError("team-state workers must be an array")
168
+ for worker in workers:
169
+ if isinstance(worker, Mapping) and worker.get("workerId") == worker_id:
170
+ return worker
171
+ raise DispatchError(f"team-state has no workerId={worker_id}")
172
+
173
+
174
+ def set_worker_status(
175
+ team_state_path: Path,
176
+ worker_id: str,
177
+ status: str,
178
+ reason: str,
179
+ *,
180
+ model_execution_value: str = "",
181
+ ) -> None:
182
+ payload = load_json_object(team_state_path, "team-state")
183
+ workers = payload.get("workers")
184
+ if not isinstance(workers, list):
185
+ raise DispatchError(f"team-state workers must be an array: {team_state_path}")
186
+ for worker in workers:
187
+ if isinstance(worker, dict) and worker.get("workerId") == worker_id:
188
+ worker["status"] = status
189
+ worker["reason"] = reason
190
+ if model_execution_value:
191
+ worker["model"] = model_execution_value
192
+ worker["modelExecutionValue"] = model_execution_value
193
+ write_json(team_state_path, payload)
194
+ return
195
+ raise DispatchError(f"team-state has no workerId={worker_id}: {team_state_path}")
196
+
197
+
198
+ def set_dispatch_mode(team_state_path: Path, dispatch_mode: str) -> None:
199
+ payload = load_json_object(team_state_path, "team-state")
200
+ payload["dispatchMode"] = dispatch_mode
201
+ write_json(team_state_path, payload)
202
+
203
+
204
+ # --- job facts ----------------------------------------------------------------
205
+
206
+ def dispatch_mode(jobs: Sequence[WorkerJob]) -> str:
207
+ backends = {job.backend for job in jobs}
208
+ if len(backends) == 1:
209
+ return next(iter(backends))
210
+ return BACKEND_MIXED
211
+
212
+
213
+ def missing_completion_paths(job: WorkerJob) -> tuple[Path, ...]:
214
+ return tuple(path for path in job.completion_paths if not path.is_file())
215
+
216
+
217
+ def validate_initial_prompts(
218
+ manifest: Mapping[str, Any], jobs: Sequence[WorkerJob]
219
+ ) -> None:
220
+ records = [
221
+ PromptRecord(job.worker_id, job.dispatch_kind, job.prompt_path)
222
+ for job in jobs
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
+ def utc_now() -> str:
231
+ return datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
@@ -13,6 +13,7 @@ import sys
13
13
  from pathlib import Path
14
14
 
15
15
  from okstra_ctl.task_target import resolve_task_root, project_rel
16
+ from okstra_project import read_task_key
16
17
  from okstra_ctl.error_log_core import (
17
18
  glob_error_logs,
18
19
  parse_records,
@@ -80,13 +81,7 @@ def _timestamp_segment(now: dt.datetime) -> str:
80
81
 
81
82
 
82
83
  def build_and_write(task_root: Path, project_root: Path, now: dt.datetime) -> dict:
83
- manifest_path = task_root / "task-manifest.json"
84
- task_key = ""
85
- if manifest_path.is_file():
86
- try:
87
- task_key = json.loads(manifest_path.read_text(encoding="utf-8")).get("taskKey", "")
88
- except Exception:
89
- task_key = ""
84
+ task_key = read_task_key(task_root)
90
85
  paths = glob_error_logs(task_root)
91
86
  records, skipped = parse_records(paths)
92
87
  agg = aggregate(records)
@@ -6,8 +6,12 @@ from dataclasses import dataclass, field
6
6
  from pathlib import Path
7
7
  from typing import Any
8
8
 
9
- from .consumers import read_stage_consumer_state
10
- from .paths import RunRef
9
+ from .consumers import (
10
+ FAILED_CARRY_STATUSES,
11
+ carry_head_commit,
12
+ read_stage_consumer_state,
13
+ )
14
+ from .paths import RunRef, task_manifest_file
11
15
  from .workflow import DEFAULT_NEXT_PHASE, PHASE_SEQUENCE
12
16
 
13
17
 
@@ -23,7 +27,7 @@ class ImplementationOutcome:
23
27
 
24
28
  def derive_implementation_outcome(task_root: Path) -> ImplementationOutcome:
25
29
  task_root = Path(task_root)
26
- manifest = _load_json(task_root / "task-manifest.json")
30
+ manifest = _load_json(task_manifest_file(task_root))
27
31
  workflow = manifest.get("workflow") if isinstance(manifest.get("workflow"), dict) else {}
28
32
  if workflow.get("currentPhase") != "implementation":
29
33
  return ImplementationOutcome(completed=False, reason="current phase is not implementation")
@@ -32,7 +36,7 @@ def derive_implementation_outcome(task_root: Path) -> ImplementationOutcome:
32
36
  if not plan_run_root.is_dir():
33
37
  return ImplementationOutcome(completed=False, reason="implementation-planning run root missing")
34
38
 
35
- stage_map = _load_stage_map(task_root, manifest)
39
+ stage_map = load_stage_map(task_root, manifest)
36
40
  if not stage_map:
37
41
  return ImplementationOutcome(completed=False, reason="stage map unavailable")
38
42
 
@@ -53,7 +57,7 @@ def derive_implementation_outcome(task_root: Path) -> ImplementationOutcome:
53
57
  return ImplementationOutcome(
54
58
  completed=True,
55
59
  stages=sorted(required_stages),
56
- head_commit=str(latest_done.get("head_commit") or "") or _carry_head_commit(latest_carry),
60
+ head_commit=str(latest_done.get("head_commit") or "") or carry_head_commit(latest_carry),
57
61
  report_path=_latest_implementation_report(task_root),
58
62
  next_recommended_phase=DEFAULT_NEXT_PHASE["implementation"],
59
63
  reason="all implementation stages have pass-grade carry and done rows",
@@ -67,7 +71,7 @@ def reconcile_implementation_outcome(project_root: Path, task_root: Path) -> boo
67
71
  if not outcome.completed:
68
72
  return False
69
73
 
70
- manifest_path = task_root / "task-manifest.json"
74
+ manifest_path = task_manifest_file(task_root)
71
75
  manifest = _load_json(manifest_path)
72
76
  workflow = manifest.get("workflow")
73
77
  if not isinstance(workflow, dict):
@@ -110,7 +114,7 @@ def _load_json(path: Path) -> dict[str, Any]:
110
114
  return data if isinstance(data, dict) else {}
111
115
 
112
116
 
113
- def _load_stage_map(task_root: Path, manifest: dict[str, Any]) -> list[dict[str, Any]]:
117
+ def load_stage_map(task_root: Path, manifest: dict[str, Any]) -> list[dict[str, Any]]:
114
118
  plan_path = _source_plan_path(task_root, manifest)
115
119
  if not plan_path.is_file():
116
120
  return []
@@ -177,7 +181,7 @@ def _load_carry(task_root: Path, stage: int) -> dict[str, Any] | None:
177
181
 
178
182
  def _carry_passed(carry: dict[str, Any]) -> bool:
179
183
  status = str(carry.get("status") or "").lower()
180
- if status in {"fail", "failed", "blocked", "error", "aborted"}:
184
+ if status in FAILED_CARRY_STATUSES:
181
185
  return False
182
186
  conformance = carry.get("conformance")
183
187
  if isinstance(conformance, dict):
@@ -188,21 +192,6 @@ def _carry_passed(carry: dict[str, Any]) -> bool:
188
192
  return not (isinstance(unverified, list) and unverified)
189
193
 
190
194
 
191
- def _carry_head_commit(carry: dict[str, Any]) -> str:
192
- stage_range = carry.get("stageCommitRange")
193
- if isinstance(stage_range, dict) and stage_range.get("head"):
194
- return str(stage_range["head"])
195
- for key in ("head_sha", "head_commit", "head"):
196
- value = carry.get(key)
197
- if value:
198
- return str(value)
199
- commits = carry.get("commits")
200
- if isinstance(commits, list) and commits:
201
- last = commits[-1]
202
- if isinstance(last, dict) and last.get("sha"):
203
- return str(last["sha"])
204
- return ""
205
-
206
195
 
207
196
  def _latest_implementation_report(task_root: Path) -> str:
208
197
  reports = sorted(
@@ -14,7 +14,7 @@ from pathlib import Path
14
14
  from typing import Any
15
15
 
16
16
  from . import stage_targets
17
- from .design_prep import DesignPrepDecision, resolve_design_prep
17
+ from .design_prep import DesignPrepDecision, DesignPrepError, resolve_design_prep
18
18
  from .final_report_paths import final_report_data_path
19
19
  from .stage_reconcile import auto_reconcile_best_effort
20
20
 
@@ -56,7 +56,12 @@ def _as_implementation_stage_error(exc: Exception) -> ImplementationStageError:
56
56
  def _resolve_stage_design_prep(plan_path: Path, stage: int) -> DesignPrepDecision:
57
57
  plan_data_path = final_report_data_path(plan_path)
58
58
  if plan_data_path.is_file():
59
- return resolve_design_prep(plan_data_path, stage)
59
+ try:
60
+ return resolve_design_prep(plan_data_path, stage)
61
+ except DesignPrepError as exc:
62
+ # prepare catches ImplementationStageError → PrepareError; an
63
+ # untranslated DesignPrepError leaves okstra_ctl.run as a traceback.
64
+ raise _as_implementation_stage_error(exc) from exc
60
65
  return DesignPrepDecision(
61
66
  outcome="proceed",
62
67
  effective_items=(),