okstra 0.205.1 → 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.
- package/dist/commands/lifecycle/install.mjs +1 -0
- package/dist/commands/lifecycle/install.mjs.map +1 -1
- package/docs/architecture.md +6 -6
- package/docs/contributor-change-matrix.md +2 -2
- package/docs/project-structure-overview.md +8 -5
- package/package.json +2 -3
- package/runtime/BUILD.json +2 -2
- package/runtime/prompts/lead/okstra-lead-contract.md +1 -1
- package/runtime/prompts/lead/phase-routing.md +18 -0
- package/runtime/python/okstra_ctl/contract_graph.py +75 -10
- package/runtime/python/okstra_ctl/doctor.py +13 -3
- package/runtime/python/okstra_ctl/implementation_direction.py +9 -8
- package/runtime/python/okstra_ctl/next_phase.py +2 -2
- package/runtime/python/okstra_ctl/paths.py +14 -0
- package/runtime/python/okstra_ctl/phases/__init__.py +4 -0
- package/runtime/python/okstra_ctl/phases/catalog.py +216 -0
- package/runtime/python/okstra_ctl/phases/final_verification/__init__.py +4 -0
- package/runtime/python/okstra_ctl/phases/final_verification/entry.py +166 -0
- package/runtime/{prompts/profiles/final-verification.md → python/okstra_ctl/phases/final_verification/profile.md} +4 -4
- package/runtime/python/okstra_ctl/{report_html/view_models/final_verification.py → phases/final_verification/report.py} +12 -3
- package/{docs/task-process/final-verification.md → runtime/python/okstra_ctl/phases/final_verification/spec.md} +42 -25
- package/runtime/python/okstra_ctl/phases/final_verification/target.py +296 -0
- package/runtime/python/okstra_ctl/phases/final_verification/validation.py +190 -0
- package/runtime/python/okstra_ctl/phases/final_verification/wizard.py +38 -0
- package/runtime/python/okstra_ctl/profile_show.py +7 -1
- package/runtime/python/okstra_ctl/render_final_report.py +3 -2
- package/runtime/python/okstra_ctl/report_assembly.py +3 -3
- package/runtime/python/okstra_ctl/report_html/render.py +3 -2
- package/runtime/python/okstra_ctl/report_html/router.py +9 -33
- package/runtime/python/okstra_ctl/report_template_loader.py +35 -0
- package/runtime/python/okstra_ctl/report_views.py +17 -1
- package/runtime/python/okstra_ctl/run.py +46 -154
- package/runtime/python/okstra_ctl/stage_targets.py +9 -286
- package/runtime/python/okstra_ctl/user_response.py +199 -4
- package/runtime/python/okstra_ctl/verification_target.py +1 -1
- package/runtime/python/okstra_ctl/wizard/state.py +6 -2
- package/runtime/python/okstra_ctl/wizard/steps_plan.py +17 -15
- package/runtime/skills/okstra-user-response/SKILL.md +23 -4
- package/runtime/validators/validate-run.py +22 -185
- package/docs/task-process/README.md +0 -82
- package/docs/task-process/common-flow.md +0 -173
- package/docs/task-process/error-analysis.md +0 -103
- package/docs/task-process/implementation-option-selection.md +0 -70
- package/docs/task-process/implementation-planning.md +0 -180
- package/docs/task-process/implementation.md +0 -226
- package/docs/task-process/release-handoff.md +0 -220
- package/docs/task-process/requirements-discovery.md +0 -113
- /package/runtime/{prompts/profiles/final-verification.json → python/okstra_ctl/phases/final_verification/profile.json} +0 -0
- /package/runtime/{templates/reports/html/tasks → python/okstra_ctl/phases/final_verification/report_assets}/final-verification.template.html +0 -0
- /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
|
|
4
|
-
commit it branches from, and
|
|
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
|
|
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
|
-
진입은 통합만 하고 정리를 남겨 두므로(`
|
|
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
|
-
# (`
|
|
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
|
-
|
|
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=
|
|
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)
|