okstra 0.127.0 → 0.129.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 (53) hide show
  1. package/docs/architecture/storage-model.md +23 -3
  2. package/docs/architecture.md +28 -1
  3. package/docs/cli.md +9 -0
  4. package/docs/project-structure-overview.md +17 -7
  5. package/package.json +1 -1
  6. package/runtime/BUILD.json +2 -2
  7. package/runtime/agents/workers/antigravity-worker.md +3 -3
  8. package/runtime/agents/workers/claude-worker.md +4 -4
  9. package/runtime/agents/workers/codex-worker.md +3 -3
  10. package/runtime/agents/workers/report-writer-worker.md +5 -5
  11. package/runtime/prompts/lead/adapters/claude-code.md +2 -1
  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/convergence.md +45 -83
  15. package/runtime/prompts/lead/okstra-lead-contract.md +12 -8
  16. package/runtime/prompts/lead/report-writer.md +3 -2
  17. package/runtime/prompts/lead/team-contract.md +19 -9
  18. package/runtime/prompts/profiles/_common-contract.md +1 -1
  19. package/runtime/prompts/profiles/error-analysis.md +1 -1
  20. package/runtime/prompts/profiles/final-verification.md +10 -6
  21. package/runtime/prompts/profiles/implementation-planning.md +5 -0
  22. package/runtime/prompts/profiles/improvement-discovery.md +11 -1
  23. package/runtime/prompts/profiles/requirements-discovery.md +8 -1
  24. package/runtime/python/okstra_ctl/analysis_packet.py +14 -15
  25. package/runtime/python/okstra_ctl/codex_dispatch.py +51 -80
  26. package/runtime/python/okstra_ctl/context_cost.py +82 -7
  27. package/runtime/python/okstra_ctl/convergence.py +223 -0
  28. package/runtime/python/okstra_ctl/convergence_engine.py +1152 -0
  29. package/runtime/python/okstra_ctl/convergence_migration.py +184 -0
  30. package/runtime/python/okstra_ctl/convergence_store.py +85 -0
  31. package/runtime/python/okstra_ctl/dispatch_core.py +60 -1
  32. package/runtime/python/okstra_ctl/improvement_assignment.py +61 -0
  33. package/runtime/python/okstra_ctl/log_report.py +44 -5
  34. package/runtime/python/okstra_ctl/path_hints.py +28 -1
  35. package/runtime/python/okstra_ctl/paths.py +10 -1
  36. package/runtime/python/okstra_ctl/render.py +78 -11
  37. package/runtime/python/okstra_ctl/run.py +66 -2
  38. package/runtime/python/okstra_ctl/wizard.py +15 -4
  39. package/runtime/python/okstra_ctl/work_categories.py +37 -0
  40. package/runtime/python/okstra_ctl/worker_prompt_body.py +107 -0
  41. package/runtime/python/okstra_ctl/worker_prompt_contract.py +316 -0
  42. package/runtime/python/okstra_ctl/worker_prompt_headers.py +109 -10
  43. package/runtime/python/okstra_ctl/worker_prompt_policy.py +158 -0
  44. package/runtime/templates/implementation-worker-preamble.md +51 -0
  45. package/runtime/templates/operating-standard.md +4 -4
  46. package/runtime/templates/report-writer-prompt-preamble.md +37 -0
  47. package/runtime/templates/worker-error-contract.md +46 -0
  48. package/runtime/templates/worker-prompt-preamble.md +28 -227
  49. package/runtime/validators/lib/fixtures.sh +27 -12
  50. package/runtime/validators/validate-run.py +228 -45
  51. package/src/cli-registry.mjs +7 -0
  52. package/src/commands/execute/convergence.mjs +34 -0
  53. package/src/commands/inspect/log-report.mjs +5 -3
