okstra 0.128.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 (47) hide show
  1. package/docs/architecture/storage-model.md +15 -2
  2. package/docs/architecture.md +19 -3
  3. package/docs/cli.md +8 -0
  4. package/docs/project-structure-overview.md +14 -5
  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 +42 -84
  15. package/runtime/prompts/lead/okstra-lead-contract.md +10 -8
  16. package/runtime/prompts/lead/report-writer.md +3 -2
  17. package/runtime/prompts/lead/team-contract.md +11 -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/implementation-planning.md +5 -0
  21. package/runtime/prompts/profiles/improvement-discovery.md +11 -1
  22. package/runtime/prompts/profiles/requirements-discovery.md +8 -1
  23. package/runtime/python/okstra_ctl/analysis_packet.py +7 -13
  24. package/runtime/python/okstra_ctl/codex_dispatch.py +42 -97
  25. package/runtime/python/okstra_ctl/context_cost.py +82 -7
  26. package/runtime/python/okstra_ctl/convergence.py +223 -0
  27. package/runtime/python/okstra_ctl/convergence_engine.py +1152 -0
  28. package/runtime/python/okstra_ctl/convergence_migration.py +184 -0
  29. package/runtime/python/okstra_ctl/convergence_store.py +85 -0
  30. package/runtime/python/okstra_ctl/dispatch_core.py +53 -14
  31. package/runtime/python/okstra_ctl/improvement_assignment.py +61 -0
  32. package/runtime/python/okstra_ctl/path_hints.py +23 -1
  33. package/runtime/python/okstra_ctl/paths.py +10 -1
  34. package/runtime/python/okstra_ctl/render.py +56 -11
  35. package/runtime/python/okstra_ctl/worker_prompt_body.py +107 -0
  36. package/runtime/python/okstra_ctl/worker_prompt_contract.py +102 -9
  37. package/runtime/python/okstra_ctl/worker_prompt_headers.py +79 -9
  38. package/runtime/python/okstra_ctl/worker_prompt_policy.py +158 -0
  39. package/runtime/templates/implementation-worker-preamble.md +51 -0
  40. package/runtime/templates/operating-standard.md +4 -4
  41. package/runtime/templates/report-writer-prompt-preamble.md +37 -0
  42. package/runtime/templates/worker-error-contract.md +46 -0
  43. package/runtime/templates/worker-prompt-preamble.md +28 -234
  44. package/runtime/validators/lib/fixtures.sh +27 -12
  45. package/runtime/validators/validate-run.py +201 -72
  46. package/src/cli-registry.mjs +7 -0
  47. package/src/commands/execute/convergence.mjs +34 -0
@@ -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,7 +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_contract import validate_final_verification_prompt_paths
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
22
24
  from .wrapper_status import read_wrapper_status, status_path_for_prompt
23
25
 
24
26
 
@@ -184,7 +186,7 @@ def build_dispatch_plan(
184
186
  report_writer_model,
185
187
  options,
186
188
  )
