okstra 0.169.1 → 0.170.1

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 (45) hide show
  1. package/docs/architecture.md +17 -1
  2. package/docs/cli.md +12 -2
  3. package/docs/for-ai/skills/okstra-setup.md +8 -0
  4. package/docs/project-structure-overview.md +3 -1
  5. package/package.json +1 -1
  6. package/runtime/BUILD.json +2 -2
  7. package/runtime/prompts/duties/acceptance-critic.md +25 -5
  8. package/runtime/prompts/duties/acceptance-verifier.md +25 -5
  9. package/runtime/prompts/duties/analysis-worker.md +25 -5
  10. package/runtime/prompts/duties/code-reviewer.md +25 -5
  11. package/runtime/prompts/duties/common.md +15 -11
  12. package/runtime/prompts/duties/diagnosis-worker.md +44 -0
  13. package/runtime/prompts/duties/discovery-worker.md +44 -0
  14. package/runtime/prompts/duties/implementation-executor.md +25 -5
  15. package/runtime/prompts/duties/implementation-verifier.md +25 -5
  16. package/runtime/prompts/duties/lead.md +25 -5
  17. package/runtime/prompts/duties/planning-worker.md +44 -0
  18. package/runtime/prompts/duties/report-writer.md +25 -5
  19. package/runtime/prompts/duties/reverification-worker.md +25 -5
  20. package/runtime/prompts/duties/schedule-verifier.md +25 -5
  21. package/runtime/prompts/duties/scope-critic.md +25 -5
  22. package/runtime/prompts/duties/translator.md +25 -5
  23. package/runtime/prompts/lead/plan-body-verification.md +7 -2
  24. package/runtime/prompts/lead/report-writer.md +1 -1
  25. package/runtime/prompts/profiles/_coding-conventions-preflight.md +1 -1
  26. package/runtime/prompts/profiles/_implementation-verifier.md +1 -1
  27. package/runtime/prompts/profiles/final-verification.md +1 -1
  28. package/runtime/prompts/profiles/implementation-planning.md +2 -2
  29. package/runtime/python/okstra_ctl/agent_invocation.py +92 -3
  30. package/runtime/python/okstra_ctl/agent_prompt_cli.py +30 -0
  31. package/runtime/python/okstra_ctl/cmux.py +36 -19
  32. package/runtime/python/okstra_ctl/dispatch_core.py +92 -22
  33. package/runtime/python/okstra_ctl/dispatch_state.py +143 -9
  34. package/runtime/python/okstra_ctl/doctor.py +31 -0
  35. package/runtime/python/okstra_ctl/plan_derivations.py +94 -0
  36. package/runtime/python/okstra_ctl/plan_items_cli.py +131 -4
  37. package/runtime/python/okstra_ctl/run.py +7 -1
  38. package/runtime/python/okstra_ctl/schema_excerpt.py +34 -0
  39. package/runtime/python/okstra_ctl/verdict_blocks.py +17 -0
  40. package/runtime/python/okstra_ctl/worker_prompt_policy.py +12 -1
  41. package/runtime/python/okstra_project/resolver.py +34 -0
  42. package/runtime/schemas/final-report-v2.0.schema.json +5 -0
  43. package/runtime/skills/okstra-setup/references/project-config.md +38 -0
  44. package/runtime/validators/lib/fixtures.sh +9 -1
  45. package/runtime/validators/validate-run.py +108 -2
