okstra 0.205.0 → 0.206.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (53) hide show
  1. package/dist/commands/lifecycle/install.mjs +1 -0
  2. package/dist/commands/lifecycle/install.mjs.map +1 -1
  3. package/docs/architecture.md +6 -6
  4. package/docs/contributor-change-matrix.md +2 -2
  5. package/docs/project-structure-overview.md +8 -5
  6. package/package.json +2 -3
  7. package/runtime/BUILD.json +2 -2
  8. package/runtime/prompts/lead/okstra-lead-contract.md +1 -1
  9. package/runtime/prompts/lead/phase-routing.md +18 -0
  10. package/runtime/python/okstra_ctl/contract_graph.py +75 -10
  11. package/runtime/python/okstra_ctl/doctor.py +13 -3
  12. package/runtime/python/okstra_ctl/implementation_direction.py +9 -8
  13. package/runtime/python/okstra_ctl/incremental_carry.py +19 -2
  14. package/runtime/python/okstra_ctl/next_phase.py +2 -2
  15. package/runtime/python/okstra_ctl/paths.py +14 -0
  16. package/runtime/python/okstra_ctl/phases/__init__.py +4 -0
  17. package/runtime/python/okstra_ctl/phases/catalog.py +216 -0
  18. package/runtime/python/okstra_ctl/phases/final_verification/__init__.py +4 -0
  19. package/runtime/python/okstra_ctl/phases/final_verification/entry.py +166 -0
  20. package/runtime/{prompts/profiles/final-verification.md → python/okstra_ctl/phases/final_verification/profile.md} +4 -4
  21. package/runtime/python/okstra_ctl/{report_html/view_models/final_verification.py → phases/final_verification/report.py} +12 -3
  22. package/{docs/task-process/final-verification.md → runtime/python/okstra_ctl/phases/final_verification/spec.md} +42 -25
  23. package/runtime/python/okstra_ctl/phases/final_verification/target.py +296 -0
  24. package/runtime/python/okstra_ctl/phases/final_verification/validation.py +190 -0
  25. package/runtime/python/okstra_ctl/phases/final_verification/wizard.py +38 -0
  26. package/runtime/python/okstra_ctl/plan_items_cli.py +9 -0
  27. package/runtime/python/okstra_ctl/profile_show.py +7 -1
  28. package/runtime/python/okstra_ctl/render_final_report.py +3 -2
  29. package/runtime/python/okstra_ctl/report_assembly.py +3 -3
  30. package/runtime/python/okstra_ctl/report_html/render.py +3 -2
  31. package/runtime/python/okstra_ctl/report_html/router.py +9 -33
  32. package/runtime/python/okstra_ctl/report_template_loader.py +35 -0
  33. package/runtime/python/okstra_ctl/report_views.py +17 -1
  34. package/runtime/python/okstra_ctl/run.py +46 -154
  35. package/runtime/python/okstra_ctl/stage_targets.py +9 -286
  36. package/runtime/python/okstra_ctl/user_response.py +199 -4
  37. package/runtime/python/okstra_ctl/verification_target.py +1 -1
  38. package/runtime/python/okstra_ctl/wizard/state.py +6 -2
  39. package/runtime/python/okstra_ctl/wizard/steps_plan.py +17 -15
  40. package/runtime/skills/okstra-user-response/SKILL.md +23 -4
  41. package/runtime/validators/validate-run.py +59 -187
  42. package/runtime/validators/validate_session_conformance.py +70 -1
  43. package/docs/task-process/README.md +0 -82
  44. package/docs/task-process/common-flow.md +0 -173
  45. package/docs/task-process/error-analysis.md +0 -103
  46. package/docs/task-process/implementation-option-selection.md +0 -70
  47. package/docs/task-process/implementation-planning.md +0 -180
  48. package/docs/task-process/implementation.md +0 -226
  49. package/docs/task-process/release-handoff.md +0 -220
  50. package/docs/task-process/requirements-discovery.md +0 -113
  51. /package/runtime/{prompts/profiles/final-verification.json → python/okstra_ctl/phases/final_verification/profile.json} +0 -0
  52. /package/runtime/{templates/reports/html/tasks → python/okstra_ctl/phases/final_verification/report_assets}/final-verification.template.html +0 -0
  53. /package/runtime/{templates/reports/md/tasks → python/okstra_ctl/phases/final_verification/report_assets}/final-verification.template.md +0 -0
@@ -1,7 +1,11 @@
1
1
  """Stage readiness and verification target rules.
2
2
 
3
- This module owns the policy that decides which Stage Map stage can run, which
4
- commit it branches from, and what final-verification should inspect. It keeps
3
+ This module owns the policy that decides which Stage Map stage can run and which
4
+ commit it branches from, plus the Git and ledger facts final-verification builds
5
+ its target from (whole-task integration, the stage that contains every other
6
+ done stage, teardown after the verdict). Which of those facts a
7
+ final-verification run uses is decided in
8
+ ``phases/final_verification/target.py``. It keeps
5
9
  the stage lifecycle rules behind one interface instead of leaking raw
6
10
  ``consumers.jsonl`` rows and git ancestry checks into prepare callers.
7
11
  """