187
- _validate_initial_final_verification_prompts(manifest, jobs)
189
+ _validate_initial_prompts(manifest, jobs)
188
190
  return DispatchPlan(
189
191
  project_root=project_root,
190
192
  workspace_root=workspace_root.resolve(),
@@ -197,22 +199,18 @@ def build_dispatch_plan(
197
199
  )
198
200
 
199
201
 
200
- def _validate_initial_final_verification_prompts(
202
+ def _validate_initial_prompts(
201
203
  manifest: Mapping[str, Any],
202
204
  jobs: Sequence[WorkerJob],
203
205
  ) -> None:
204
- if manifest.get("taskType") != "final-verification":
205
- return
206
- prompt_paths = {
207
- job.worker_id: job.prompt_path
206
+ records = [
207
+ PromptRecord(job.worker_id, job.dispatch_kind, job.prompt_path)
208
208
  for job in jobs
209
- if job.worker_id != REPORT_WRITER_WORKER_ID
210
- and job.dispatch_kind == "initial"
211
- and "-reverify-r" not in job.prompt_path.name
212
- }
213
- errors = validate_final_verification_prompt_paths(prompt_paths)
209
+ ]
210
+ errors = validate_initial_prompt_records(manifest=manifest, records=records)
214
211
  if errors:
215
- raise DispatchError("final-verification prompt contract: " + "; ".join(errors))
212
+ task_type = _require_string(manifest, "taskType")
213
+ raise DispatchError(f"{task_type} prompt contract: " + "; ".join(errors))
216
214
 
217
215
 
218
216
  def dispatch_plan(plan: DispatchPlan, *, wait: bool = True) -> int:
@@ -334,6 +332,9 @@ def _job_from_roster_worker(
334
332
  project_root=project_root,
335
333
  manifest=manifest,
336
334
  active_context=active_context,
335
+ dispatch_kind=options.dispatch_kind,
336
+ model=model,
337
+ role=_string_value(state.get("role")) or "worker",
337
338
  )
338
339
  return WorkerJob(
339
340
  worker_id=worker_id,
@@ -798,6 +799,9 @@ def _materialize_prompt_if_missing(
798
799
  project_root: Path,
799
800
  manifest: Mapping[str, Any],
800
801
  active_context: Mapping[str, Any],
802
+ dispatch_kind: str,
803
+ model: str,
804
+ role: str,
801
805
  ) -> None:
802
806
  if prompt_path.is_file():
803
807
  return
@@ -808,12 +812,47 @@ def _materialize_prompt_if_missing(
808
812
  prompt_rel=prompt_rel,
809
813
  result_rel=result_rel,
810
814
  worker_id=worker_id,
815
+ dispatch_kind=dispatch_kind,
811
816
  manifest=manifest,
812
817
  active_context=active_context,
813
818
  )
814
819
  except WorkerPromptHeaderError as exc:
815
820
  raise DispatchError(str(exc)) from exc
816
- 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")
817
856
 
818
857
 
819
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
@@ -98,6 +98,17 @@ def hydrate_active_run_context(payload: Mapping[str, Any]) -> dict[str, Any]:
98
98
  "errorLogs": _hydrate_active_error_logs(ctx),
99
99
  "runtimeResources": {
100
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", ""),
101
112
  },
102
113
  "executorWorktree": dict(_mapping(payload.get("executorWorktree"))),
103
114
  "verificationTarget": dict(_mapping(payload.get("verificationTarget"))),
@@ -456,7 +467,18 @@ def _runtime_fields(paths: Mapping[str, Any]) -> dict[str, str]:
456
467
  runtime_home = Path(paths["runtime_home"])
457
468
  lead = runtime_home / "prompts" / "lead"
458
469
  return {
459
- "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
+ ),
460
482
  "OKSTRA_LEAD_CONTRACT_PATH": str(lead / "okstra-lead-contract.md"),
461
483
  "OKSTRA_CONTEXT_LOADER_PATH": str(lead / "context-loader.md"),
462
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
  ),
@@ -376,6 +376,22 @@ def _active_runtime_resources(ctx: dict) -> dict:
376
376
  return {
377
377
  "codingPreflightDir": ctx.get("OKSTRA_CODING_PREFLIGHT_DIR")
378
378
  or str(runtime_home / "prompts" / "coding-preflight"),
379
+ "workerPreamblePathByAudience": {
380
+ "analysis": ctx.get("ANALYSIS_WORKER_PREAMBLE_PATH")
381
+ or str(runtime_home / "templates" / "worker-prompt-preamble.md"),
382
+ "implementation-executor": ctx.get(
383
+ "IMPLEMENTATION_WORKER_PREAMBLE_PATH"
384
+ )
385
+ or str(runtime_home / "templates" / "implementation-worker-preamble.md"),
386
+ "implementation-verifier": ctx.get(
387
+ "IMPLEMENTATION_WORKER_PREAMBLE_PATH"
388
+ )
389
+ or str(runtime_home / "templates" / "implementation-worker-preamble.md"),
390
+ "report-writer": ctx.get("REPORT_WRITER_PREAMBLE_PATH")
391
+ or str(runtime_home / "templates" / "report-writer-prompt-preamble.md"),
392
+ },
393
+ "workerErrorContractPath": ctx.get("WORKER_ERROR_CONTRACT_PATH")
394
+ or str(runtime_home / "templates" / "worker-error-contract.md"),
379
395
  }
380
396
 
381
397
 
@@ -1783,6 +1799,19 @@ def inject_lead_prompt_computed_tokens(ctx: dict) -> None:
1783
1799
  coding_preflight_dir = ctx.get("OKSTRA_CODING_PREFLIGHT_DIR") or str(
1784
1800
  home_prompts / "coding-preflight"
1785
1801
  )
1802
+ runtime_templates = okstra_home() / "templates"
1803
+ analysis_preamble = ctx.get("ANALYSIS_WORKER_PREAMBLE_PATH") or str(
1804
+ runtime_templates / "worker-prompt-preamble.md"
1805
+ )
1806
+ implementation_preamble = ctx.get("IMPLEMENTATION_WORKER_PREAMBLE_PATH") or str(
1807
+ runtime_templates / "implementation-worker-preamble.md"
1808
+ )
1809
+ report_writer_preamble = ctx.get("REPORT_WRITER_PREAMBLE_PATH") or str(
1810
+ runtime_templates / "report-writer-prompt-preamble.md"
1811
+ )
1812
+ worker_error_contract = ctx.get("WORKER_ERROR_CONTRACT_PATH") or str(
1813
+ runtime_templates / "worker-error-contract.md"
1814
+ )
1786
1815
  okstra_runtime_resources_block = (
1787
1816
  "## Okstra Runtime Resources\n"
1788
1817
  "\n"
@@ -1795,7 +1824,11 @@ def inject_lead_prompt_computed_tokens(ctx: dict) -> None:
1795
1824
  f"- Convergence contract: `{convergence_path}`\n"
1796
1825
  f"- Plan-body verification contract (implementation-planning Phase 6 sub-step only): `{plan_body_verification_path}`\n"
1797
1826
  f"- Report writer contract: `{report_writer_path}`\n"
1798
- f"- Coding preflight pack: `{coding_preflight_dir}`"
1827
+ f"- Coding preflight pack: `{coding_preflight_dir}`\n"
1828
+ f"- Analysis worker preamble: `{analysis_preamble}`\n"
1829
+ f"- Implementation worker preamble: `{implementation_preamble}`\n"
1830
+ f"- Report writer preamble: `{report_writer_preamble}`\n"
1831
+ f"- Shared worker error contract: `{worker_error_contract}`"
1799
1832
  )
1800
1833
 
1801
1834
  def fmt_assignment(role: str, model: str, execution: str) -> str:
@@ -1915,10 +1948,6 @@ def inject_lead_prompt_computed_tokens(ctx: dict) -> None:
1915
1948
  "the selected adapter's mapping. Follow the lifecycle core's lazy-read rules "
1916
1949
  "for support resources; do not invoke hidden/internal skills."
1917
1950
  )
1918
- worker_preamble_path = ctx.get(
1919
- "WORKER_PROMPT_PREAMBLE_PATH",
1920
- str(okstra_home() / "templates" / "worker-prompt-preamble.md"),
1921
- )
1922
1951
  worker_dispatch_guidance = (
1923
1952
  "## Worker Dispatch Contract\n"
1924
1953
  "\n"
@@ -1932,10 +1961,14 @@ def inject_lead_prompt_computed_tokens(ctx: dict) -> None:
1932
1961
  "- Use `await_workers` through the selected adapter and confirm terminal state "
1933
1962
  "plus every required Result Path. File presence alone is never a signal to "
1934
1963
  "skip; worker selection and skipped status come from `team-state`.\n"
1935
- "- Every worker prompt must include the anchor header "
1936
- f"`**Worker Preamble Path:** {worker_preamble_path}`. That file is the canonical "
1937
- "source for Required Reading, Error Reporting, and Output sections; do not "
1938
- "re-inline those blocks into worker prompt bodies."
1964
+ "- Resolve the worker's PromptPlan audience and inject exactly one "
1965
+ "`**Worker Preamble Path:**` from the audience map: "
1966
+ f"analysis=`{analysis_preamble}`, implementation executor/verifier="
1967
+ f"`{implementation_preamble}`, report-writer=`{report_writer_preamble}`.\n"
1968
+ "- Every initial worker prompt also injects "
1969
+ f"`**Worker Error Contract Path:** {worker_error_contract}`. The selected "
1970
+ "preamble owns audience procedure; the shared error contract owns error "
1971
+ "schema and write rules. Do not re-inline either contract."
1939
1972
  )
1940
1973
 
1941
1974
  if lead_runtime == "external":
@@ -2006,12 +2039,24 @@ def apply_lead_prompt_defaults(ctx: dict) -> None:
2006
2039
  ctx.setdefault("STAGE_INTEGRATION", "")
2007
2040
  runtime_home = okstra_home()
2008
2041
  ctx.setdefault(
2009
- "WORKER_PROMPT_PREAMBLE_PATH",
2042
+ "ANALYSIS_WORKER_PREAMBLE_PATH",
2010
2043
  str(runtime_home / "templates" / "worker-prompt-preamble.md"),
2011
2044
  )
2045
+ ctx.setdefault(
2046
+ "IMPLEMENTATION_WORKER_PREAMBLE_PATH",
2047
+ str(runtime_home / "templates" / "implementation-worker-preamble.md"),
2048
+ )
2049
+ ctx.setdefault(
2050
+ "REPORT_WRITER_PREAMBLE_PATH",
2051
+ str(runtime_home / "templates" / "report-writer-prompt-preamble.md"),
2052
+ )
2053
+ ctx.setdefault(
2054
+ "WORKER_ERROR_CONTRACT_PATH",
2055
+ str(runtime_home / "templates" / "worker-error-contract.md"),
2056
+ )
2012
2057
  # Lead resource paths the launch template references directly. paths.py
2013
2058
  # seeds the production values; setdefault backfills callers that render the
2014
- # template without the full path ctx (mirrors WORKER_PROMPT_PREAMBLE_PATH).
2059
+ # template without the full path ctx.
2015
2060
  ctx.setdefault(
2016
2061
  "OKSTRA_LEAD_CONTRACT_PATH",
2017
2062
  str(runtime_home / "prompts" / "lead" / "okstra-lead-contract.md"),