@@ -0,0 +1,184 @@
1
+ """Resume and legacy-state migration decisions for convergence seeding."""
2
+ from __future__ import annotations
3
+
4
+ from dataclasses import dataclass
5
+ import hashlib
6
+ import os
7
+ from pathlib import Path
8
+ import tempfile
9
+ from typing import Any, Literal, Mapping
10
+
11
+ from .convergence_engine import (
12
+ ConvergenceContractError,
13
+ grouped_input_digest,
14
+ validate_final_state,
15
+ validate_working_state,
16
+ )
17
+ from .convergence_store import load_json_object
18
+
19
+
20
+ SeedAction = Literal[
21
+ "create-work",
22
+ "resume-work",
23
+ "reuse-final",
24
+ "restart-round0",
25
+ ]
26
+
27
+
28
+ @dataclass(frozen=True)
29
+ class SeedDecision:
30
+ action: SeedAction
31
+ archive_path: Path | None
32
+ legacy_digest: str | None
33
+ reason: str
34
+
35
+
36
+ def decide_seed_action(
37
+ *,
38
+ grouped_input: Mapping[str, Any],
39
+ work_state_path: Path,
40
+ final_state_path: Path,
41
+ restart_from_round0: bool,
42
+ ) -> SeedDecision:
43
+ """Classify existing new-engine/legacy state without mutating files."""
44
+ task_key = grouped_input.get("taskKey")
45
+ digest = grouped_input_digest(grouped_input)
46
+ final_status, final_value = _inspect_final(final_state_path)
47
+ if final_status == "terminal":
48
+ return SeedDecision("reuse-final", None, None, "valid terminal final state")
49
+
50
+ work_status, work_value = _inspect_work(work_state_path)
51
+ invalid_final = final_status == "invalid"
52
+ if work_status == "valid":
53
+ assert work_value is not None
54
+ if work_value.get("taskKey") != task_key:
55
+ raise ConvergenceContractError(
56
+ "existing working state taskKey does not match grouped input"
57
+ )
58
+ if work_value.get("groupsDigest") != digest:
59
+ raise ConvergenceContractError(
60
+ "existing working state groupsDigest does not match grouped input"
61
+ )
62
+ if invalid_final:
63
+ archive_path, legacy_digest = _archive_identity(
64
+ final_state_path, final_state_path.parent / "migrations"
65
+ )
66
+ return SeedDecision(
67
+ "resume-work",
68
+ archive_path,
69
+ legacy_digest,
70
+ "matching working state with invalid legacy final",
71
+ )
72
+ return SeedDecision("resume-work", None, None, "matching working state")
73
+
74
+ if work_status == "invalid" and not restart_from_round0:
75
+ raise ConvergenceContractError(
76
+ "existing convergence working state is invalid; pass "
77
+ "--restart-from-round0 to archive it and restart"
78
+ )
79
+ if work_status == "invalid":
80
+ source = final_state_path if invalid_final else work_state_path
81
+ archive_path, legacy_digest = _archive_identity(
82
+ source, source.parent / "migrations"
83
+ )
84
+ return SeedDecision(
85
+ "restart-round0",
86
+ archive_path,
87
+ legacy_digest,
88
+ "explicit restart of invalid working state",
89
+ )
90
+ if invalid_final:
91
+ archive_path, legacy_digest = _archive_identity(
92
+ final_state_path, final_state_path.parent / "migrations"
93
+ )
94
+ return SeedDecision(
95
+ "restart-round0",
96
+ archive_path,
97
+ legacy_digest,
98
+ "invalid or partial legacy final state",
99
+ )
100
+ return SeedDecision("create-work", None, None, "no existing convergence state")
101
+
102
+
103
+ def archive_state_bytes(
104
+ *,
105
+ source_path: Path,
106
+ migration_dir: Path,
107
+ ) -> tuple[Path, str]:
108
+ """Archive source bytes under a digest-derived immutable filename."""
109
+ data = source_path.read_bytes()
110
+ digest = hashlib.sha256(data).hexdigest()
111
+ archive_path = migration_dir / f"{source_path.name}.{digest[:12]}.json"
112
+ if archive_path.exists():
113
+ if archive_path.read_bytes() != data:
114
+ raise ConvergenceContractError(
115
+ f"migration archive path contains different bytes: {archive_path}"
116
+ )
117
+ return archive_path, digest
118
+ migration_dir.mkdir(parents=True, exist_ok=True)
119
+ temp_path: Path | None = None
120
+ try:
121
+ with tempfile.NamedTemporaryFile(
122
+ mode="wb",
123
+ dir=migration_dir,
124
+ prefix=f".{archive_path.name}.",
125
+ suffix=".tmp",
126
+ delete=False,
127
+ ) as handle:
128
+ temp_path = Path(handle.name)
129
+ handle.write(data)
130
+ handle.flush()
131
+ os.fsync(handle.fileno())
132
+ os.replace(temp_path, archive_path)
133
+ temp_path = None
134
+ finally:
135
+ if temp_path is not None:
136
+ temp_path.unlink(missing_ok=True)
137
+ return archive_path, digest
138
+
139
+
140
+ def migration_record(
141
+ source_path: Path,
142
+ archive_path: Path,
143
+ digest: str,
144
+ ) -> dict[str, str]:
145
+ return {
146
+ "mode": "restart-round0",
147
+ "sourcePath": str(source_path),
148
+ "archivePath": str(archive_path),
149
+ "legacyDigest": digest,
150
+ }
151
+
152
+
153
+ def _inspect_final(path: Path) -> tuple[str, dict[str, Any] | None]:
154
+ if not path.exists():
155
+ return "absent", None
156
+ try:
157
+ value = load_json_object(path)
158
+ except (OSError, ValueError):
159
+ return "invalid", None
160
+ if validate_final_state(value):
161
+ return "invalid", value
162
+ if value.get("finalState") not in {
163
+ "converged",
164
+ "max-rounds-reached",
165
+ "aborted-non-result",
166
+ }:
167
+ return "invalid", value
168
+ return "terminal", value
169
+
170
+
171
+ def _inspect_work(path: Path) -> tuple[str, dict[str, Any] | None]:
172
+ if not path.exists():
173
+ return "absent", None
174
+ try:
175
+ value = load_json_object(path)
176
+ except (OSError, ValueError):
177
+ return "invalid", None
178
+ return ("valid", value) if not validate_working_state(value) else ("invalid", value)
179
+
180
+
181
+ def _archive_identity(source_path: Path, migration_dir: Path) -> tuple[Path, str]:
182
+ data = source_path.read_bytes()
183
+ digest = hashlib.sha256(data).hexdigest()
184
+ return migration_dir / f"{source_path.name}.{digest[:12]}.json", digest
@@ -0,0 +1,85 @@
1
+ """Validated JSON loading and atomic persistence for convergence artifacts."""
2
+ from __future__ import annotations
3
+
4
+ import json
5
+ import hashlib
6
+ import os
7
+ from pathlib import Path
8
+ import tempfile
9
+ from typing import Any, Mapping
10
+
11
+ from .convergence_engine import ConvergenceContractError
12
+
13
+
14
+ def load_json_object(path: Path) -> dict[str, Any]:
15
+ value = json.loads(path.read_text(encoding="utf-8"))
16
+ if not isinstance(value, dict):
17
+ raise ConvergenceContractError(f"JSON input must be an object: {path}")
18
+ return value
19
+
20
+
21
+ def write_json_atomic(path: Path, payload: Mapping[str, Any]) -> None:
22
+ if not isinstance(payload, Mapping):
23
+ raise ConvergenceContractError("JSON output payload must be an object")
24
+ path.parent.mkdir(parents=True, exist_ok=True)
25
+ temp_path: Path | None = None
26
+ try:
27
+ with tempfile.NamedTemporaryFile(
28
+ mode="w",
29
+ encoding="utf-8",
30
+ dir=path.parent,
31
+ prefix=f".{path.name}.",
32
+ suffix=".tmp",
33
+ delete=False,
34
+ ) as handle:
35
+ temp_path = Path(handle.name)
36
+ json.dump(
37
+ payload,
38
+ handle,
39
+ ensure_ascii=False,
40
+ sort_keys=True,
41
+ indent=2,
42
+ )
43
+ handle.write("\n")
44
+ handle.flush()
45
+ os.fsync(handle.fileno())
46
+ os.replace(temp_path, path)
47
+ temp_path = None
48
+ finally:
49
+ if temp_path is not None:
50
+ temp_path.unlink(missing_ok=True)
51
+
52
+
53
+ def write_final_state_atomic(
54
+ path: Path,
55
+ payload: Mapping[str, Any],
56
+ *,
57
+ migration: Mapping[str, Any] | None,
58
+ ) -> None:
59
+ """Replace a legacy final only when its byte archive proves preservation."""
60
+ if path.exists():
61
+ _validate_final_replacement(path, migration)
62
+ write_json_atomic(path, payload)
63
+
64
+
65
+ def _validate_final_replacement(
66
+ path: Path,
67
+ migration: Mapping[str, Any] | None,
68
+ ) -> None:
69
+ if not isinstance(migration, Mapping) or migration.get("mode") != "restart-round0":
70
+ raise ConvergenceContractError(
71
+ "existing final state requires a restart-round0 migration archive"
72
+ )
73
+ if migration.get("sourcePath") != str(path):
74
+ raise ConvergenceContractError("migration archive sourcePath does not match final state")
75
+ archive_value = migration.get("archivePath")
76
+ digest = migration.get("legacyDigest")
77
+ if not isinstance(archive_value, str) or not isinstance(digest, str):
78
+ raise ConvergenceContractError("migration archive metadata is incomplete")
79
+ archive_path = Path(archive_value)
80
+ if not archive_path.is_file():
81
+ raise ConvergenceContractError(f"migration archive is missing: {archive_path}")
82
+ archived = archive_path.read_bytes()
83
+ current = path.read_bytes()
84
+ if hashlib.sha256(archived).hexdigest() != digest or archived != current:
85
+ raise ConvergenceContractError("migration archive bytes do not match legacy final")
@@ -18,6 +18,9 @@ from .final_report_paths import (
18
18
  from .lead_events import LeadEvent, append_lead_event
19
19
  from .path_hints import hydrate_active_run_context
20
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
21
24
  from .wrapper_status import read_wrapper_status, status_path_for_prompt
22
25
 
23
26
 
@@ -183,6 +186,7 @@ def build_dispatch_plan(
183
186
  report_writer_model,
184
187
  options,
185
188
  )
189
+ _validate_initial_prompts(manifest, jobs)
186
190
  return DispatchPlan(
187
191
  project_root=project_root,
188
192
  workspace_root=workspace_root.resolve(),
@@ -195,6 +199,20 @@ def build_dispatch_plan(
195
199
  )
196
200
 
197
201
 
202
+ def _validate_initial_prompts(
203
+ manifest: Mapping[str, Any],
204
+ jobs: Sequence[WorkerJob],
205
+ ) -> None:
206
+ records = [
207
+ PromptRecord(job.worker_id, job.dispatch_kind, job.prompt_path)
208
+ for job in jobs
209
+ ]
210
+ errors = validate_initial_prompt_records(manifest=manifest, records=records)
211
+ if errors:
212
+ task_type = _require_string(manifest, "taskType")
213
+ raise DispatchError(f"{task_type} prompt contract: " + "; ".join(errors))
214
+
215
+
198
216
  def dispatch_plan(plan: DispatchPlan, *, wait: bool = True) -> int:
199
217
  if wait:
200
218
  if any(job.backend == BACKEND_TMUX_PANE for job in plan.jobs):
@@ -314,6 +332,9 @@ def _job_from_roster_worker(
314
332
  project_root=project_root,
315
333
  manifest=manifest,
316
334
  active_context=active_context,
335
+ dispatch_kind=options.dispatch_kind,
336
+ model=model,
337
+ role=_string_value(state.get("role")) or "worker",
317
338
  )
318
339
  return WorkerJob(
319
340
  worker_id=worker_id,
@@ -778,6 +799,9 @@ def _materialize_prompt_if_missing(
778
799
  project_root: Path,
779
800
  manifest: Mapping[str, Any],
780
801
  active_context: Mapping[str, Any],
802
+ dispatch_kind: str,
803
+ model: str,
804
+ role: str,
781
805
  ) -> None:
782
806
  if prompt_path.is_file():
783
807
  return
@@ -788,12 +812,47 @@ def _materialize_prompt_if_missing(
788
812
  prompt_rel=prompt_rel,
789
813
  result_rel=result_rel,
790
814
  worker_id=worker_id,
815
+ dispatch_kind=dispatch_kind,
791
816
  manifest=manifest,
792
817
  active_context=active_context,
793
818
  )
794
819
  except WorkerPromptHeaderError as exc:
795
820
  raise DispatchError(str(exc)) from exc
796
- prompt_path.write_text("\n".join([*headers, ""]), encoding="utf-8")
821
+ try:
822
+ plan = resolve_prompt_plan_for_manifest(
823
+ manifest=manifest,
824
+ worker_id=worker_id,
825
+ dispatch_kind=dispatch_kind,
826
+ )
827
+ except ValueError as exc:
828
+ raise DispatchError(str(exc)) from exc
829
+ lines = list(headers)
830
+ if plan.audience in {
831
+ "analysis",
832
+ "implementation-executor",
833
+ "implementation-verifier",
834
+ }:
835
+ if _string_value(manifest.get("taskType")) == "implementation":
836
+ worktree = _worktree_path(active_context)
837
+ if not worktree:
838
+ raise DispatchError(
839
+ "implementation prompt generation requires a worktree path"
840
+ )
841
+ lines.append(f"**Worktree:** {worktree}")
842
+ try:
843
+ lines.extend(
844
+ analysis_prompt_body(
845
+ manifest,
846
+ active_context,
847
+ worker_id,
848
+ model,
849
+ role,
850
+ plan,
851
+ )
852
+ )
853
+ except ValueError as exc:
854
+ raise DispatchError(str(exc)) from exc
855
+ prompt_path.write_text("\n".join([*lines, ""]), encoding="utf-8")
797
856
 
798
857
 
799
858
  def _provider_for_worker(worker_id: str) -> str:
@@ -0,0 +1,61 @@
1
+ """Assign improvement-discovery primary passes from the resolved roster."""
2
+ from __future__ import annotations
3
+
4
+ from collections.abc import Mapping
5
+
6
+
7
+ def assign_primary_lenses(
8
+ worker_ids: list[str],
9
+ priority_lenses: list[str],
10
+ ) -> dict[str, str]:
11
+ """Round-robin resolved worker instances over resolved priority lenses."""
12
+ if not worker_ids:
13
+ raise ValueError("worker IDs must not be empty")
14
+ if not priority_lenses:
15
+ raise ValueError("priority lenses must not be empty")
16
+ if len(set(worker_ids)) != len(worker_ids):
17
+ raise ValueError("worker IDs must be unique")
18
+ return {
19
+ worker_id: priority_lenses[index % len(priority_lenses)]
20
+ for index, worker_id in enumerate(worker_ids)
21
+ }
22
+
23
+
24
+ def validate_primary_lens_assignments(
25
+ assignments: Mapping[str, str],
26
+ worker_ids: list[str],
27
+ priority_lenses: list[str],
28
+ ) -> list[str]:
29
+ """Require every selected analyser once at its roster-order lens."""
30
+ errors: list[str] = []
31
+ if not worker_ids:
32
+ return ["resolved analyser worker IDs must not be empty"]
33
+ if not priority_lenses:
34
+ return ["resolved priority lenses must not be empty"]
35
+ try:
36
+ expected = assign_primary_lenses(worker_ids, priority_lenses)
37
+ except ValueError as exc:
38
+ return [str(exc)]
39
+ if list(assignments) != worker_ids:
40
+ errors.append(
41
+ "assignment order must match resolved analyser worker order: "
42
+ + ", ".join(worker_ids)
43
+ )
44
+ for worker_id in worker_ids:
45
+ if worker_id not in assignments:
46
+ errors.append(f"missing worker assignment: {worker_id}")
47
+ for worker_id, lens in assignments.items():
48
+ if worker_id not in expected:
49
+ errors.append(f"extra worker assignment: {worker_id}")
50
+ continue
51
+ if lens not in priority_lenses:
52
+ errors.append(
53
+ f"{worker_id}: primary lens {lens!r} is outside resolved priority lenses"
54
+ )
55
+ continue
56
+ if lens != expected[worker_id]:
57
+ errors.append(
58
+ f"{worker_id}: expected primary lens {expected[worker_id]!r}, "
59
+ f"got {lens!r}"
60
+ )
61
+ return errors
@@ -53,6 +53,34 @@ def _per_task(files: list[dict]) -> list[dict]:
53
53
  return sorted(acc.values(), key=lambda a: a["totalBytes"], reverse=True)
54
54
 
55
55
 
56
+ def _prompt_pair(log_path: Path, transcript_bytes: int) -> dict:
57
+ prompt_path = log_path.with_suffix(".md")
58
+ if not prompt_path.is_file():
59
+ return {
60
+ "transcriptPath": str(log_path),
61
+ "transcriptBytes": transcript_bytes,
62
+ "promptPath": "",
63
+ "promptBytes": 0,
64
+ "transcriptToPromptRatio": None,
65
+ }
66
+ try:
67
+ prompt_bytes = prompt_path.stat().st_size
68
+ except OSError:
69
+ prompt_bytes = 0
70
+ ratio = (
71
+ round(transcript_bytes / prompt_bytes, 2)
72
+ if prompt_bytes > 0
73
+ else None
74
+ )
75
+ return {
76
+ "transcriptPath": str(log_path),
77
+ "transcriptBytes": transcript_bytes,
78
+ "promptPath": str(prompt_path),
79
+ "promptBytes": prompt_bytes,
80
+ "transcriptToPromptRatio": ratio,
81
+ }
82
+
83
+
56
84
  def scan_logs(logs_root: Path, project_root: Path, top: int = 20) -> dict:
57
85
  project_id = _project_id(project_root)
58
86
  files: list[dict] = []
@@ -68,16 +96,27 @@ def scan_logs(logs_root: Path, project_root: Path, top: int = 20) -> dict:
68
96
  continue
69
97
  meta = _parse(parts, p.stem)
70
98
  tk = f"{project_id}:{meta['taskGroup']}:{meta['taskId']}" if meta["taskGroup"] else ""
71
- files.append({"path": str(p), "sizeBytes": size, "mtimeEpoch": mtime,
72
- "taskKey": tk, **meta})
99
+ files.append({
100
+ "path": str(p),
101
+ "sizeBytes": size,
102
+ "mtimeEpoch": mtime,
103
+ "taskKey": tk,
104
+ **meta,
105
+ **_prompt_pair(p, size),
106
+ })
73
107
  files.sort(key=lambda f: f["sizeBytes"], reverse=True)
74
108
  return {
75
109
  "logsRoot": str(logs_root),
76
110
  "topLargest": files[:top],
77
111
  "perTask": _per_task(files),
78
- "totals": {"fileCount": len(files),
79
- "totalBytes": sum(f["sizeBytes"] for f in files),
80
- "taskCount": len({f["taskKey"] for f in files if f["taskKey"]})},
112
+ "totals": {
113
+ "fileCount": len(files),
114
+ "totalBytes": sum(f["sizeBytes"] for f in files),
115
+ "taskCount": len({f["taskKey"] for f in files if f["taskKey"]}),
116
+ "promptBytes": sum(f["promptBytes"] for f in files),
117
+ "transcriptBytes": sum(f["transcriptBytes"] for f in files),
118
+ "pairedFileCount": sum(1 for f in files if f["promptPath"]),
119
+ },
81
120
  }
82
121
 
83
122
 
@@ -59,6 +59,7 @@ def compact_active_run_context(
59
59
  "inputs": _compact_active_inputs(payload),
60
60
  "workers": _compact_active_workers(payload),
61
61
  "executorWorktree": dict(_mapping(payload.get("executorWorktree"))),
62
+ "verificationTarget": dict(_mapping(payload.get("verificationTarget"))),
62
63
  "lazyReadPlan": dict(_mapping(payload.get("lazyReadPlan"))),
63
64
  }
64
65
 
@@ -97,8 +98,20 @@ def hydrate_active_run_context(payload: Mapping[str, Any]) -> dict[str, Any]:
97
98
  "errorLogs": _hydrate_active_error_logs(ctx),
98
99
  "runtimeResources": {
99
100
  "codingPreflightDir": ctx.get("OKSTRA_CODING_PREFLIGHT_DIR", ""),
101
+ "workerPreamblePathByAudience": {
102
+ "analysis": ctx.get("ANALYSIS_WORKER_PREAMBLE_PATH", ""),
103
+ "implementation-executor": ctx.get(
104
+ "IMPLEMENTATION_WORKER_PREAMBLE_PATH", ""
105
+ ),
106
+ "implementation-verifier": ctx.get(
107
+ "IMPLEMENTATION_WORKER_PREAMBLE_PATH", ""
108
+ ),
109
+ "report-writer": ctx.get("REPORT_WRITER_PREAMBLE_PATH", ""),
110
+ },
111
+ "workerErrorContractPath": ctx.get("WORKER_ERROR_CONTRACT_PATH", ""),
100
112
  },
101
113
  "executorWorktree": dict(_mapping(payload.get("executorWorktree"))),
114
+ "verificationTarget": dict(_mapping(payload.get("verificationTarget"))),
102
115
  "sourceArtifacts": _hydrate_active_source_artifacts(ctx),
103
116
  "lazyReadPlan": dict(_mapping(payload.get("lazyReadPlan"))),
104
117
  }
@@ -190,6 +203,7 @@ def _hydrate_active_instruction_set(
190
203
  clarification_response = ""
191
204
  if inputs.get("hasClarificationResponse"):
192
205
  clarification_response = f"{instruction_set}/clarification-response.md"
206
+ target = _mapping(payload.get("verificationTarget"))
193
207
  return {
194
208
  "path": instruction_set,
195
209
  "analysisPacketPath": ctx.get("ANALYSIS_PACKET_RELATIVE_PATH", ""),
@@ -200,6 +214,8 @@ def _hydrate_active_instruction_set(
200
214
  "clarificationResponsePath": clarification_response,
201
215
  "finalReportTemplatePath": ctx.get("FINAL_REPORT_TEMPLATE_RELATIVE_PATH", ""),
202
216
  "finalReportSchemaPath": ctx.get("FINAL_REPORT_SCHEMA_RELATIVE_PATH", ""),
217
+ "verificationTargetPath": str(target.get("path") or ""),
218
+ "verificationTargetDigest": str(target.get("digest") or ""),
203
219
  }
204
220
 
205
221
 
@@ -451,7 +467,18 @@ def _runtime_fields(paths: Mapping[str, Any]) -> dict[str, str]:
451
467
  runtime_home = Path(paths["runtime_home"])
452
468
  lead = runtime_home / "prompts" / "lead"
453
469
  return {
454
- "WORKER_PROMPT_PREAMBLE_PATH": str(runtime_home / "templates" / "worker-prompt-preamble.md"),
470
+ "ANALYSIS_WORKER_PREAMBLE_PATH": str(
471
+ runtime_home / "templates" / "worker-prompt-preamble.md"
472
+ ),
473
+ "IMPLEMENTATION_WORKER_PREAMBLE_PATH": str(
474
+ runtime_home / "templates" / "implementation-worker-preamble.md"
475
+ ),
476
+ "REPORT_WRITER_PREAMBLE_PATH": str(
477
+ runtime_home / "templates" / "report-writer-prompt-preamble.md"
478
+ ),
479
+ "WORKER_ERROR_CONTRACT_PATH": str(
480
+ runtime_home / "templates" / "worker-error-contract.md"
481
+ ),
455
482
  "OKSTRA_LEAD_CONTRACT_PATH": str(lead / "okstra-lead-contract.md"),
456
483
  "OKSTRA_CONTEXT_LOADER_PATH": str(lead / "context-loader.md"),
457
484
  "OKSTRA_TEAM_CONTRACT_PATH": str(lead / "team-contract.md"),
@@ -265,9 +265,18 @@ def compute_run_paths(
265
265
  "TASK_TYPE_SEGMENT": task_type_segment,
266
266
  "OKSTRA_ROOT": str(okstra_root),
267
267
  "OKSTRA_TASKS_ROOT": str(tasks_root),
268
- "WORKER_PROMPT_PREAMBLE_PATH": str(
268
+ "ANALYSIS_WORKER_PREAMBLE_PATH": str(
269
269
  runtime_home / "templates" / "worker-prompt-preamble.md"
270
270
  ),
271
+ "IMPLEMENTATION_WORKER_PREAMBLE_PATH": str(
272
+ runtime_home / "templates" / "implementation-worker-preamble.md"
273
+ ),
274
+ "REPORT_WRITER_PREAMBLE_PATH": str(
275
+ runtime_home / "templates" / "report-writer-prompt-preamble.md"
276
+ ),
277
+ "WORKER_ERROR_CONTRACT_PATH": str(
278
+ runtime_home / "templates" / "worker-error-contract.md"
279
+ ),
271
280
  "OKSTRA_LEAD_CONTRACT_PATH": str(
272
281
  runtime_home / "prompts" / "lead" / "okstra-lead-contract.md"
273
282
  ),