okstra 0.169.1 → 0.170.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 (44) hide show
  1. package/docs/architecture.md +17 -1
  2. package/docs/cli.md +11 -1
  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 +5 -1
  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 +60 -0
  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 +114 -3
  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/skills/okstra-setup/references/project-config.md +38 -0
  43. package/runtime/validators/lib/fixtures.sh +9 -1
  44. package/runtime/validators/validate-run.py +37 -2
@@ -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:
@@ -33,6 +33,11 @@ from .agent_invocation import (
33
33
  agent_model_assignment_from_payload,
34
34
  verify_agent_invocation,
35
35
  )
36
+ from .final_report_paths import (
37
+ final_report_data_path,
38
+ final_report_markdown_path,
39
+ )
40
+ from .worker_prompt_body import REPORT_WRITER_WORKER_ID
36
41
  from .worker_prompt_contract import (
37
42
  PromptRecord,
38
43
  validate_initial_prompt_records,
@@ -504,9 +509,20 @@ def link_agent_dispatch_result(
504
509
  item for item in links
505
510
  if isinstance(item, Mapping) and item.get("resultPath") == result_relative
506
511
  ]
507
- if same_result and any(dict(item) != link for item in same_result):
512
+ # A superseded link is the record of a result the lead rejected, kept so
513
+ # the chain shows the corrective round happened rather than hiding it.
514
+ # It no longer owns the path, so the re-dispatch may claim it.
515
+ live_conflicts = [
516
+ item for item in same_result
517
+ if not item.get("supersededBy") and dict(item) != link
518
+ ]
519
+ if live_conflicts:
508
520
  raise DispatchError(
509
- f"agent result is already linked to another dispatch: {result_relative}"
521
+ f"agent result is already linked to another dispatch: "
522
+ f"{result_relative}. If that result was rejected and re-dispatched, "
523
+ f"record the rejection with `okstra agent-prompt reject-result "
524
+ f"--dispatch-id {live_conflicts[0].get('dispatchId')} "
525
+ f"--superseded-by {dispatch_id}` first"
510
526
  )
511
527
  if any(
512
528
  isinstance(item, Mapping)
@@ -523,6 +539,69 @@ def link_agent_dispatch_result(
523
539
  return link
524
540
 
525
541
 
542
+ def reject_agent_dispatch_result(
543
+ *,
544
+ project_root: Path,
545
+ run_manifest_path: Path,
546
+ dispatch_id: str,
547
+ superseded_by: str,
548
+ reason: str,
549
+ ) -> dict[str, str]:
550
+ """Record that a linked result was rejected and re-dispatched.
551
+
552
+ The contract tells the lead to re-dispatch a worker whose answer violated the
553
+ response format, with a correction paragraph appended. That path did not
554
+ finish: the prompt is immutable and already dispatched, so
555
+ `--replace-undispatched` refuses (correctly), and a fresh invocation id
556
+ produces a worker that writes the same result path — where `link-result`
557
+ refused because the path already belonged to the first dispatch. The worker
558
+ ran, wrote a good result, and the ledger could not accept it.
559
+
560
+ Rejection is a fact worth recording rather than routing around, so it is
561
+ written rather than allowed implicitly: the first link stays in the ledger
562
+ marked `supersededBy` with the lead's reason, and only then may the
563
+ re-dispatch claim the path. Nothing is deleted, so the chain still shows both
564
+ attempts and why the second exists.
565
+ """
566
+ if not superseded_by.strip():
567
+ raise DispatchError("superseding dispatch ID is required")
568
+ if not reason.strip():
569
+ raise DispatchError(
570
+ "a rejection reason is required — the ledger has to say why a "
571
+ "returned result was not accepted"
572
+ )
573
+ project_root = project_root.resolve()
574
+ manifest_path = resolve_project_path(
575
+ project_root, str(run_manifest_path)
576
+ ).resolve(strict=True)
577
+ manifest = load_json_object(manifest_path, "run manifest")
578
+ team_state_path = resolve_required_path(project_root, manifest, "teamStatePath")
579
+ with _team_state_lock(team_state_path):
580
+ team_state = load_json_object(team_state_path, "team-state")
581
+ links = team_state.get("agentResultLinks")
582
+ if not isinstance(links, list):
583
+ raise DispatchError("team-state agentResultLinks must be an array")
584
+ matches = [
585
+ item for item in links
586
+ if isinstance(item, Mapping) and item.get("dispatchId") == dispatch_id
587
+ ]
588
+ if len(matches) != 1:
589
+ raise DispatchError(
590
+ f"expected exactly one agent result link for {dispatch_id}, "
591
+ f"found {len(matches)}"
592
+ )
593
+ row = matches[0]
594
+ if row.get("supersededBy"):
595
+ raise DispatchError(
596
+ f"agent result link is already superseded by "
597
+ f"{row['supersededBy']}: {dispatch_id}"
598
+ )
599
+ row["supersededBy"] = superseded_by
600
+ row["rejectionReason"] = reason
601
+ write_json(team_state_path, team_state)
602
+ return dict(row)
603
+
604
+
526
605
  def _relative_project_path(project_root: Path, path: Path) -> str:
527
606
  try:
528
607
  return path.resolve(strict=False).relative_to(project_root).as_posix()
@@ -644,6 +723,54 @@ def missing_completion_paths(job: WorkerJob) -> tuple[Path, ...]:
644
723
  return tuple(path for path in job.completion_paths if not path.is_file())
645
724
 
646
725
 
726
+ def dispatch_result_path(
727
+ worker_id: str,
728
+ worker_result_path: Path,
729
+ manifest: Mapping[str, Any],
730
+ project_root: Path,
731
+ ) -> Path:
732
+ """What `resultPath` means for this worker.
733
+
734
+ For everyone but the report writer it is the worker-result file. The report
735
+ writer authors three artifacts and its canonical result is the final-report
736
+ data.json, not its own `.md` pointer — a distinction that is not cosmetic:
737
+ `dispatch_core` hands `job.result_path` to report finalization as the data
738
+ path, so a `.md` there is parsed as JSON and a complete report settles
739
+ `error`.
740
+
741
+ This lives beside `worker_jobs_from_file` because both job constructors must
742
+ apply it. It was `dispatch_core`-private while the roster path applied it and
743
+ the `--jobs-file` path took whatever the file said, which is how the two
744
+ produced different jobs for the same worker.
745
+ """
746
+ if worker_id != REPORT_WRITER_WORKER_ID:
747
+ return worker_result_path
748
+ return final_report_data_path(
749
+ resolve_required_path(project_root, manifest, "expectedReportPath")
750
+ )
751
+
752
+
753
+ def dispatch_completion_paths(
754
+ worker_id: str,
755
+ worker_result_path: Path,
756
+ manifest: Mapping[str, Any],
757
+ project_root: Path,
758
+ ) -> tuple[Path, ...]:
759
+ """Every artifact that must exist before this worker counts as done.
760
+
761
+ The report writer's three are the data.json, its rendered Markdown sibling,
762
+ and the worker-result pointer (`prompts/lead/report-writer.md` §"Completion
763
+ detection"). Same reason as `dispatch_result_path`: both constructors need
764
+ the same answer.
765
+ """
766
+ if worker_id != REPORT_WRITER_WORKER_ID:
767
+ return (worker_result_path,)
768
+ data_json = final_report_data_path(
769
+ resolve_required_path(project_root, manifest, "expectedReportPath")
770
+ )
771
+ return (data_json, final_report_markdown_path(data_json), worker_result_path)
772
+
773
+
647
774
  def validate_initial_prompts(
648
775
  manifest: Mapping[str, Any], jobs: Sequence[WorkerJob]
649
776
  ) -> None:
@@ -798,6 +925,7 @@ def worker_jobs_from_file(
798
925
  project_root: Path,
799
926
  jobs_file: Path,
800
927
  *,
928
+ manifest: Mapping[str, Any],
801
929
  backend: str,
802
930
  idle_timeout_seconds: int,
803
931
  default_dispatch_kind: str,
@@ -816,6 +944,7 @@ def worker_jobs_from_file(
816
944
  _worker_job_from_file(
817
945
  project_root,
818
946
  item,
947
+ manifest=manifest,
819
948
  backend=backend,
820
949
  idle_timeout_seconds=idle_timeout_seconds,
821
950
  dispatch_kind=dispatch_kind,
@@ -831,6 +960,7 @@ def _worker_job_from_file(
831
960
  project_root: Path,
832
961
  item: Mapping[str, Any],
833
962
  *,
963
+ manifest: Mapping[str, Any],
834
964
  backend: str,
835
965
  idle_timeout_seconds: int,
836
966
  dispatch_kind: str,
@@ -842,16 +972,20 @@ def _worker_job_from_file(
842
972
  prompt_path = resolve_project_path(
843
973
  project_root, require_string(item, "promptPath")
844
974
  )
845
- result_path = resolve_project_path(
846
- project_root, require_string(item, "resultPath")
847
- )
848
975
  worker_result_path = resolve_project_path(
849
976
  project_root, require_string(item, "workerResultPath")
850
977
  )
851
- completion_paths = tuple(
852
- resolve_project_path(project_root, path)
853
- for path in string_list(item.get("completionPaths"))
854
- ) or (result_path,)
978
+ # The file's own `resultPath` / `completionPaths` are advisory: the same
979
+ # derivation the roster path applies decides them, so a jobs-file dispatch
980
+ # and a roster dispatch of one worker cannot disagree about which artifact
981
+ # is the result. A file that names the report writer's `.md` as its result
982
+ # used to reach report finalization as the data path.
983
+ result_path = dispatch_result_path(
984
+ worker_id, worker_result_path, manifest, project_root
985
+ )
986
+ completion_paths = dispatch_completion_paths(
987
+ worker_id, worker_result_path, manifest, project_root
988
+ )
855
989
  digests = item.get("digests")
856
990
  digest_values = digests if isinstance(digests, Mapping) else {}
857
991
  host_model_value = item.get("hostModelValue")
@@ -9,6 +9,7 @@ from pathlib import Path
9
9
  from typing import Iterable
10
10
 
11
11
  from okstra_project import ResolverError, project_json_path, resolve_project_root
12
+ from okstra_project.resolver import resolve_review_rule_packs
12
13
 
13
14
  from . import improvement_lenses, worktree_registry
14
15
  from .models import provider_wrappers
@@ -99,6 +100,9 @@ def _project_checks(cwd: Path) -> tuple[Path | None, list[DoctorCheck]]:
99
100
  architecture = _architecture_declaration_check(project_root)
100
101
  if architecture is not None:
101
102
  checks.append(architecture)
103
+ review_packs = _review_rule_pack_check(project_root)
104
+ if review_packs is not None:
105
+ checks.append(review_packs)
102
106
  return project_root, checks
103
107
 
104
108
 
@@ -158,6 +162,33 @@ def _architecture_declaration_check(project_root: Path) -> DoctorCheck | None:
158
162
  )
159
163
 
160
164
 
165
+ def _review_rule_pack_check(project_root: Path) -> DoctorCheck | None:
166
+ """Report a declared review rule pack that no worker will be able to open.
167
+
168
+ `reviewRulePacks` is what makes a project's own review standard apply
169
+ without every brief citing it, so a stale path costs the whole pack in
170
+ silence: the phase records `project-review-rules: declared <path>
171
+ unreadable` at best, and a run that reviewed against nothing still passes.
172
+ Absent declaration stays quiet — brief-cited packs remain the other, equally
173
+ valid, channel.
174
+
175
+ What this cannot check is whether a pack that does resolve was actually read
176
+ and applied; that stays the worker's own `project-review-rules:` record.
177
+ """
178
+ declared = resolve_review_rule_packs(project_root)
179
+ if not declared:
180
+ return None
181
+ missing = [path for path in declared if not Path(path).is_file()]
182
+ if not missing:
183
+ return _ok("review rule packs", f"{len(declared)} declared, all readable")
184
+ return _fail(
185
+ "review rule packs",
186
+ f"declared but not readable: {', '.join(missing)} — phases skip a pack "
187
+ "they cannot open, so the run reviews against fewer rules than declared. "
188
+ f"Fix the path in {project_json_path(project_root)} or drop the entry.",
189
+ )
190
+
191
+
161
192
  def _profile_check(workspace: Path, phase: str) -> DoctorCheck:
162
193
  profile = _profile_path(workspace, phase)
163
194
  if profile.is_file():