@@ -10,13 +14,11 @@ from __future__ import annotations
10
14
  import heapq
11
15
  import subprocess
12
16
  import sys
13
- from dataclasses import dataclass, replace
17
+ from dataclasses import dataclass
14
18
  from pathlib import Path
15
19
  from typing import Any
16
20
 
17
- from .plan_run_root import plan_run_root_from_approved_plan
18
21
  from .prepare_error import PrepareError # noqa: F401 — 재노출(단일 참조점)
19
- from .stage_integrate import IntegrateResult
20
22
  from okstra_project.slug import slugify
21
23
 
22
24
 
@@ -37,32 +39,6 @@ class FinalVerificationTarget:
37
39
  reports: list[str]
38
40
 
39
41
 
40
- @dataclass(frozen=True)
41
- class FinalVerificationTargetRequest:
42
- """Semantic inputs needed to acquire one final-verification target."""
43
-
44
- project_root: Path
45
- project_id: str
46
- task_group: str
47
- task_id: str
48
- work_category: str
49
- approved_plan_path: Path
50
- stage: int | None
51
- stage_map: tuple[dict[str, Any], ...]
52
-
53
-
54
- @dataclass(frozen=True)
55
- class FinalVerificationTargetAcquisition:
56
- """Resolved target facts returned to the prepare adapter."""
57
-
58
- target: FinalVerificationTarget
59
- worktree_branch: str
60
- integration_result: IntegrateResult | None
61
- # whole-task 진입이 task worktree 안에서 꺼낸 중첩 stage worktree.
62
- # `{"stage": N, "from": <old path>, "to": <new path>}` 행.
63
- relocations: tuple[dict[str, Any], ...] = ()
64
-
65
-
66
42
  @dataclass(frozen=True)
67
43
  class StageLifecycle:
68
44
  stage: int
@@ -809,117 +785,6 @@ def resolve_and_integrate_whole_task(
809
785
  )
810
786
 
811
787
 
812
- def _resolve_single_stage_target(
813
- *,
814
- requested_stage: int,
815
- done_rows: list[dict[str, Any]],
816
- stage_base: str,
817
- stage_worktree_path: str,
818
- stage_head: str,
819
- stage_dirty: bool,
820
- ) -> FinalVerificationTarget:
821
- """Resolve single-stage final-verification target, enforcing all gates."""
822
- from .consumers import latest_done_by_stage
823
-
824
- n = requested_stage
825
- done_by_stage = latest_done_by_stage(done_rows)
826
- if n not in done_by_stage:
827
- raise StageTargetError(
828
- f"final-verification(single-stage): stage {n} not done — "
829
- f"run implementation --stage {n} first"
830
- )
831
- if not stage_worktree_path:
832
- raise StageTargetError(
833
- f"final-verification(single-stage): stage worktree not found for "
834
- f"stage {n} (torn down?) — use whole-task mode (--stage auto)"
835
- )
836
- if stage_dirty:
837
- raise StageTargetError(
838
- "final-verification: worktree has uncommitted source changes "
839
- "(outside .okstra/) — commit or stash before verifying"
840
- )
841
- return FinalVerificationTarget(
842
- scope="single-stage",
843
- base=stage_base,
844
- head=stage_head,
845
- worktree_path=stage_worktree_path,
846
- stages=[n],
847
- reports=[done_by_stage[n].get("report_path", "")],
848
- )
849
-
850
-
851
- def _read_final_verification_done_rows(
852
- request: FinalVerificationTargetRequest,
853
- registry_coordinates: tuple[str, str, str],
854
- ) -> list[dict[str, Any]]:
855
- from .consumers import backfill_done_from_carry, read_stage_consumer_state
856
- from .stage_reconcile import auto_reconcile_best_effort
857
-
858
- plan_run_root = plan_run_root_from_approved_plan(request.approved_plan_path)
859
- backfill_done_from_carry(plan_run_root)
860
- project_id, task_group, task_id = registry_coordinates
861
- auto_reconcile_best_effort(
862
- replace(
863
- request,
864
- project_id=project_id,
865
- task_group=task_group,
866
- task_id=task_id,
867
- ),
868
- plan_run_root,
869
- )
870
- return read_stage_consumer_state(plan_run_root).done_rows
871
-
872
-
873
- def _final_verification_registry_coordinates(
874
- request: FinalVerificationTargetRequest,
875
- ) -> tuple[str, str, str]:
876
-
877
- def segment(value: str) -> str:
878
- return slugify(value) or "_"
879
-
880
- return (
881
- segment(request.project_id),
882
- segment(request.task_group),
883
- segment(request.task_id),
884
- )
885
-
886
-
887
- def _acquire_single_stage_target(
888
- request: FinalVerificationTargetRequest,
889
- done_rows: list[dict[str, Any]],
890
- registry_coordinates: tuple[str, str, str],
891
- ) -> FinalVerificationTargetAcquisition:
892
- from . import worktree_registry
893
- from .worktree import _git, is_dirty_excluding_okstra
894
-
895
- stage = request.stage
896
- assert stage is not None
897
- row = worktree_registry.get_stage_row(*registry_coordinates, stage)
898
- worktree_path = (row or {}).get("worktree_path", "")
899
- head = ""
900
- if worktree_path and Path(worktree_path).is_dir():
901
- head_result = _git(worktree_path, "rev-parse", "HEAD")
902
- if head_result.returncode == 0:
903
- head = head_result.stdout.strip()
904
- if not head:
905
- worktree_path = ""
906
- target = _resolve_single_stage_target(
907
- requested_stage=stage,
908
- done_rows=done_rows,
909
- stage_base=(row or {}).get("base_ref", ""),
910
- stage_worktree_path=worktree_path,
911
- stage_head=head,
912
- stage_dirty=(
913
- is_dirty_excluding_okstra(worktree_path) if worktree_path else False
914
- ),
915
- )
916
- return FinalVerificationTargetAcquisition(
917
- target=target,
918
- worktree_branch=(row or {}).get("branch", ""),
919
- integration_result=None,
920
- )
921
-
922
-
923
788
  def single_containing_head(project_root: Path,
924
789
  heads: dict[int, str]) -> int | None:
925
790
  """주어진 stage 들의 commit 을 혼자서 모두 담고 있는 stage. 없으면 None.
@@ -952,117 +817,12 @@ def containing_stage(project_root: Path,
952
817
  return single_containing_head(project_root, heads)
953
818
 
954
819
 
955
- def _acquire_tip_stage_target(
956
- request: FinalVerificationTargetRequest,
957
- done_rows: list[dict[str, Any]],
958
- registry_coordinates: tuple[str, str, str],
959
- ) -> FinalVerificationTargetAcquisition | None:
960
- """전체를 담은 단일 stage 브랜치가 있으면 그것을 whole-task 검증 대상으로
961
- 삼는다 — 머지 없이. 없거나 그 워크트리가 쓸 수 없으면 None 을 돌려 기존
962
- task 브랜치 통합 경로로 넘긴다."""
963
- from . import worktree_registry
964
- from .consumers import latest_done_by_stage
965
- from .worktree import _git, is_dirty_excluding_okstra
966
-
967
- done = latest_done_by_stage(done_rows)
968
- planned = [int(s["stage_number"]) for s in request.stage_map]
969
- if any(n not in done for n in planned):
970
- return None # 미완 stage 의 거부 메시지는 기존 경로가 낸다
971
- tip = containing_stage(request.project_root,
972
- {n: done[n] for n in planned})
973
- if tip is None:
974
- return None
975
- row = worktree_registry.get_stage_row(*registry_coordinates, tip) or {}
976
- worktree_path = row.get("worktree_path", "")
977
- if not worktree_path or not Path(worktree_path).is_dir():
978
- return None
979
- head_result = _git(worktree_path, "rev-parse", "HEAD")
980
- if head_result.returncode != 0:
981
- return None
982
- head = head_result.stdout.strip()
983
- if head != done[tip].get("head_commit", ""):
984
- # 브랜치 tip 이 done 기록과 갈라져 있다. 검증 대상이 무엇인지 모호하므로
985
- # 판정을 기존 경로로 넘긴다.
986
- return None
987
- if is_dirty_excluding_okstra(worktree_path):
988
- raise StageTargetError(
989
- "final-verification: worktree has uncommitted source changes "
990
- "(outside .okstra/) — commit or stash before verifying"
991
- )
992
- anchor_base = worktree_registry.get_implementation_base(
993
- *registry_coordinates) or ""
994
- stages = sorted(planned)
995
- return FinalVerificationTargetAcquisition(
996
- target=FinalVerificationTarget(
997
- scope="whole-task",
998
- base=anchor_base,
999
- head=head,
1000
- worktree_path=worktree_path,
1001
- stages=stages,
1002
- reports=[done[n].get("report_path", "") for n in stages],
1003
- ),
1004
- relocations=(),
1005
- worktree_branch=row.get("branch", ""),
1006
- integration_result=None,
1007
- )
1008
-
1009
-
1010
- def _acquire_whole_task_target(
1011
- request: FinalVerificationTargetRequest,
1012
- done_rows: list[dict[str, Any]],
1013
- registry_coordinates: tuple[str, str, str],
1014
- ) -> FinalVerificationTargetAcquisition:
1015
- from . import worktree_registry
1016
-
1017
- tip = _acquire_tip_stage_target(request, done_rows, registry_coordinates)
1018
- if tip is not None:
1019
- return tip
1020
-
1021
- project_id, task_group, task_id = registry_coordinates
1022
- entry = worktree_registry.lookup(project_id, task_group, task_id)
1023
- worktree_path = (
1024
- entry.worktree_path if entry is not None else str(request.project_root)
1025
- )
1026
- relocations = (
1027
- relocate_nested_stage_worktrees(
1028
- project_id, task_group, task_id, worktree_path,
1029
- stages=[int(row["stage_number"]) for row in request.stage_map],
1030
- )
1031
- if entry is not None
1032
- else []
1033
- )
1034
- whole = _resolve_and_integrate_whole_task_unlocked(
1035
- project_id=project_id,
1036
- task_group=task_group,
1037
- task_id=task_id,
1038
- task_worktree_path=worktree_path,
1039
- stage_map=list(request.stage_map),
1040
- done_rows=done_rows,
1041
- anchor_base=(
1042
- worktree_registry.get_implementation_base(
1043
- project_id, task_group, task_id
1044
- )
1045
- or ""
1046
- ),
1047
- # 정리는 판정 뒤로 미룬다(Phase 7 `teardown-stages`). 되돌릴 수 없는 정리를
1048
- # 판정 앞에 두면, 재작업이 가장 필요한 blocked 판정에서 stage 작업물이 이미
1049
- # 사라져 있다. 여기서는 통합만 하고 worktree/registry 키는 남긴다.
1050
- teardown=False,
1051
- )
1052
- return FinalVerificationTargetAcquisition(
1053
- target=whole["target"],
1054
- relocations=tuple(relocations),
1055
- worktree_branch=entry.branch if entry is not None else "",
1056
- integration_result=whole["integrate_result"],
1057
- )
1058
-
1059
-
1060
820
  def integrate_and_teardown_whole_task(
1061
821
  *, project_root: Path, task_group: str, task_id: str,
1062
822
  ) -> dict[str, Any]:
1063
823
  """판정이 끝난 whole-task 검증의 stage worktree 와 registry 키를 회수한다.