@@ -18,6 +18,9 @@ from typing import Iterator, Literal, Mapping, get_args
18
18
  AgentAudience = Literal[
19
19
  "lead",
20
20
  "analysis-worker",
21
+ "discovery-worker",
22
+ "diagnosis-worker",
23
+ "planning-worker",
21
24
  "implementation-executor",
22
25
  "implementation-verifier",
23
26
  "acceptance-verifier",
@@ -31,7 +34,36 @@ AgentAudience = Literal[
31
34
  ]
32
35
 
33
36
  _SUPPORTED_AUDIENCES = frozenset(get_args(AgentAudience))
37
+ # The section set IS the duty contract's shape: a duty author adding a role file
38
+ # reads these names, and `_validate_duty_sections` refuses a file that misses one.
39
+ # The check is structural — it proves every required section exists and carries
40
+ # text, NOT that the text is a real contract. A one-line placeholder passes it;
41
+ # what keeps a section substantive is review, and the rule that earns a section a
42
+ # place here at all: a sentence that reads the same in another duty file belongs
43
+ # in `common.md` or nowhere.
44
+ COMMON_DUTY_SECTIONS = (
45
+ "Assignment fidelity",
46
+ "Required inputs",
47
+ "Evidence first",
48
+ "Authority and scope",
49
+ "Collaboration and independence",
50
+ "Instruction precedence",
51
+ "Conflict handling",
52
+ "Completion honesty",
53
+ )
54
+ ROLE_DUTY_SECTIONS = (
55
+ "Responsibility",
56
+ "Required conduct",
57
+ "Decision principles",
58
+ "Authority and boundaries",
59
+ "Evidence standard",
60
+ "Collaboration contract",
61
+ "Completion criteria",
62
+ "Forbidden conduct",
63
+ "Blocked-state reporting",
64
+ )
34
65
  _SLUG_RE = re.compile(r"^[a-z0-9]+(?:-[a-z0-9]+)*$")
66
+ _DUTY_SECTION_RE = re.compile(r"(?m)^## ([^\n]+)\s*$")
35
67
  _TOP_LEVEL_KEYS = {
36
68
  "schemaVersion",
37
69
  "invocationId",
@@ -213,6 +245,7 @@ def load_common_duty_contract(duty_root: Path) -> DutyContract:
213
245
  raise AgentInvocationError(f"invalid common duty frontmatter: {path}")
214
246
  if fields["id"] != "common" or fields["kind"] != "common":
215
247
  raise AgentInvocationError(f"invalid common duty frontmatter: {path}")
248
+ _validate_duty_sections(body, COMMON_DUTY_SECTIONS, "common", path)
216
249
  return DutyContract(
217
250
  id="common",
218
251
  version=_parse_version(fields["version"], path),
@@ -902,7 +935,7 @@ def _publish_or_reuse(
902
935
  if metadata_exists and not prompt_exists:
903
936
  raise _existing_conflict("metadata exists without prompt")
904
937
  if prompt_exists and prompt.read_bytes() != materialized.prompt_bytes:
905
- _replace_undispatched(request, materialized)
938
+ _replace_undispatched(request, materialized, _prompt_difference(prompt, materialized))
906
939
  return
907
940
  if prompt_exists and metadata_exists:
908
941
  if metadata.read_bytes() != materialized.metadata_bytes:
@@ -913,8 +946,37 @@ def _publish_or_reuse(
913
946
  _publish_exclusive(metadata, materialized.metadata_bytes)
914
947
 
915
948
 
949
+ def _prompt_difference(prompt: Path, materialized: _MaterializedInvocation) -> str:
950
+ """What actually differs between the published prompt and this one.
951
+
952
+ "existing prompt differs" alone cannot be acted on: it does not say whether
953
+ the file on disk is a stale sibling, a partially written artifact, or the
954
+ same prompt built from an edited instruction file — and the evidence is
955
+ destroyed by the very republish that unblocks the caller. One real
956
+ investigation ended undecided for exactly this reason. Digests identify the
957
+ two bodies, and the first differing line points at the edit.
958
+ """
959
+ try:
960
+ existing = prompt.read_bytes()
961
+ except OSError as exc:
962
+ return f"existing prompt is unreadable: {exc}"
963
+ composed = materialized.prompt_bytes
964
+ detail = (
965
+ f"existing {_sha256(existing)}, composed {_sha256(composed)}"
966
+ )
967
+ existing_lines = existing.decode("utf-8", errors="replace").splitlines()
968
+ composed_lines = composed.decode("utf-8", errors="replace").splitlines()
969
+ for index, (left, right) in enumerate(zip(existing_lines, composed_lines), start=1):
970
+ if left != right:
971
+ return f"{detail}, first differing line {index}"
972
+ shorter = min(len(existing_lines), len(composed_lines))
973
+ return f"{detail}, identical through line {shorter} then one body continues"
974
+
975
+
916
976
  def _replace_undispatched(
917
- request: AgentInvocationRequest, materialized: _MaterializedInvocation
977
+ request: AgentInvocationRequest,
978
+ materialized: _MaterializedInvocation,
979
+ difference: str,
918
980
  ) -> None:
919
981
  """Rewrite a prompt this invocation id wrote but no dispatch ever used.
920
982
 
@@ -930,7 +992,7 @@ def _replace_undispatched(
930
992
  agent — puts the prompt back under the original rule.
931
993
  """
932
994
  if not request.replace_undispatched:
933
- raise _existing_conflict("existing prompt differs")
995
+ raise _existing_conflict(f"existing prompt differs ({difference})")
934
996
  dispatched = _recorded_dispatch_ids(request)
935
997
  if dispatched:
936
998
  raise _existing_conflict(
@@ -1541,6 +1603,7 @@ def _load_role_duty(path: Path) -> DutyContract:
1541
1603
  raise AgentInvocationError(f"unknown duty audience: {audience}")
1542
1604
  if fields["kind"] != "role" or fields["id"] != audience:
1543
1605
  raise AgentInvocationError(f"invalid role duty frontmatter: {path}")
1606
+ _validate_duty_sections(body, ROLE_DUTY_SECTIONS, "role", path)
1544
1607
  return DutyContract(
1545
1608
  id=fields["id"],
1546
1609
  version=_parse_version(fields["version"], path),
@@ -1572,6 +1635,32 @@ def _parse_duty_file(path: Path) -> tuple[dict[str, str], str]:
1572
1635
  return fields, "".join(lines[end + 1 :]).lstrip("\n")
1573
1636
 
1574
1637
 
1638
+ def _validate_duty_sections(
1639
+ body: str,
1640
+ required: tuple[str, ...],
1641
+ kind: str,
1642
+ path: Path,
1643
+ ) -> None:
1644
+ matches = list(_DUTY_SECTION_RE.finditer(body))
1645
+ sections: dict[str, str] = {}
1646
+ for index, match in enumerate(matches):
1647
+ name = match.group(1).strip()
1648
+ if name in sections:
1649
+ raise AgentInvocationError(f"duplicate {kind} duty section {name}: {path}")
1650
+ end = matches[index + 1].start() if index + 1 < len(matches) else len(body)
1651
+ sections[name] = body[match.end() : end].strip()
1652
+ missing = [name for name in required if name not in sections]
1653
+ if missing:
1654
+ raise AgentInvocationError(
1655
+ f"missing {kind} duty sections: {', '.join(missing)}: {path}"
1656
+ )
1657
+ empty = [name for name in required if not sections[name]]
1658
+ if empty:
1659
+ raise AgentInvocationError(
1660
+ f"empty {kind} duty sections: {', '.join(empty)}: {path}"
1661
+ )
1662
+
1663
+
1575
1664
  def _parse_version(value: str, path: Path) -> int:
1576
1665
  try:
1577
1666
  version = int(value)
@@ -41,6 +41,7 @@ from .dispatch_state import (
41
41
  DispatchError,
42
42
  link_agent_dispatch_result,
43
43
  record_verified_agent_dispatch,
44
+ reject_agent_dispatch_result,
44
45
  )
45
46
 
46
47
 
@@ -110,6 +111,18 @@ def _parser() -> argparse.ArgumentParser:
110
111
  )
111
112
  record_dispatch.add_argument("--json", action="store_true")
112
113
 
114
+ reject_result = commands.add_parser(
115
+ "reject-result",
116
+ help="mark a linked result rejected so a corrective re-dispatch can "
117
+ "claim its path",
118
+ )
119
+ _common_paths(reject_result)
120
+ reject_result.add_argument("--run-manifest", required=True)
121
+ reject_result.add_argument("--dispatch-id", required=True)
122
+ reject_result.add_argument("--superseded-by", required=True)
123
+ reject_result.add_argument("--reason", required=True)
124
+ reject_result.add_argument("--json", action="store_true")
125
+
113
126
  link_result = commands.add_parser("link-result")
114
127
  _common_paths(link_result)
115
128
  link_result.add_argument("--run-manifest", required=True)
@@ -156,6 +169,9 @@ def main(argv: list[str] | None = None) -> int:
156
169
  if args.command == "record-dispatch":
157
170
  _record_dispatch(args)
158
171
  return 0
172
+ if args.command == "reject-result":
173
+ _reject_result(args)
174
+ return 0
159
175
  _link_result(args)
160
176
  return 0
161
177
  except (
@@ -192,6 +208,20 @@ def _record_dispatch(args: argparse.Namespace) -> None:
192
208
  _emit(record, args.json)
193
209
 
194
210
 
211
+ def _reject_result(args: argparse.Namespace) -> None:
212
+ project_root = _project_root(args.project_root)
213
+ row = reject_agent_dispatch_result(
214
+ project_root=project_root,
215
+ run_manifest_path=_project_input(
216
+ project_root, args.run_manifest, "run manifest"
217
+ ),
218
+ dispatch_id=args.dispatch_id,
219
+ superseded_by=args.superseded_by,
220
+ reason=args.reason,
221
+ )
222
+ _emit(row, args.json)
223
+
224
+
195
225
  def _link_result(args: argparse.Namespace) -> None:
196
226
  project_root = _project_root(args.project_root)
197
227
  link = link_agent_dispatch_result(
@@ -199,17 +199,23 @@ def plan_worker_placement(
199
199
  return _extend_the_shortest_column(columns)
200
200
 
201
201
 
202
- def lead_shrink_points(lead: PaneGeometry, *, target_columns: int) -> int:
203
- """How far to push the lead's right border, in the points `pane.resize` takes.
202
+ def lead_resize_points(lead: PaneGeometry, *, target_columns: int) -> int:
203
+ """How far to move the lead's right border, in the points `pane.resize` takes.
204
+
205
+ Signed: positive when the lead is too wide and the border comes in, negative
206
+ when it is too narrow and the border goes out.
207
+
208
+ Both directions are needed. A split halves whatever pane it lands on, and
209
+ the first worker of every round lands on the lead — so a rule that only ever
210
+ shrinks leaves that half permanent, and the round after it takes half of
211
+ what is left. Measured on this display: 215 columns becomes 80, then 40,
212
+ then 20, until neither the lead nor its workers can be read.
204
213
 
205
214
  The API's `amount` is points, not cells — measured at this pane's own
206
- `cell_width_points`, so passing a column count shrinks by an eighth of the
207
- intent on a typical display.
215
+ `cell_width_points`, so passing a column count moves the border by an eighth
216
+ of the intent on a typical display.
208
217
  """
209
- surplus = lead.columns - target_columns
210
- if surplus <= 0:
211
- return 0
212
- return surplus * lead.cell_width_points
218
+ return (lead.columns - target_columns) * lead.cell_width_points
213
219
 
214
220
 
215
221
  def _holds_an_okstra_surface(
@@ -326,7 +332,7 @@ def spawn_worker_surface(
326
332
  surface_uuid = _open_worker_surface(workspace, placement, target)
327
333
  run_cmux(["rename-tab", "--surface", surface_uuid, "--title", title])
328
334
  _exec_worker(surface_uuid, cwd=cwd, command=command)
329
- _shrink_lead_pane(workspace)
335
+ _size_lead_pane(workspace)
330
336
  return surface_uuid
331
337
 
332
338
 
@@ -530,28 +536,39 @@ def _exec_worker(surface_uuid: str, *, cwd: Path, command: Sequence[str]) -> Non
530
536
  raise RuntimeError(started.stderr.strip() or "cmux could not start the worker")
531
537
 
532
538
 
533
- def _shrink_lead_pane(workspace: str) -> None:
534
- """Give the lead's width to the workers by pushing its right border left.
539
+ def _size_lead_pane(workspace: str) -> None:
540
+ """Put the lead back on its target width, leaving the rest to the workers.
541
+
542
+ Which pane carries the request follows from what `pane.resize` does: it
543
+ moves the named pane's own border in the direction given. The lead can push
544
+ its right border out — that is `right` on the lead itself — but it cannot
545
+ pull that border in, because `left` on the leftmost pane finds no adjacent
546
+ border to move. Narrowing is therefore the right-hand neighbour's request,
547
+ and widening is the lead's.
535
548
 
536
- The lead cannot shrink itself: `pane.resize` moves the named pane's border,
537
- so asking the leftmost pane to move `left` fails with no adjacent border and
538
- `right` widens it. The neighbour on its right carries the request instead.
549
+ Run after every worker opens rather than once per round: the split that just
550
+ happened is what knocked the lead off its width, and no other event does.
539
551
  """
540
552
  panes = list_panes(workspace)
541
553
  lead = _lead_pane(panes)
542
- amount = lead_shrink_points(lead, target_columns=LEAD_TARGET_COLUMNS)
543
- if amount <= 0:
554
+ offset = lead_resize_points(lead, target_columns=LEAD_TARGET_COLUMNS)
555
+ if offset == 0:
544
556
  return
545
557
  neighbours = [pane for pane in panes if pane.x > lead.x]
546
558
  if not neighbours:
547
559
  return
560
+ narrowing = offset > 0
548
561
  rpc(
549
562
  "pane.resize",
550
563
  {
551
564
  "workspace_id": workspace,
552
- "pane_id": min(neighbours, key=lambda pane: pane.x).pane_id,
553
- "direction": "left",
554
- "amount": amount,
565
+ "pane_id": (
566
+ min(neighbours, key=lambda pane: pane.x).pane_id
567
+ if narrowing
568
+ else lead.pane_id
569
+ ),
570
+ "direction": "left" if narrowing else "right",
571
+ "amount": abs(offset),
555
572
  },
556
573
  )
557
574
 
@@ -19,7 +19,9 @@ from .dispatch_state import (
19
19
  append_worker_dispatch as _append_worker_dispatch,
20
20
  DispatchError,
21
21
  WorkerJob,
22
+ dispatch_completion_paths as _completion_paths,
22
23
  dispatch_mode as _dispatch_mode,
24
+ dispatch_result_path as _result_path_for_worker,
23
25
  LIVENESS_AUDIT_HEARTBEAT,
24
26
  LIVENESS_WRAPPER_STATUS,
25
27
  load_json_object as _load_json_object,
@@ -41,10 +43,6 @@ from .dispatch_state import (
41
43
  worker_state as _worker_state,
42
44
  worktree_path as _worktree_path,
43
45
  )
44
- from .final_report_paths import (
45
- final_report_data_path as _final_report_data_path,
46
- final_report_markdown_path as _final_report_markdown_path,
47
- )
48
46
  from .error_log_write import append_observed
49
47
  from .lead_events import LeadEvent, append_lead_event
50
48
  from .initial_prompt_materialization import (
@@ -55,6 +53,8 @@ from .initial_prompt_materialization import (
55
53
  materialize_initial_prompts,
56
54
  )
57
55
  from .path_hints import hydrate_active_run_context
56
+ from .schema_excerpt import bundle_excerpt_path, excerpt_version_skew
57
+ from .seeding import installed_version
58
58
  from .report_finalize import (
59
59
  STEP_VALIDATE_RUN,
60
60
  FinalizeContext,
@@ -191,7 +191,7 @@ def build_dispatch_plan(
191
191
  default_provider_by_worker_id=dict(default_provider_by_worker_id or {}),
192
192
  )
193
193
  if jobs_file:
194
- jobs = _jobs_from_file(project_root, workspace_root, jobs_file, options)
194
+ jobs = _jobs_from_file(project_root, workspace_root, jobs_file, manifest, options)
195
195
  else:
196
196
  jobs = _jobs_from_roster(
197
197
  project_root,
@@ -204,6 +204,7 @@ def build_dispatch_plan(
204
204
  options,
205
205
  )
206
206
  _validate_dispatch_prompts(manifest, active_context, jobs)
207
+ _reject_stale_schema_excerpt(project_root, manifest, jobs)
207
208
  return DispatchPlan(
208
209
  project_root=project_root,
209
210
  workspace_root=workspace_root.resolve(),
@@ -705,6 +706,7 @@ def _jobs_from_file(
705
706
  project_root: Path,
706
707
  workspace_root: Path,
707
708
  jobs_file: Path | None,
709
+ manifest: Mapping[str, Any],
708
710
  options: _BuildOptions,
709
711
  ) -> list[WorkerJob]:
710
712
  if jobs_file is None:
@@ -712,6 +714,7 @@ def _jobs_from_file(
712
714
  return _worker_jobs_from_file(
713
715
  project_root,
714
716
  jobs_file,
717
+ manifest=manifest,
715
718
  backend=options.default_backend,
716
719
  idle_timeout_seconds=options.idle_timeout_seconds,
717
720
  default_dispatch_kind=options.dispatch_kind,
@@ -958,13 +961,7 @@ def _retry_from_record(
958
961
  def _finish_attempt(plan: DispatchPlan, job: WorkerJob, attempt: int, outcome: WorkerOutcome) -> None:
959
962
  settlement = _settle(plan, job, attempt, outcome)
960
963
  if settlement.completed:
961
- if job.invocation_id:
962
- _link_agent_dispatch_result(
963
- project_root=plan.project_root,
964
- run_manifest_path=plan.manifest_path,
965
- dispatch_id=f"{job.invocation_id}:attempt-{attempt}",
966
- result_path=job.worker_result_path,
967
- )
964
+ result_link = _link_result(plan, job, attempt)
968
965
  post_process = _post_process_report_writer_result(plan, job)
969
966
  if not post_process["ok"]:
970
967
  reason = _require_string(post_process, "reason")
@@ -990,10 +987,15 @@ def _finish_attempt(plan: DispatchPlan, job: WorkerJob, attempt: int, outcome: W
990
987
  plan.team_state_path, job.worker_id, "completed", ""
991
988
  )
992
989
  _update_dispatch_status(
993
- plan.team_state_path, job, attempt, "completed", settlement.note
990
+ plan.team_state_path,
991
+ job,
992
+ attempt,
993
+ "completed",
994
+ "; ".join(part for part in (settlement.note, result_link["reason"]) if part),
994
995
  )
995
996
  details = _result_details(job, attempt, outcome)
996
997
  details["postProcessing"] = post_process["steps"]
998
+ details["resultLink"] = result_link
997
999
  if settlement.error_log_append is not None:
998
1000
  details["errorLogAppend"] = settlement.error_log_append
999
1001
  _append_event(plan, "worker-result-collected", details)
@@ -1008,6 +1010,43 @@ def _finish_attempt(plan: DispatchPlan, job: WorkerJob, attempt: int, outcome: W
1008
1010
  _append_event(plan, "worker-failed", details)
1009
1011
 
1010
1012
 
1013
+ def _link_result(
1014
+ plan: DispatchPlan, job: WorkerJob, attempt: int
1015
+ ) -> dict[str, Any]:
1016
+ """Bind this result to its verified dispatch, reporting rather than raising.
1017
+
1018
+ Linking is bookkeeping around a dispatch that has already settled, and it
1019
+ used to run before any status was written. A refused link — the live case is
1020
+ a corrective re-dispatch claiming a path the first attempt still owns — threw
1021
+ out of `_finish_attempt`, so nothing transitioned, the row stayed `running`,
1022
+ and the exception reached the caller as exit 2. The next `await` re-read the
1023
+ same terminal sidecar, re-settled the same way, and threw at the same line:
1024
+ a worker with complete artifacts wedged the run permanently, and no amount of
1025
+ waiting could clear it.
1026
+
1027
+ So the settle is written either way and the refusal travels back as data. It
1028
+ is not swallowed: the reason lands in the dispatch row and in the lead event,
1029
+ and the post-hoc validators still require every accepted result to carry a
1030
+ link, so an unlinked result fails where an audit failure belongs rather than
1031
+ by stopping the run mid-phase. `agent-prompt reject-result` is the remedy the
1032
+ refusal names.
1033
+ """
1034
+ link: dict[str, Any] = {"ok": True, "reason": ""}
1035
+ if not job.invocation_id:
1036
+ return link
1037
+ try:
1038
+ _link_agent_dispatch_result(
1039
+ project_root=plan.project_root,
1040
+ run_manifest_path=plan.manifest_path,
1041
+ dispatch_id=f"{job.invocation_id}:attempt-{attempt}",
1042
+ result_path=job.worker_result_path,
1043
+ )
1044
+ except (DispatchError, OSError) as exc:
1045
+ link["ok"] = False
1046
+ link["reason"] = f"result link refused: {exc}"
1047
+ return link
1048
+
1049
+
1011
1050
  def _finish_record(plan: DispatchPlan, record: Mapping[str, Any], outcome: WorkerOutcome) -> None:
1012
1051
  job = _job_from_record(plan.project_root, record)
1013
1052
  _finish_attempt(plan, job, int(record.get("attempt", 1)), outcome)
@@ -1626,17 +1665,48 @@ def _provider_for_worker(
1626
1665
  return worker_id
1627
1666
 
1628
1667
 
1629
- def _result_path_for_worker(worker_id: str, result_path: Path, manifest: Mapping[str, Any], project_root: Path) -> Path:
1630
- if worker_id != REPORT_WRITER_WORKER_ID:
1631
- return result_path
1632
- return _final_report_data_path(_resolve_required_path(project_root, manifest, "expectedReportPath"))
1633
1668
 
1634
1669
 
1635
- def _completion_paths(worker_id: str, result_path: Path, manifest: Mapping[str, Any], project_root: Path) -> tuple[Path, ...]:
1636
- if worker_id != REPORT_WRITER_WORKER_ID:
1637
- return (result_path,)
1638
- data_json = _final_report_data_path(_resolve_required_path(project_root, manifest, "expectedReportPath"))
1639
- return (data_json, _final_report_markdown_path(data_json), result_path)
1670
+ def _reject_stale_schema_excerpt(
1671
+ project_root: Path,
1672
+ manifest: Mapping[str, Any],
1673
+ jobs: Sequence[WorkerJob],
1674
+ ) -> None:
1675
+ """Refuse to send the report writer at a schema excerpt from another runtime.
1676
+
1677
+ The bundle's `instruction-set/final-report-schema.json` is cut at prep time
1678
+ and never moves again, while validation always runs against the installed
1679
+ schema. A run long enough to straddle a runtime upgrade therefore has the
1680
+ author writing to one contract and the validator reading another — and the
1681
+ only thing that noticed was the renderer, in Phase 6, after the worker had
1682
+ authored the whole report. The two versions are comparable the moment the
1683
+ dispatch is built, and the remedy is the same either way, so it belongs here.
1684
+
1685
+ Only the report writer is stopped: it is the only worker that authors against
1686
+ the excerpt. Re-running bundle prep re-cuts it from the installed schema.
1687
+ """
1688
+ writer = next(
1689
+ (job for job in jobs if job.worker_id == REPORT_WRITER_WORKER_ID), None
1690
+ )
1691
+ if writer is None:
1692
+ return
1693
+ expected = _string_value(manifest.get("expectedReportPath"))
1694
+ if not expected:
1695
+ return
1696
+ excerpt_path = bundle_excerpt_path(_resolve_project_path(project_root, expected))
1697
+ if excerpt_path is None:
1698
+ return
1699
+ installed = installed_version()
1700
+ cut_from = excerpt_version_skew(excerpt_path, installed)
1701
+ if not cut_from:
1702
+ return
1703
+ raise DispatchError(
1704
+ f"the bundle's schema excerpt ({excerpt_path}) was cut from okstra "
1705
+ f"{cut_from} but this runtime is {installed}. The report writer authors "
1706
+ f"against that excerpt and validation runs against the installed schema, "
1707
+ f"so dispatching now spends a full authoring pass on the wrong contract. "
1708
+ f"Re-prepare the task bundle to re-cut the excerpt, then dispatch again."
1709
+ )
1640
1710
 
1641
1711
 
1642
1712
  def _run_dir(plan: DispatchPlan) -> Path: