okstra 0.183.2 → 0.185.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 (54) hide show
  1. package/README.md +2 -2
  2. package/dist/cli-registry.mjs +9 -0
  3. package/dist/cli-registry.mjs.map +1 -1
  4. package/dist/commands/chat/chat.d.mts +1 -0
  5. package/dist/commands/chat/chat.mjs +385 -0
  6. package/dist/commands/chat/chat.mjs.map +1 -0
  7. package/dist/lib/skill-catalog.mjs +1 -0
  8. package/dist/lib/skill-catalog.mjs.map +1 -1
  9. package/docs/architecture.md +10 -8
  10. package/docs/cli.md +9 -5
  11. package/docs/for-ai/README.md +4 -2
  12. package/docs/for-ai/skills/okstra-chat.md +28 -0
  13. package/docs/for-ai/skills/okstra-inspect.md +1 -1
  14. package/docs/for-ai/skills/okstra-run.md +2 -2
  15. package/docs/for-ai/skills/okstra-user-response.md +10 -8
  16. package/docs/project-structure-overview.md +6 -5
  17. package/docs/task-process/README.md +2 -2
  18. package/docs/task-process/common-flow.md +2 -3
  19. package/docs/task-process/error-analysis.md +3 -4
  20. package/docs/task-process/final-verification.md +2 -3
  21. package/docs/task-process/implementation-planning.md +3 -4
  22. package/docs/task-process/implementation.md +2 -3
  23. package/docs/task-process/release-handoff.md +3 -4
  24. package/docs/task-process/requirements-discovery.md +3 -4
  25. package/package.json +1 -1
  26. package/runtime/BUILD.json +2 -2
  27. package/runtime/prompts/launch.template.md +8 -7
  28. package/runtime/prompts/lead/okstra-lead-contract.md +7 -6
  29. package/runtime/prompts/lead/plan-body-verification.md +27 -19
  30. package/runtime/prompts/lead/report-writer.md +4 -4
  31. package/runtime/prompts/profiles/_clarification-recommendation.md +2 -2
  32. package/runtime/prompts/profiles/_implementation-executor.md +1 -0
  33. package/runtime/prompts/profiles/_implementation-verifier.md +1 -1
  34. package/runtime/prompts/profiles/implementation-planning.md +11 -12
  35. package/runtime/prompts/wizard/prompts.ko.json +9 -10
  36. package/runtime/python/okstra_ctl/adapters/hosts/claude-code/relay.md +2 -2
  37. package/runtime/python/okstra_ctl/adapters/hosts/codex/relay.md +1 -1
  38. package/runtime/python/okstra_ctl/adapters/hosts/grok/relay.md +1 -1
  39. package/runtime/python/okstra_ctl/conformance.py +37 -1
  40. package/runtime/python/okstra_ctl/incremental_scope.py +84 -39
  41. package/runtime/python/okstra_ctl/next_phase.py +67 -4
  42. package/runtime/python/okstra_ctl/plan_items.py +410 -1
  43. package/runtime/python/okstra_ctl/plan_items_cli.py +346 -31
  44. package/runtime/python/okstra_ctl/render.py +4 -0
  45. package/runtime/python/okstra_ctl/user_response.py +147 -37
  46. package/runtime/python/okstra_ctl/wizard.py +52 -73
  47. package/runtime/schemas/final-report-v2.0.schema.json +12 -0
  48. package/runtime/schemas/final-report-v3.0.schema.json +12 -0
  49. package/runtime/skills/okstra-chat/SKILL.md +104 -0
  50. package/runtime/skills/okstra-inspect/facets/status.md +6 -5
  51. package/runtime/skills/okstra-run/SKILL.md +4 -4
  52. package/runtime/skills/okstra-user-response/SKILL.md +50 -16
  53. package/runtime/validators/validate-run.py +254 -81
  54. package/runtime/validators/validate_session_conformance.py +24 -5
@@ -9,7 +9,7 @@ from __future__ import annotations
9
9
  import argparse
10
10
  import importlib.util
11
11
  import json
12
- from collections.abc import Mapping
12
+ from collections.abc import Mapping, Sequence
13
13
  from datetime import datetime, timezone
14
14
  from pathlib import Path
15
15
  import sys
@@ -22,8 +22,21 @@ from .convergence import (
22
22
  )
23
23
  from .convergence_store import write_json_atomic
24
24
  from .json_boundary import JsonBoundaryError, load_owned_object
25
+ from .report_finalize import task_manifest_path
25
26
  from .plan_derivations import extract_tokens, find_derivations
26
- from .plan_items import PlanItemContractError, extract_plan_items
27
+ from .plan_items import (
28
+ NextDispatch,
29
+ PlanItemContractError,
30
+ advisory_plan_body_gating,
31
+ content_hash,
32
+ correction_prompt_text,
33
+ dispatch_item_ids,
34
+ extract_plan_items,
35
+ next_dispatch,
36
+ planning_stage_ledger,
37
+ reverify_item_ids,
38
+ tie_vote_item_ids,
39
+ )
27
40
  from .paths import RunRef
28
41
  from .stage_ledger import build_stage_ledger
29
42
  from .claim_reproduction import NOT_RUNNABLE, reproduce
@@ -61,6 +74,64 @@ def _envelope(data: Mapping[str, Any]) -> dict[str, Any]:
61
74
  }
62
75
 
63
76
 
77
+ def _merged_ledger(
78
+ planning: Mapping[str, Any],
79
+ run_manifest: Path | None,
80
+ ) -> dict[str, str]:
81
+ return planning_stage_ledger(planning, _stage_ledger_snapshot(run_manifest))
82
+
83
+
84
+ def _previous_hashes(state_path: Path | None) -> dict[str, str]:
85
+ if state_path is None or not state_path.is_file():
86
+ return {}
87
+ rows = _state_plan_body_items(_load_json_object(state_path), state_path)
88
+ return {
89
+ str(row["id"]): str(row["verifiedContentHash"])
90
+ for row in rows
91
+ if isinstance(row, Mapping)
92
+ and isinstance(row.get("id"), str)
93
+ and isinstance(row.get("verifiedContentHash"), str)
94
+ }
95
+
96
+
97
+ def _queue_for(
98
+ items: list[dict[str, Any]],
99
+ planning: Mapping[str, Any],
100
+ run_manifest: Path | None,
101
+ previous_hashes: Mapping[str, str] | None = None,
102
+ tied_ids: Sequence[str] | None = None,
103
+ ) -> list[str]:
104
+ ledger = _merged_ledger(planning, run_manifest)
105
+ if tied_ids is not None:
106
+ return tie_vote_item_ids(items, ledger, tied_ids)
107
+ if previous_hashes:
108
+ return reverify_item_ids(items, previous_hashes, ledger)
109
+ return dispatch_item_ids(items, ledger)
110
+
111
+
112
+ def _queue_kwargs(args: argparse.Namespace) -> dict[str, Any]:
113
+ if getattr(args, "tie_vote", False):
114
+ state = getattr(args, "state", None)
115
+ if state is None:
116
+ raise PlanItemContractError("--tie-vote requires --state")
117
+ return {"tied_ids": _tied_ids(state)}
118
+ return {"previous_hashes": _previous_hashes(getattr(args, "state", None))}
119
+
120
+
121
+ def _tied_ids(state_path: Path) -> list[str]:
122
+ if not state_path.is_file():
123
+ raise PlanItemContractError("--tie-vote requires --state with recorded verdicts")
124
+ items = _state_plan_body_items(_load_json_object(state_path), state_path)
125
+ gate = _gate_module()
126
+ return [
127
+ str(item["id"])
128
+ for item in items
129
+ if isinstance(item, Mapping)
130
+ and isinstance(item.get("id"), str)
131
+ and gate._is_unsettled_tie(dict(item))
132
+ ]
133
+
134
+
64
135
  def _parser() -> argparse.ArgumentParser:
65
136
  parser = argparse.ArgumentParser(
66
137
  prog="okstra plan-items",
@@ -76,11 +147,28 @@ def _parser() -> argparse.ArgumentParser:
76
147
  prepare = commands.add_parser("prepare")
77
148
  _add_plan_source(prepare)
78
149
  prepare.add_argument("--run-manifest", type=Path, required=True)
150
+ prepare.add_argument(
151
+ "--state", type=Path,
152
+ help="previous plan-body state; when it carries verifiedContentHash "
153
+ "rows the dispatch queue shrinks to the self-fix reverify set",
154
+ )
155
+ prepare.add_argument(
156
+ "--tie-vote", action="store_true",
157
+ help="dispatch queue is the needs-reverify ties in --state, nothing else",
158
+ )
79
159
  prompt = commands.add_parser("prompt")
80
160
  prompt.add_argument("--run-manifest", type=Path, required=True)
81
161
  validate_prepared = commands.add_parser("validate-prepared")
82
162
  _add_plan_source(validate_prepared)
83
163
  validate_prepared.add_argument("--run-manifest", type=Path, required=True)
164
+ validate_prepared.add_argument(
165
+ "--state", type=Path,
166
+ help="same previous state prepare used to shrink the dispatch queue",
167
+ )
168
+ validate_prepared.add_argument(
169
+ "--tie-vote", action="store_true",
170
+ help="same --tie-vote prepare used to shrink the dispatch queue",
171
+ )
84
172
  collect = commands.add_parser(
85
173
  "collect-verdicts",
86
174
  help="read this round's worker responses into a verdicts envelope",
@@ -138,6 +226,10 @@ def _parser() -> argparse.ArgumentParser:
138
226
  help="the verification round these verdicts were cast in; stamped on "
139
227
  "every row so a later self-fix can be told from a current judgement",
140
228
  )
229
+ apply_verdicts.add_argument(
230
+ "--append", action="store_true",
231
+ help="add these votes to existing rows instead of replacing the round",
232
+ )
141
233
  complete = commands.add_parser(
142
234
  "complete-round",
143
235
  help="derive and atomically record one verified plan-body round",
@@ -148,9 +240,26 @@ def _parser() -> argparse.ArgumentParser:
148
240
  complete.add_argument("--self-fix-note", action="append", default=[], metavar="<item-id>=<markdown-file>")
149
241
  complete.add_argument("--self-fix-group", action="append", default=[], metavar="<cause-file>=<item-id>[,<item-id>...]")
150
242
  complete.add_argument("--self-fix-stop-reason", choices=("all-resolved", "no-progress", "max-rounds-reached"))
243
+ _add_dispatch_commands(commands)
151
244
  return parser
152
245
 
153
246
 
247
+ def _add_dispatch_commands(commands: Any) -> None:
248
+ nxt = commands.add_parser(
249
+ "next-dispatch",
250
+ help="decide whether this round opens a worker batch",
251
+ )
252
+ nxt.add_argument("--state", type=Path, required=True)
253
+ nxt.add_argument("--run-manifest", type=Path)
254
+ correction = commands.add_parser(
255
+ "correction-prompt",
256
+ help="environment-exception preamble then the assigned queue",
257
+ )
258
+ correction.add_argument("--state", type=Path, required=True)
259
+ correction.add_argument("--run-manifest", type=Path, required=True)
260
+ correction.add_argument("--worker", required=True)
261
+
262
+
154
263
  def _add_plan_source(parser: argparse.ArgumentParser) -> None:
155
264
  source = parser.add_mutually_exclusive_group(required=True)
156
265
  source.add_argument("--data", type=Path)
@@ -211,11 +320,43 @@ def _prepared_items_path(
211
320
  return path
212
321
 
213
322
 
323
+ def _sync_task_manifest_gating(run_manifest: Path, gating: bool) -> None:
324
+ """준비 이후 매니페스트 ``gating`` 을 계획 사실로 맞춘다."""
325
+ try:
326
+ authority = validated_run_authority(run_manifest)
327
+ path = task_manifest_path(authority.project_root, authority.payload)
328
+ payload = load_owned_object(path, artifact="task manifest")
329
+ except (ConvergenceContractError, JsonBoundaryError, OSError, ValueError):
330
+ return
331
+ block = payload.get("convergence")
332
+ if not isinstance(block, dict):
333
+ return
334
+ pbv = block.get("planBodyVerification")
335
+ if not isinstance(pbv, dict):
336
+ return
337
+ pbv["gating"] = gating
338
+ write_json_atomic(path, payload)
339
+
340
+
214
341
  def _prepare(args: argparse.Namespace) -> dict[str, Any]:
215
342
  output = _prepared_items_path(args.run_manifest)
216
- envelope = _envelope(_plan_source(args))
343
+ source = _plan_source(args)
344
+ envelope = _envelope(source)
345
+ envelope["dispatchQueue"] = _queue_for(
346
+ envelope["items"],
347
+ _planning(source),
348
+ args.run_manifest,
349
+ **_queue_kwargs(args),
350
+ )
351
+ gating = not advisory_plan_body_gating(_planning(source), envelope["items"])
352
+ _sync_task_manifest_gating(args.run_manifest, gating)
217
353
  write_json_atomic(output, envelope)
218
- return {"ok": True, "operation": "prepare", "path": str(output)}
354
+ return {
355
+ "ok": True,
356
+ "operation": "prepare",
357
+ "path": str(output),
358
+ "gating": gating,
359
+ }
219
360
 
220
361
 
221
362
  _PAYLOAD_FIELDS = {
@@ -364,6 +505,13 @@ def _prompt(args: argparse.Namespace) -> str:
364
505
  _prepared_items_path(args.run_manifest, require_regular=True)
365
506
  )
366
507
  items = envelope.get("items") if isinstance(envelope.get("items"), list) else []
508
+ queue = envelope.get("dispatchQueue")
509
+ if isinstance(queue, list):
510
+ allowed = {item_id for item_id in queue if isinstance(item_id, str)}
511
+ items = [
512
+ item for item in items
513
+ if isinstance(item, Mapping) and item.get("id") in allowed
514
+ ]
367
515
  rows = ["# Plan verification queue\n", line("Item count", len(items))]
368
516
  for index, item in enumerate(items, 1):
369
517
  if not isinstance(item, Mapping):
@@ -376,10 +524,22 @@ def _prompt(args: argparse.Namespace) -> str:
376
524
 
377
525
 
378
526
  def _validate_prepared(args: argparse.Namespace) -> dict[str, Any]:
379
- expected = _envelope(_plan_source(args))
527
+ source = _plan_source(args)
528
+ expected = _envelope(source)
380
529
  path = _prepared_items_path(args.run_manifest, require_regular=True)
381
- if _load_json_object(path) != expected:
382
- raise PlanItemContractError("prepared plan items do not match deterministic extraction")
530
+ actual = _load_json_object(path)
531
+ if actual.get("items") != expected["items"]:
532
+ raise PlanItemContractError(
533
+ "prepared plan items do not match deterministic extraction"
534
+ )
535
+ expected_queue = _queue_for(
536
+ expected["items"],
537
+ _planning(source),
538
+ args.run_manifest,
539
+ **_queue_kwargs(args),
540
+ )
541
+ if actual.get("dispatchQueue") != expected_queue:
542
+ raise PlanItemContractError("prepared dispatch queue does not match")
383
543
  return {"ok": True, "operation": "validate-prepared", "path": str(path)}
384
544
 
385
545
 
@@ -397,6 +557,9 @@ def _assigned_item_ids(items_path: Path) -> list[str]:
397
557
  items = envelope.get("items")
398
558
  if not isinstance(items, list):
399
559
  raise PlanItemContractError(f"items envelope has no `items` array: {items_path}")
560
+ queue = envelope.get("dispatchQueue")
561
+ if isinstance(queue, list):
562
+ return [item_id for item_id in queue if isinstance(item_id, str) and item_id]
400
563
  ids: list[str] = []
401
564
  for item in items:
402
565
  item_id = item.get("id") if isinstance(item, Mapping) else None
@@ -630,7 +793,9 @@ def _seed(args: argparse.Namespace) -> dict[str, Any]:
630
793
  already applied, `carriedForwardFromSeq`, `selfFixNote` — because a re-seed
631
794
  between rounds must not erase the round before it.
632
795
  """
633
- extracted = _envelope(_plan_source(args))["items"]
796
+ source = _plan_source(args)
797
+ extracted = _envelope(source)["items"]
798
+ hashes = {item["id"]: content_hash(item) for item in extracted}
634
799
  if args.state is not None:
635
800
  data = _load_json_object(args.state) if args.state.is_file() else _new_v3_state()
636
801
  recorded = _state_plan_body_items(data, args.state)
@@ -661,11 +826,17 @@ def _seed(args: argparse.Namespace) -> dict[str, Any]:
661
826
  if item.get("stageScope") else {}
662
827
  ) | (
663
828
  {"block": item["block"]} if item.get("block") else {}
829
+ ) | (
830
+ {"contentHash": hashes[item["id"]]} if item["id"] in hashes else {}
664
831
  ) | {"verdicts": []}
665
832
  for item in extracted
666
833
  if item["id"] not in known
667
834
  ]
668
835
  recorded.extend(added)
836
+ for row in recorded:
837
+ item_id = row.get("id") if isinstance(row, Mapping) else None
838
+ if isinstance(item_id, str) and item_id in hashes:
839
+ row["contentHash"] = hashes[item_id]
669
840
  if args.state is not None:
670
841
  audit_rows = data.get("planItems")
671
842
  if not isinstance(audit_rows, list):
@@ -685,13 +856,26 @@ def _seed(args: argparse.Namespace) -> dict[str, Any]:
685
856
  for item in extracted
686
857
  if item["id"] not in audited
687
858
  )
688
- ledger = _stage_ledger_snapshot(getattr(args, "run_manifest", None))
689
- if ledger is not None:
690
- verification = (
691
- data["planBodyVerification"] if args.state is not None
692
- else _planning(data)["planBodyVerification"]
693
- )
859
+ verification = (
860
+ data["planBodyVerification"] if args.state is not None
861
+ else _planning(data)["planBodyVerification"]
862
+ )
863
+ ledger = _merged_ledger(_planning(source), getattr(args, "run_manifest", None))
864
+ if ledger:
694
865
  verification["stageLedger"] = ledger
866
+ previous = {
867
+ str(row["id"]): str(row["verifiedContentHash"])
868
+ for row in recorded
869
+ if isinstance(row, Mapping)
870
+ and isinstance(row.get("id"), str)
871
+ and isinstance(row.get("verifiedContentHash"), str)
872
+ }
873
+ verification["dispatchQueue"] = _queue_for(
874
+ extracted, _planning(source), getattr(args, "run_manifest", None), previous,
875
+ )
876
+ verification["gating"] = not advisory_plan_body_gating(
877
+ _planning(source), extracted,
878
+ )
695
879
  write_json_atomic(target, data)
696
880
  return {
697
881
  "ok": True,
@@ -743,7 +927,16 @@ def _apply_verdicts(args: argparse.Namespace) -> dict[str, Any]:
743
927
  else _plan_body_items(data, target)
744
928
  )
745
929
  known = {item.get("id") for item in recorded if isinstance(item, Mapping)}
746
- rows = _incoming_verdict_rows(args, known)
930
+ verification = (
931
+ data["planBodyVerification"] if args.state is not None
932
+ else _planning(data)["planBodyVerification"]
933
+ )
934
+ queue = verification.get("dispatchQueue") if isinstance(verification, Mapping) else None
935
+ assigned = (
936
+ {item_id for item_id in queue if isinstance(item_id, str)}
937
+ if isinstance(queue, list) else known
938
+ )
939
+ rows = _incoming_verdict_rows(args, assigned)
747
940
  missing = sorted(item_id for item_id in rows if item_id not in known)
748
941
  if missing:
749
942
  raise PlanItemContractError(
@@ -754,25 +947,74 @@ def _apply_verdicts(args: argparse.Namespace) -> dict[str, Any]:
754
947
  if args.round_number < 1:
755
948
  raise PlanItemContractError("--round must be 1 or greater")
756
949
  project_root = _probe_project_root(getattr(args, "run_manifest", None))
950
+ writer = (
951
+ _append_item_verdicts if getattr(args, "append", False)
952
+ else _replace_item_verdicts
953
+ )
757
954
  for item in recorded:
758
955
  if isinstance(item, Mapping) and item.get("id") in rows:
759
- # Overwrite, never merge: the contract records one round at a time,
760
- # and a merged table lets a previous round's votes keep voting.
761
- #
762
- # Each row carries the round it was cast in. A self-fix round
763
- # rewrites the plan *after* a verification round, so an item left out
764
- # of a later round keeps a verdict on text that has since changed —
765
- # invisibly, because the gate reads the table without knowing any
766
- # row's vintage. Stamping it here is what lets the validator tell a
767
- # current judgement from one two rewrites old.
768
- item["verdicts"] = [
769
- {**_judged_row(row, project_root), "round": args.round_number}
770
- for row in rows[item["id"]]
771
- ]
956
+ writer(item, rows[item["id"]], args.round_number, project_root)
772
957
  write_json_atomic(target, data)
773
958
  return {"ok": True, "operation": "apply-verdicts", "path": str(target)}
774
959
 
775
960
 
961
+ def _stamped_verdicts(
962
+ incoming: list[dict[str, Any]], round_number: int, project_root: Path | None,
963
+ ) -> list[dict[str, Any]]:
964
+ return [
965
+ {**_judged_row(row, project_root), "round": round_number}
966
+ for row in incoming
967
+ ]
968
+
969
+
970
+ def _remember_verified_hash(item: dict[str, Any]) -> None:
971
+ if item.get("contentHash"):
972
+ item["verifiedContentHash"] = item["contentHash"]
973
+
974
+
975
+ def _replace_item_verdicts(
976
+ item: dict[str, Any],
977
+ incoming: list[dict[str, Any]],
978
+ round_number: int,
979
+ project_root: Path | None,
980
+ ) -> None:
981
+ item["verdicts"] = _stamped_verdicts(incoming, round_number, project_root)
982
+ _remember_verified_hash(item)
983
+
984
+
985
+ def _append_item_verdicts(
986
+ item: dict[str, Any],
987
+ incoming: list[dict[str, Any]],
988
+ round_number: int,
989
+ project_root: Path | None,
990
+ ) -> None:
991
+ """동수 항목의 세 번째 표. 이미 투표한 워커는 거부한다."""
992
+ stamped = _stamped_verdicts(incoming, round_number, project_root)
993
+ existing = item.get("verdicts")
994
+ current = existing if isinstance(existing, list) else []
995
+ seen = {
996
+ row.get("worker") for row in current if isinstance(row, Mapping)
997
+ }
998
+ clash = [row.get("worker") for row in stamped if row.get("worker") in seen]
999
+ if clash:
1000
+ raise PlanItemContractError(
1001
+ f"plan item {item.get('id')} already has a vote from {clash} — "
1002
+ "the extra vote must come from a worker who has not voted on it"
1003
+ )
1004
+ item["verdicts"] = [*current, *stamped]
1005
+ _remember_verified_hash(item)
1006
+
1007
+
1008
+ def _gate_module() -> Any:
1009
+ path = Path(__file__).parents[2] / "validators" / "validate-run.py"
1010
+ spec = importlib.util.spec_from_file_location("okstra_plan_gate", path)
1011
+ if spec is None or spec.loader is None:
1012
+ raise PlanItemContractError("cannot load plan-body gate authority")
1013
+ module = importlib.util.module_from_spec(spec)
1014
+ spec.loader.exec_module(module)
1015
+ return module
1016
+
1017
+
776
1018
  def _round_snapshot(item: Mapping[str, Any], round_number: int) -> dict[str, Any]:
777
1019
  votes = {
778
1020
  str(row["worker"]): str(row["verdict"])
@@ -852,7 +1094,15 @@ def _round_inputs(args: argparse.Namespace) -> tuple[dict[str, Any], list[dict[s
852
1094
 
853
1095
 
854
1096
  def _round_snapshots(current: list[dict[str, Any]], verification: Mapping[str, Any], round_number: int) -> tuple[dict[str, dict[str, Any]], dict[str, Any]]:
855
- snapshots = {str(item.get("id")): _round_snapshot(item, round_number) for item in current}
1097
+ queue = verification.get("dispatchQueue")
1098
+ scoped = current
1099
+ if isinstance(queue, list):
1100
+ allowed = {item_id for item_id in queue if isinstance(item_id, str)}
1101
+ scoped = [
1102
+ item for item in current
1103
+ if isinstance(item, Mapping) and item.get("id") in allowed
1104
+ ]
1105
+ snapshots = {str(item.get("id")): _round_snapshot(item, round_number) for item in scoped}
856
1106
  if not snapshots or any(not row["votes"] for row in snapshots.values()):
857
1107
  raise PlanItemContractError("every current plan item needs a verdict before completion")
858
1108
  summary = _plan_gate_summary(verification)
@@ -892,6 +1142,15 @@ def _complete_round(args: argparse.Namespace) -> dict[str, Any]:
892
1142
  raise PlanItemContractError("--round must be 1 or greater")
893
1143
  data, current, audit, history = _round_inputs(args)
894
1144
  verification = data["planBodyVerification"]
1145
+ if verification.get("gating") is False:
1146
+ if args.round_number > 1:
1147
+ raise PlanItemContractError(
1148
+ "advisory plan-body gating allows one verification round"
1149
+ )
1150
+ if args.self_fix_group or args.self_fix_note:
1151
+ raise PlanItemContractError(
1152
+ "advisory plan-body gating forbids the self-fix loop"
1153
+ )
895
1154
  snapshots, summary = _round_snapshots(current, verification, args.round_number)
896
1155
  _record_audit_round(audit, snapshots, args.round_number)
897
1156
  gate = str(summary["recomputed"])
@@ -915,7 +1174,54 @@ def _complete_round(args: argparse.Namespace) -> dict[str, Any]:
915
1174
  history[:] = [row for row in history if row.get("round") != args.round_number]
916
1175
  history.append({"round": args.round_number, "completedAt": datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ"), "gateResult": gate, "gateBlockedBy": verification["gateBlockedBy"]})
917
1176
  write_json_atomic(args.state, data)
918
- return {"ok": True, "operation": "complete-round", "path": str(args.state), "gateResult": gate}
1177
+ payloads = _prepared_payloads(args.run_manifest)
1178
+ return {
1179
+ "ok": True,
1180
+ "operation": "complete-round",
1181
+ "path": str(args.state),
1182
+ "gateResult": gate,
1183
+ "nextDispatch": next_dispatch(current, payloads).as_dict(),
1184
+ }
1185
+
1186
+
1187
+ def _prepared_payloads(run_manifest: Path | None) -> dict[str, dict[str, Any]]:
1188
+ if run_manifest is None:
1189
+ return {}
1190
+ try:
1191
+ envelope = _load_json_object(
1192
+ _prepared_items_path(run_manifest, require_regular=True)
1193
+ )
1194
+ except PlanItemContractError:
1195
+ return {}
1196
+ items = envelope.get("items")
1197
+ if not isinstance(items, list):
1198
+ return {}
1199
+ return {
1200
+ str(item["id"]): item
1201
+ for item in items
1202
+ if isinstance(item, Mapping) and isinstance(item.get("id"), str)
1203
+ }
1204
+
1205
+
1206
+ def _state_next_dispatch(args: argparse.Namespace) -> NextDispatch:
1207
+ _data, current, _audit, _history = _round_inputs(args)
1208
+ payloads = _prepared_payloads(getattr(args, "run_manifest", None))
1209
+ return next_dispatch(current, payloads)
1210
+
1211
+
1212
+ def _next_dispatch(args: argparse.Namespace) -> dict[str, Any]:
1213
+ decision = _state_next_dispatch(args)
1214
+ return {"ok": True, "operation": "next-dispatch", **decision.as_dict()}
1215
+
1216
+
1217
+ def _correction_prompt(args: argparse.Namespace) -> str:
1218
+ decision = _state_next_dispatch(args)
1219
+ if decision.kind != "worker-correction" or args.worker not in decision.workers:
1220
+ raise PlanItemContractError(
1221
+ f"worker `{args.worker}` is not a blanket-UNVERIFIABLE correction "
1222
+ "target — do not open a queue round"
1223
+ )
1224
+ return correction_prompt_text(_prompt(args))
919
1225
 
920
1226
 
921
1227
  def _incoming_verdict_rows(
@@ -949,6 +1255,8 @@ _HANDLERS = {
949
1255
  "seed": _seed,
950
1256
  "apply-verdicts": _apply_verdicts,
951
1257
  "complete-round": _complete_round,
1258
+ "next-dispatch": _next_dispatch,
1259
+ "correction-prompt": _correction_prompt,
952
1260
  }
953
1261
 
954
1262
 
@@ -962,7 +1270,14 @@ def main(argv: list[str] | None = None) -> int:
962
1270
  if isinstance(result, str):
963
1271
  print(result, end="")
964
1272
  elif args.command in {"prepare", "validate-prepared"}:
965
- print("Plan items\n" + line("Status", "ready") + line("Operation", result["operation"]), end="")
1273
+ rows = (
1274
+ "Plan items\n"
1275
+ + line("Status", "ready")
1276
+ + line("Operation", result["operation"])
1277
+ )
1278
+ if "gating" in result:
1279
+ rows += line("Gating", result["gating"])
1280
+ print(rows, end="")
966
1281
  else:
967
1282
  print(json.dumps(result, ensure_ascii=False, indent=2))
968
1283
  return 0
@@ -1584,6 +1584,10 @@ def _build_convergence_block(ctx: dict) -> dict:
1584
1584
  always emitted (dead-letter on other phases) so the schema stays stable.
1585
1585
  Its `selfFixMaxRounds` default 1 bounds the report-writer self-fix loop
1586
1586
  that runs before a planner-fixable defect is promoted to the user.
1587
+ `gating` is true here because the plan does not exist yet. After the
1588
+ report-writer draft, `okstra plan-items prepare` flips it to false when
1589
+ `designPreparation.mode` is `no-design-inputs` and the Stage Map has
1590
+ exactly one row.
1587
1591
 
1588
1592
  ctx knobs honoured:
1589
1593
  - `OKSTRA_PLAN_VERIFICATION`: "true" | "false" | "" (empty → default True).