1064
824
 
1065
- 진입은 통합만 하고 정리를 남겨 두므로(`_resolve_whole_task_acquisition`), 정리는
825
+ 진입은 통합만 하고 정리를 남겨 두므로(`phases/final_verification/target.py`), 정리는
1066
826
  판정 뒤인 Phase 7 에서 여기로 들어온다. 통합은 이미 끝나 있어 Phase A 는 전부
1067
827
  `already_merged` 로 지나가고 Phase B 만 실제 일을 한다. 두 번 불려도 결과는 같다 —
1068
828
  사라진 worktree 는 건너뛰고, 미커밋 변경이 남은 stage 트리는 보존한다.
@@ -1117,7 +877,7 @@ def integrate_and_teardown_whole_task(
1117
877
  done_rows=done_rows,
1118
878
  teardown=True,
1119
879
  # 한 stage 브랜치가 이미 전체를 담고 있으면 검증은 거기서 돌았다
1120
- # (`_acquire_tip_stage_target`). 그 경우 task 브랜치로의 머지는 아무도
880
+ # (`phases/final_verification/target.py`). 그 경우 task 브랜치로의 머지는 아무도
1121
881
  # 읽지 않는 산출물이므로 정리만 한다.
1122
882
  merge=tip is None,
1123
883
  verified_head=(done_by_stage.get(tip) or {}).get("head_commit", ""),
@@ -1130,40 +890,3 @@ def integrate_and_teardown_whole_task(
1130
890
  ],
1131
891
  "warnings": result.warnings,
1132
892
  }
1133
-
1134
-
1135
- def acquire_final_verification_target(
1136
- request: FinalVerificationTargetRequest,
1137
- ) -> FinalVerificationTargetAcquisition:
1138
- """Acquire a stable final-verification target behind one task-key lock."""
1139
- from okstra_project.dirs import okstra_home
1140
-
1141
- from .locks import worktree_provision_mutex
1142
-
1143
- try:
1144
- with worktree_provision_mutex(
1145
- okstra_home(),
1146
- request.project_id,
1147
- slugify(request.task_group),
1148
- slugify(request.task_id),
1149
- ):
1150
- registry_coordinates = _final_verification_registry_coordinates(request)
1151
- done_rows = _read_final_verification_done_rows(
1152
- request,
1153
- registry_coordinates,
1154
- )
1155
- if request.stage is not None:
1156
- return _acquire_single_stage_target(
1157
- request,
1158
- done_rows,
1159
- registry_coordinates,
1160
- )
1161
- return _acquire_whole_task_target(
1162
- request,
1163
- done_rows,
1164
- registry_coordinates,
1165
- )
1166
- except PrepareError:
1167
- raise
1168
- except (OSError, RuntimeError, StageTargetError, ValueError) as exc:
1169
- raise PrepareError(str(exc)) from exc
@@ -31,6 +31,7 @@ from okstra_ctl.report_views import (
31
31
  normalize_direction_selection_identity,
32
32
  serialize_user_response, UserResponseEntry, UserPlanDecision,
33
33
  UserReportAuthoring, UserResponseAnalysisReview, UserDirectionSelection,
34
+ direction_candidate_ineligibility,
34
35
  infer_run_meta,
35
36
  parse_expected_form_options,
36
37
  resolve_recommended_option,
@@ -712,6 +713,57 @@ def _plan_decision_required(context: ResponseReportContext) -> bool:
712
713
  return _existing_sidecar_state(context.sidecar_path).plan_decision is None
713
714
 
714
715
 
716
+ _DIRECTION_TASK_TYPE = "implementation-option-selection"
717
+
718
+
719
+ def _direction_candidates(
720
+ context: ResponseReportContext,
721
+ ) -> tuple[list[dict[str, str]], str]:
722
+ """Ranked candidates the user picks the planning direction from.
723
+
724
+ Only a finished candidate comparison that still awaits a direction
725
+ (`routing == pending-direction-selection`, the state `next_phase` reports as
726
+ "pick a direction") has any. Each row carries the report's id and name and,
727
+ when the planning gate would refuse it, the reason.
728
+ """
729
+ if context.task_type != _DIRECTION_TASK_TYPE:
730
+ return [], ""
731
+ record = _load_report_record(context.report_path) or {}
732
+ selection = record.get("implementationOptionSelection")
733
+ if (
734
+ not isinstance(selection, Mapping)
735
+ or selection.get("mode") != "candidate-comparison"
736
+ or selection.get("routing") != "pending-direction-selection"
737
+ ):
738
+ return [], ""
739
+ options = selection.get("rankedOptions")
740
+ candidates: list[dict[str, str]] = []
741
+ for option in options if isinstance(options, list) else []:
742
+ if not isinstance(option, Mapping):
743
+ continue
744
+ try:
745
+ option_id, option_name = normalize_direction_selection_identity(
746
+ str(option.get("id") or ""), str(option.get("name") or "")
747
+ )
748
+ except ValueError as exc:
749
+ raise UserResponseError(str(exc)) from exc
750
+ candidates.append({
751
+ "id": option_id,
752
+ "name": option_name,
753
+ "goal": str(option.get("goal") or ""),
754
+ "coreMechanism": str(option.get("coreMechanism") or ""),
755
+ "ineligibility": direction_candidate_ineligibility(option),
756
+ })
757
+ return candidates, str(selection.get("recommendedOptionId") or "")
758
+
759
+
760
+ def _direction_selection_required(context: ResponseReportContext) -> bool:
761
+ candidates, _ = _direction_candidates(context)
762
+ if not candidates:
763
+ return False
764
+ return _existing_sidecar_state(context.sidecar_path).direction_selection is None
765
+
766
+
715
767
  def list_awaiting_tasks(home: Path, project_id: str, limit: int) -> list[dict]:
716
768
  """사용자 답변을 기다리는 clarification 이 있는 태스크를 최신 report mtime 순으로.
717
769
 
@@ -736,6 +788,7 @@ def list_awaiting_tasks(home: Path, project_id: str, limit: int) -> list[dict]:
736
788
  context = resolve_report_context(report)
737
789
  blockers = _open_blocker_rows(context.report_path)
738
790
  plan_required = _plan_decision_required(context)
791
+ direction_required = _direction_selection_required(context)
739
792
  except UserResponseError:
740
793
  base = {"taskKey": key, "taskType": row.get("taskType", ""),
741
794
  "seq": _seq_from_report(report), "reportPath": str(report),
@@ -752,12 +805,13 @@ def list_awaiting_tasks(home: Path, project_id: str, limit: int) -> list[dict]:
752
805
  "normalizedReportPath": str(context.report_path),
753
806
  "reportMtime": context.report_path.stat().st_mtime,
754
807
  }
755
- if not blockers and not plan_required:
808
+ if not blockers and not plan_required and not direction_required:
756
809
  continue
757
810
  approval_count = sum(1 for row in blockers if row["item"].blocks == "approval")
758
811
  out.append({**base, "openBlockerCount": len(blockers),
759
812
  "openApprovalCount": approval_count,
760
813
  "planDecisionRequired": plan_required,
814
+ "directionSelectionRequired": direction_required,
761
815
  "unreadable": False})
762
816
  out.sort(key=lambda t: t["reportMtime"], reverse=True)
763
817
  return out[:limit] if limit > 0 else out
@@ -1031,8 +1085,11 @@ def _transaction_errors(payload: dict[str, Any]) -> list[str]:
1031
1085
  if not isinstance(draft, Mapping):
1032
1086
  errors.append("draft must be an object")
1033
1087
  else:
1088
+ # 방향 선택 도입 전의 2.0 초안에는 이 필드가 없다.
1034
1089
  errors.extend(_unexpected_keys(
1035
- draft, {"answers", "planDecision", "legacyReportAuthoring"}, "draft"
1090
+ {"directionSelection": None, **draft},
1091
+ {"answers", "planDecision", "legacyReportAuthoring", "directionSelection"},
1092
+ "draft",
1036
1093
  ))
1037
1094
  answers = draft.get("answers")
1038
1095
  if not isinstance(answers, list):
@@ -1066,7 +1123,7 @@ def _transaction_errors(payload: dict[str, Any]) -> list[str]:
1066
1123
  errors.append(f"{label}.rationale must be a string")
1067
1124
  if answer.get("disposition") not in _DIRECT_ANSWER_DISPOSITIONS:
1068
1125
  errors.append(f"{label}.disposition is invalid for a direct answer")
1069
- for field in ("planDecision", "legacyReportAuthoring"):
1126
+ for field in ("planDecision", "legacyReportAuthoring", "directionSelection"):
1070
1127
  value = draft.get(field)
1071
1128
  if value is not None and not isinstance(value, Mapping):
1072
1129
  errors.append(f"draft.{field} must be an object or null")
@@ -1083,6 +1140,21 @@ def _transaction_errors(payload: dict[str, Any]) -> list[str]:
1083
1140
  errors.append(f"draft.planDecision.{field} must be a string")
1084
1141
  if decision.get("status") != "approved" and not decision.get("reason"):
1085
1142
  errors.append("draft.planDecision.reason is required")
1143
+ direction = draft.get("directionSelection")
1144
+ if isinstance(direction, Mapping):
1145
+ errors.extend(_unexpected_keys(
1146
+ direction,
1147
+ {"optionId", "optionName", "selectionNote", "constraints"},
1148
+ "draft.directionSelection",
1149
+ ))
1150
+ for field in ("optionId", "optionName"):
1151
+ if not isinstance(direction.get(field), str) or not direction[field]:
1152
+ errors.append(
1153
+ f"draft.directionSelection.{field} must be a non-empty string"
1154
+ )
1155
+ for field in ("selectionNote", "constraints"):
1156
+ if not isinstance(direction.get(field), str):
1157
+ errors.append(f"draft.directionSelection.{field} must be a string")
1086
1158
  authoring = draft.get("legacyReportAuthoring")
1087
1159
  if isinstance(authoring, Mapping):
1088
1160
  errors.extend(_unexpected_keys(
@@ -1171,6 +1243,7 @@ def _load_transaction(transaction_id: str) -> tuple[Path, dict[str, Any]]:
1171
1243
  raise UserResponseError("transaction anchor digest does not match its token")
1172
1244
  if payload["transactionId"] != transaction_id:
1173
1245
  raise UserResponseError("transaction id does not match its state")
1246
+ payload["draft"].setdefault("directionSelection", None)
1174
1247
  return path, payload
1175
1248
 
1176
1249
 
@@ -1270,6 +1343,9 @@ def _validate_draft_against_context(
1270
1343
  raise UserResponseError(
1271
1344
  f"implementation option is not a report candidate: {selected}"
1272
1345
  )
1346
+ direction = draft.get("directionSelection")
1347
+ if isinstance(direction, Mapping):
1348
+ _eligible_direction(context, str(direction.get("optionId") or ""))
1273
1349
  authoring = draft.get("legacyReportAuthoring")
1274
1350
  if context.report_contract_version != "2.0" and authoring is not None:
1275
1351
  raise UserResponseError(
@@ -1342,6 +1418,7 @@ def begin_response(report_path: Path, task_key: str) -> str:
1342
1418
  "answers": [],
1343
1419
  "planDecision": None,
1344
1420
  "legacyReportAuthoring": None,
1421
+ "directionSelection": None,
1345
1422
  },
1346
1423
  "publication": {
1347
1424
  "status": "draft",
@@ -1478,6 +1555,53 @@ def set_plan_decision(
1478
1555
  _write_transaction(path, payload)
1479
1556
 
1480
1557
 
1558
+ def _eligible_direction(
1559
+ context: ResponseReportContext, option_id: str
1560
+ ) -> dict[str, str]:
1561
+ candidates, _ = _direction_candidates(context)
1562
+ if not candidates:
1563
+ raise UserResponseError(
1564
+ "direction selection needs an implementation-option-selection report "
1565
+ "that awaits a direction (candidate-comparison, pending-direction-selection)"
1566
+ )
1567
+ candidate = next((row for row in candidates if row["id"] == option_id), None)
1568
+ if candidate is None:
1569
+ raise UserResponseError(f"direction option is not a ranked candidate: {option_id}")
1570
+ if candidate["ineligibility"]:
1571
+ raise UserResponseError(f"{option_id}: {candidate['ineligibility']}")
1572
+ return candidate
1573
+
1574
+
1575
+ def set_direction_selection(
1576
+ transaction_id: str,
1577
+ option_number: int,
1578
+ note_file: Path | None,
1579
+ constraints_file: Path | None,
1580
+ ) -> None:
1581
+ with _locked_transaction(transaction_id, mutable=True) as (path, payload, context):
1582
+ candidates, _ = _direction_candidates(context)
1583
+ if not candidates:
1584
+ _eligible_direction(context, "")
1585
+ if option_number < 1 or option_number > len(candidates):
1586
+ raise UserResponseError(
1587
+ f"direction option number does not exist: {option_number}"
1588
+ )
1589
+ candidate = _eligible_direction(context, candidates[option_number - 1]["id"])
1590
+ payload["draft"]["directionSelection"] = {
1591
+ "optionId": candidate["id"],
1592
+ "optionName": candidate["name"],
1593
+ "selectionNote": (
1594
+ _read_body_file(note_file, "selection note", context)
1595
+ if note_file else ""
1596
+ ),
1597
+ "constraints": (
1598
+ _read_body_file(constraints_file, "constraints", context)
1599
+ if constraints_file else ""
1600
+ ),
1601
+ }
1602
+ _write_transaction(path, payload)
1603
+
1604
+
1481
1605
  def set_legacy_report_authoring(
1482
1606
  transaction_id: str, status: str, reason_file: Path
1483
1607
  ) -> None:
@@ -1653,6 +1777,19 @@ def _transaction_authoring(payload: Mapping[str, Any]) -> UserReportAuthoring |
1653
1777
  )
1654
1778
 
1655
1779
 
1780
+ def _transaction_direction(payload: Mapping[str, Any]) -> UserDirectionSelection | None:
1781
+ direction = payload.get("directionSelection")
1782
+ if not isinstance(direction, Mapping):
1783
+ return None
1784
+ return UserDirectionSelection(
1785
+ option_id=str(direction.get("optionId") or ""),
1786
+ option_name=str(direction.get("optionName") or ""),
1787
+ confirmed=True,
1788
+ selection_note=str(direction.get("selectionNote") or ""),
1789
+ constraints=str(direction.get("constraints") or ""),
1790
+ )
1791
+
1792
+
1656
1793
  def _render_transaction_sidecar(
1657
1794
  payload: Mapping[str, Any], context: ResponseReportContext
1658
1795
  ) -> str:
@@ -1669,6 +1806,7 @@ def _render_transaction_sidecar(
1669
1806
  and not draft["answers"]
1670
1807
  and draft["planDecision"] is None
1671
1808
  and draft["legacyReportAuthoring"] is None
1809
+ and draft["directionSelection"] is None
1672
1810
  ):
1673
1811
  return existing_text
1674
1812
  merged = {entry.response_id: entry for entry in existing_state.entries}
@@ -1712,7 +1850,9 @@ def _render_transaction_sidecar(
1712
1850
  _transaction_decision(draft) or existing_state.plan_decision
1713
1851
  ),
1714
1852
  analysis_review=existing_state.analysis_review,
1715
- direction_selection=existing_state.direction_selection,
1853
+ direction_selection=(
1854
+ _transaction_direction(draft) or existing_state.direction_selection
1855
+ ),
1716
1856
  report_authoring=(
1717
1857
  _transaction_authoring(draft) or existing_state.report_authoring
1718
1858
  ),
@@ -1791,6 +1931,8 @@ def format_list_view(rows: list[dict[str, Any]]) -> str:
1791
1931
  f"Open items: {open_items}",
1792
1932
  f"Open approval items: {row.get('openApprovalCount', 0)}",
1793
1933
  f"Plan decision required: {'yes' if row.get('planDecisionRequired') else 'no'}",
1934
+ "Direction selection required: "
1935
+ f"{'yes' if row.get('directionSelectionRequired') else 'no'}",
1794
1936
  f"Status: {status}",
1795
1937
  ])
1796
1938
  picker.extend([
@@ -2007,6 +2149,43 @@ def _format_open_row_view(
2007
2149
  return lines
2008
2150
 
2009
2151
 
2152
+ def _format_direction_view(
2153
+ context: ResponseReportContext, state: ExistingSidecarState
2154
+ ) -> list[str]:
2155
+ candidates, recommended = _direction_candidates(context)
2156
+ if not candidates:
2157
+ return []
2158
+ current = state.direction_selection
2159
+ lines = [
2160
+ "Current direction selection: "
2161
+ f"{f'{current.option_id} {current.option_name}' if current else 'none'}",
2162
+ "Direction candidates:",
2163
+ ]
2164
+ ordered = sorted(
2165
+ enumerate(candidates, start=1),
2166
+ key=lambda pair: pair[1]["id"] != recommended,
2167
+ )
2168
+ for index, candidate in enumerate(candidates, start=1):
2169
+ lines.extend([
2170
+ f"Direction option {index}: {candidate['id']} {candidate['name']}",
2171
+ f" Recommended: {'yes' if candidate['id'] == recommended else 'no'}",
2172
+ f" Goal: {candidate['goal'] or 'not stated in the report'}",
2173
+ f" Core mechanism: {candidate['coreMechanism'] or 'not stated in the report'}",
2174
+ f" Selectable: {'no - ' + candidate['ineligibility'] if candidate['ineligibility'] else 'yes'}",
2175
+ ])
2176
+ lines.append("Direction picker:")
2177
+ for index, candidate in ordered:
2178
+ if candidate["ineligibility"]:
2179
+ continue
2180
+ suffix = " (Recommended)" if candidate["id"] == recommended else ""
2181
+ lines.extend([
2182
+ f"- Label: {candidate['id']} {candidate['name']}{suffix}",
2183
+ f" Description: {candidate['goal'] or 'not stated in the report'}",
2184
+ f" Option number: {index}",
2185
+ ])
2186
+ return lines
2187
+
2188
+
2010
2189
  def format_show_view(report_path: Path, project_root: Path) -> str:
2011
2190
  context = _validate_owned_report_context(
2012
2191
  report_path, expected_project_root=project_root
@@ -2043,6 +2222,7 @@ def format_show_view(report_path: Path, project_root: Path) -> str:
2043
2222
  f" Recommended: {'yes' if candidate == recommended_name else 'no'}",
2044
2223
  f" Current decision: {'yes' if current_pick else 'no'}",
2045
2224
  ])
2225
+ lines.extend(_format_direction_view(context, state))
2046
2226
  for row in rows:
2047
2227
  item = row["item"]
2048
2228
  if item.status not in {"open", "answered"} or item.row_id in current:
@@ -2108,6 +2288,8 @@ _CLI_EPILOG = r"""Usage:
2108
2288
  --kind <kind> --disposition <value> --value-file <md> [--rationale-file <md>]
2109
2289
  okstra user-response plan-decision --transaction <id> --status <value> \
2110
2290
  [--implementation-option <name>] [--reason-file <md>]
2291
+ okstra user-response direction --transaction <id> --option-number <N> \
2292
+ [--note-file <md>] [--constraints-file <md>]
2111
2293
  okstra user-response legacy-report-authoring --transaction <id> \
2112
2294
  --status <approved|denied> --reason-file <md>
2113
2295
  okstra user-response finalize --transaction <id>
@@ -2147,6 +2329,11 @@ def _build_parser() -> argparse.ArgumentParser:
2147
2329
  decision.add_argument("--status", choices=sorted(_PLAN_DECISION_STATUSES), required=True)
2148
2330
  decision.add_argument("--implementation-option", default="")
2149
2331
  decision.add_argument("--reason-file")
2332
+ direction = sub.add_parser("direction")
2333
+ direction.add_argument("--transaction", required=True)
2334
+ direction.add_argument("--option-number", type=int, required=True)
2335
+ direction.add_argument("--note-file")
2336
+ direction.add_argument("--constraints-file")
2150
2337
  authoring = sub.add_parser("legacy-report-authoring")
2151
2338
  authoring.add_argument("--transaction", required=True)
2152
2339
  authoring.add_argument("--status", choices=("approved", "denied"), required=True)
@@ -2191,6 +2378,14 @@ def _dispatch_command(ns: argparse.Namespace) -> None:
2191
2378
  Path(ns.reason_file) if ns.reason_file else None,
2192
2379
  )
2193
2380
  _write_json({"transaction": ns.transaction, "status": "draft"})
2381
+ elif ns.cmd == "direction":
2382
+ set_direction_selection(
2383
+ ns.transaction,
2384
+ ns.option_number,
2385
+ Path(ns.note_file) if ns.note_file else None,
2386
+ Path(ns.constraints_file) if ns.constraints_file else None,
2387
+ )
2388
+ _write_json({"transaction": ns.transaction, "status": "draft"})
2194
2389
  elif ns.cmd == "legacy-report-authoring":
2195
2390
  set_legacy_report_authoring(
2196
2391
  ns.transaction, ns.status, Path(ns.reason_file)