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.
- 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/incremental_carry.py +19 -2
- 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/plan_items_cli.py +9 -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 +59 -187
- package/runtime/validators/validate_session_conformance.py +70 -1
- 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
|
@@ -30,7 +30,10 @@ from .json_boundary import (
|
|
|
30
30
|
load_owned_object_snapshot,
|
|
31
31
|
write_owned_object_atomic,
|
|
32
32
|
)
|
|
33
|
-
from .report_views import
|
|
33
|
+
from .report_views import (
|
|
34
|
+
direction_candidate_ineligibility,
|
|
35
|
+
normalize_direction_selection_identity,
|
|
36
|
+
)
|
|
34
37
|
from .qa_commands import find_denied_tokens
|
|
35
38
|
from .scope_provenance import brief_end_state_id_sequence
|
|
36
39
|
from .user_response import UserResponseError, parse_direction_selection
|
|
@@ -309,15 +312,13 @@ def _selected_option(
|
|
|
309
312
|
raise DirectionSelectionError(str(exc)) from exc
|
|
310
313
|
if report_option_id != option_id:
|
|
311
314
|
raise DirectionSelectionError("selected candidate id does not match report")
|
|
312
|
-
|
|
313
|
-
if
|
|
314
|
-
raise DirectionSelectionError(
|
|
315
|
+
ineligibility = direction_candidate_ineligibility(option)
|
|
316
|
+
if ineligibility:
|
|
317
|
+
raise DirectionSelectionError(ineligibility)
|
|
315
318
|
return option, report_option_name
|
|
316
319
|
|
|
317
320
|
|
|
318
|
-
def _validate_no_blockers(data: Mapping[str, Any]
|
|
319
|
-
if option.get("safetyBlockers") or option.get("unresolvedFeasibilityFacts"):
|
|
320
|
-
raise DirectionSelectionError("selected candidate has a safety blocker")
|
|
321
|
+
def _validate_no_blockers(data: Mapping[str, Any]) -> None:
|
|
321
322
|
blockers = progress_blocking_ids(
|
|
322
323
|
data.get("clarificationItems"), USER_INPUT_BLOCKS
|
|
323
324
|
)
|
|
@@ -389,7 +390,7 @@ def resolve_selected_direction(
|
|
|
389
390
|
else:
|
|
390
391
|
raise DirectionSelectionError(f"unsupported selection mode: {mode}")
|
|
391
392
|
option, normalized_option_name = _selected_option(selection, option_id)
|
|
392
|
-
_validate_no_blockers(data
|
|
393
|
+
_validate_no_blockers(data)
|
|
393
394
|
return SelectedDirection(
|
|
394
395
|
mode=mode,
|
|
395
396
|
task_key=expected_task_key,
|
|
@@ -387,7 +387,16 @@ def _carry_unchanged_checklists(
|
|
|
387
387
|
def _recompute_dispatch_queue(
|
|
388
388
|
narrative: Mapping[str, Any],
|
|
389
389
|
state_items: dict[str, dict],
|
|
390
|
+
stage_ledger: Mapping[str, Any] | None = None,
|
|
390
391
|
) -> list[str]:
|
|
392
|
+
"""이월 뒤 남은 디스패치 큐.
|
|
393
|
+
|
|
394
|
+
stage 상태는 디스크 원장이 답한다. 그것 없이 서사의 depends-on 만으로 다시
|
|
395
|
+
세면 이미 구현된 stage 가 `ready` 로 읽혀 큐에 들어가고, 정작 이번 run 이
|
|
396
|
+
추가한 stage 는 의존이 안 풀린 것으로 보여 빠진다 — 큐가 정확히 뒤집힌다
|
|
397
|
+
(2026-09-24, jobs implementation-planning 003: stage 1~8 done + stage 9
|
|
398
|
+
추가인 carry-all run 에서 큐 33 → 60, 내용은 done stage 의 항목뿐).
|
|
399
|
+
"""
|
|
391
400
|
try:
|
|
392
401
|
extracted = extract_plan_items(_planning(dict(narrative)))
|
|
393
402
|
except (CarryError, PlanItemContractError, KeyError, TypeError):
|
|
@@ -404,7 +413,13 @@ def _recompute_dispatch_queue(
|
|
|
404
413
|
}
|
|
405
414
|
if not previous_hashes:
|
|
406
415
|
return []
|
|
407
|
-
ledger = planning_stage_ledger(
|
|
416
|
+
ledger = planning_stage_ledger(
|
|
417
|
+
_planning(dict(narrative)),
|
|
418
|
+
{
|
|
419
|
+
str(stage): str(status)
|
|
420
|
+
for stage, status in (stage_ledger or {}).items()
|
|
421
|
+
},
|
|
422
|
+
)
|
|
408
423
|
return reverify_item_ids(extracted, previous_hashes, ledger)
|
|
409
424
|
|
|
410
425
|
|
|
@@ -479,7 +494,9 @@ def merge_v3_plan_state(
|
|
|
479
494
|
pbv["planItems"] = [
|
|
480
495
|
state_items[item_id] for item_id in sorted(state_items)
|
|
481
496
|
]
|
|
482
|
-
queue = _recompute_dispatch_queue(
|
|
497
|
+
queue = _recompute_dispatch_queue(
|
|
498
|
+
narrative, state_items, pbv.get("stageLedger"),
|
|
499
|
+
)
|
|
483
500
|
if queue:
|
|
484
501
|
pbv["dispatchQueue"] = queue
|
|
485
502
|
return state
|
|
@@ -283,8 +283,8 @@ def _direction_selection_reason(selection: Mapping[str, Any]) -> str:
|
|
|
283
283
|
# (`implementation_direction.py` `_comparison_selection`). 종전 문장은
|
|
284
284
|
# "위저드에 후보가 선택지로 나온다" 고 해서 사이드카 없이 계획을 열게 했다.
|
|
285
285
|
return (
|
|
286
|
-
f"{head}
|
|
287
|
-
"
|
|
286
|
+
f"{head} `/okstra-user-response` 에서 이 리포트를 골라 채팅으로 고르거나, "
|
|
287
|
+
"이 리포트의 HTML 열람본 방향 선택 칸에서 고르고 Export 한 파일을 "
|
|
288
288
|
"`runs/implementation-option-selection/user-responses/"
|
|
289
289
|
"user-response-implementation-option-selection-<이 리포트 번호>.md` 로 "
|
|
290
290
|
"저장하세요. 그다음 `/okstra-run` 으로 `implementation-planning` 을 "
|
|
@@ -88,7 +88,21 @@ def find_asset_root(
|
|
|
88
88
|
root = Path(override)
|
|
89
89
|
if is_present(root.joinpath(*relative)):
|
|
90
90
|
return root
|
|
91
|
+
return walk_to_asset_root(relative, start=start, is_present=is_present)
|
|
91
92
|
|
|
93
|
+
|
|
94
|
+
def walk_to_asset_root(
|
|
95
|
+
relative: Sequence[str],
|
|
96
|
+
*,
|
|
97
|
+
start: Optional[Path] = None,
|
|
98
|
+
is_present: Callable[[Path], bool] = Path.is_file,
|
|
99
|
+
) -> Optional[Path]:
|
|
100
|
+
"""The nearest ancestor of ``start`` that carries ``relative``, or None.
|
|
101
|
+
|
|
102
|
+
This is the layout walk of `find_asset_root` without the `OKSTRA_HOME`
|
|
103
|
+
override. A caller that must stay inside the tree it started from (a phase
|
|
104
|
+
profile resolving its includes) calls this directly.
|
|
105
|
+
"""
|
|
92
106
|
here = Path(start or __file__).resolve()
|
|
93
107
|
for parent in [here, *here.parents]:
|
|
94
108
|
if is_present(parent.joinpath(*relative)):
|
|
@@ -0,0 +1,216 @@
|
|
|
1
|
+
"""12개 작업 유형과 단계 디렉터리의 고정 대응.
|
|
2
|
+
|
|
3
|
+
이 모듈을 import 해도 단계 실행 모듈은 적재되지 않는다. 경로는 문자열로만
|
|
4
|
+
계산한다. 외부 설정에서 모듈 경로를 받아 실행하지 않는다.
|
|
5
|
+
"""
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
from dataclasses import dataclass
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
|
|
11
|
+
from ..paths import walk_to_asset_root
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class PhaseAssetError(ValueError):
|
|
15
|
+
"""단계 자산을 요청한 실행 루트에서 해석할 수 없다."""
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class UnknownTaskType(PhaseAssetError):
|
|
19
|
+
"""등록표에 없는 작업 유형이다."""
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
@dataclass(frozen=True)
|
|
23
|
+
class PhaseRecord:
|
|
24
|
+
"""외부 작업 유형 하나와 그 코드 위치."""
|
|
25
|
+
|
|
26
|
+
task_type: str
|
|
27
|
+
package_name: str
|
|
28
|
+
migrated: bool
|
|
29
|
+
view_module: str
|
|
30
|
+
view_function: str
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def _view(package_name: str, function: str, *, phase_package: bool) -> tuple[str, str]:
|
|
34
|
+
if phase_package:
|
|
35
|
+
return f"okstra_ctl.phases.{package_name}.report", function
|
|
36
|
+
return f"okstra_ctl.report_html.view_models.{package_name}", function
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def _record(
|
|
40
|
+
task_type: str,
|
|
41
|
+
package_name: str,
|
|
42
|
+
migrated: bool,
|
|
43
|
+
view_function: str,
|
|
44
|
+
) -> PhaseRecord:
|
|
45
|
+
module, function = _view(package_name, view_function, phase_package=migrated)
|
|
46
|
+
return PhaseRecord(task_type, package_name, migrated, module, function)
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
# 이전되지 않은 단계는 기존 ``prompts/profiles`` 위치를 명시적으로 쓴다.
|
|
50
|
+
# 이전된 단계는 okstra_ctl 패키지를 품은 루트에서 단계 디렉터리만 본다. 그
|
|
51
|
+
# 루트의 단계 파일이 없어도 prompts/profiles 사본으로 넘어가지 않는다. 패키지를
|
|
52
|
+
# 품지 않은 루트는 그 루트의 prompts/profiles 사본이 자료다 (``profile_markdown``).
|
|
53
|
+
PHASES: tuple[PhaseRecord, ...] = (
|
|
54
|
+
_record("requirements-discovery", "requirements_discovery", False, "build_requirements_discovery_view"),
|
|
55
|
+
_record("improvement-discovery", "improvement_discovery", False, "build_improvement_discovery_view"),
|
|
56
|
+
_record("project-analysis", "project_analysis", False, "build_project_analysis_view"),
|
|
57
|
+
_record("feature-analysis", "feature_analysis", False, "build_feature_analysis_view"),
|
|
58
|
+
_record("change-impact-analysis", "change_impact_analysis", False, "build_change_impact_analysis_view"),
|
|
59
|
+
_record("error-analysis", "error_analysis", False, "build_error_analysis_view"),
|
|
60
|
+
_record("technical-verification", "technical_verification", False, "build_technical_verification_view"),
|
|
61
|
+
_record(
|
|
62
|
+
"implementation-option-selection",
|
|
63
|
+
"implementation_option_selection",
|
|
64
|
+
False,
|
|
65
|
+
"build_implementation_option_selection_view",
|
|
66
|
+
),
|
|
67
|
+
_record("implementation-planning", "implementation_planning", False, "build_implementation_planning_view"),
|
|
68
|
+
_record("implementation", "implementation", False, "build_implementation_view"),
|
|
69
|
+
_record("final-verification", "final_verification", True, "build_final_verification_view"),
|
|
70
|
+
_record("release-handoff", "release_handoff", False, "build_release_handoff_view"),
|
|
71
|
+
)
|
|
72
|
+
|
|
73
|
+
_BY_TASK_TYPE = {row.task_type: row for row in PHASES}
|
|
74
|
+
|
|
75
|
+
# 같은 자산 루트 안에서만 고른다. 다른 설치본으로 넘어가는 순서가 아니다.
|
|
76
|
+
_PACKAGE_RELATIVES = (
|
|
77
|
+
("scripts", "okstra_ctl"),
|
|
78
|
+
("python", "okstra_ctl"),
|
|
79
|
+
("lib", "python", "okstra_ctl"),
|
|
80
|
+
)
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def phase_record(task_type: str) -> PhaseRecord:
|
|
84
|
+
try:
|
|
85
|
+
return _BY_TASK_TYPE[task_type]
|
|
86
|
+
except KeyError as exc:
|
|
87
|
+
raise UnknownTaskType(f"unknown task type: {task_type}") from exc
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def task_types() -> tuple[str, ...]:
|
|
91
|
+
return tuple(row.task_type for row in PHASES)
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def is_migrated(task_type: str) -> bool:
|
|
95
|
+
"""단계 패키지로 옮겨진 작업 유형인지. 등록표에 없는 이름은 False."""
|
|
96
|
+
record = _BY_TASK_TYPE.get(task_type)
|
|
97
|
+
return record is not None and record.migrated
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def logical_profile_markdown(task_type: str) -> str:
|
|
101
|
+
"""실행 자료에 기록되는 프로필 계약 경로. 물리 파일 이름과 다를 수 있다."""
|
|
102
|
+
phase_record(task_type)
|
|
103
|
+
return f"prompts/profiles/{task_type}.md"
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def package_root(asset_root: Path) -> Path | None:
|
|
107
|
+
"""``asset_root`` 안의 okstra_ctl 패키지. 없으면 None, 둘 이상이면 오류."""
|
|
108
|
+
found = [
|
|
109
|
+
asset_root.joinpath(*parts)
|
|
110
|
+
for parts in _PACKAGE_RELATIVES
|
|
111
|
+
if (asset_root.joinpath(*parts) / "__init__.py").is_file()
|
|
112
|
+
]
|
|
113
|
+
if not found:
|
|
114
|
+
return None
|
|
115
|
+
if len(found) > 1:
|
|
116
|
+
joined = ", ".join(str(path) for path in found)
|
|
117
|
+
raise PhaseAssetError(f"ambiguous okstra_ctl package under {asset_root}: {joined}")
|
|
118
|
+
return found[0]
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def profile_markdown(asset_root: Path, task_type: str) -> Path:
|
|
122
|
+
"""요청한 실행 루트에서 프로필 Markdown 의 물리 경로."""
|
|
123
|
+
record = phase_record(task_type)
|
|
124
|
+
legacy = asset_root / "prompts" / "profiles" / f"{task_type}.md"
|
|
125
|
+
if not record.migrated:
|
|
126
|
+
return legacy
|
|
127
|
+
root = package_root(asset_root)
|
|
128
|
+
# 요청한 루트가 런타임 패키지를 품지 않으면 그 루트에 있는 프로필이 그 실행의
|
|
129
|
+
# 자료다. 패키지를 품은 루트에서 단계 파일이 없을 때는 아래 경로를 그대로
|
|
130
|
+
# 돌려, 같은 루트의 prompts/profiles 사본으로 넘어가지 않는다.
|
|
131
|
+
if root is None:
|
|
132
|
+
return legacy
|
|
133
|
+
return root / "phases" / record.package_name / "profile.md"
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
def profile_contract(asset_root: Path, task_type: str) -> Path:
|
|
137
|
+
return profile_markdown(asset_root, task_type).with_suffix(".json")
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
def view_builder_import(task_type: str) -> tuple[str, str]:
|
|
141
|
+
"""표시 함수의 모듈과 이름. import 는 호출자가 선택 시점에 한다."""
|
|
142
|
+
record = phase_record(task_type)
|
|
143
|
+
return record.view_module, record.view_function
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
def migrated_report_template(asset_root: Path, logical_name: str) -> Path | None:
|
|
147
|
+
"""이전된 보고서 본문의 물리 경로.
|
|
148
|
+
|
|
149
|
+
이 논리 이름이 단계 소유가 아니면 None 이다. ``asset_root`` 가 okstra_ctl
|
|
150
|
+
패키지를 품지 않을 때도 None 이며, 호출자는 그 루트의 ``templates/reports``
|
|
151
|
+
사본을 읽는다 (``profile_markdown`` 과 같은 규칙).
|
|
152
|
+
"""
|
|
153
|
+
for record in PHASES:
|
|
154
|
+
if not record.migrated:
|
|
155
|
+
continue
|
|
156
|
+
filename = _report_template_filename(record.task_type, logical_name)
|
|
157
|
+
if filename is None:
|
|
158
|
+
continue
|
|
159
|
+
root = package_root(asset_root)
|
|
160
|
+
if root is None:
|
|
161
|
+
return None
|
|
162
|
+
return root / "phases" / record.package_name / "report_assets" / filename
|
|
163
|
+
return None
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
def resolve_include(profile_path: Path, target_name: str) -> Path:
|
|
167
|
+
"""포함 대상을 찾는다.
|
|
168
|
+
|
|
169
|
+
이전되지 않은 프로필은 자기 부모 디렉터리 기준을 유지한다. 이전된
|
|
170
|
+
``profile.md`` 는 그 파일을 품은 자산 루트의 논리 경로로 찾고, 다른
|
|
171
|
+
설치본의 ``OKSTRA_HOME`` 으로 넘어가지 않는다. 공유 조각과 같은 이름의
|
|
172
|
+
단계 전용 파일이 함께 있으면 어느 쪽인지 정할 수 없으므로 오류다.
|
|
173
|
+
"""
|
|
174
|
+
name = target_name.strip()
|
|
175
|
+
if not name or name.startswith("/") or ".." in Path(name).parts:
|
|
176
|
+
raise PhaseAssetError(f"invalid profile include target: {target_name!r}")
|
|
177
|
+
if not _is_migrated_profile(profile_path):
|
|
178
|
+
return profile_path.parent / name
|
|
179
|
+
asset_root = _asset_root_containing(profile_path)
|
|
180
|
+
if name.startswith("prompts/"):
|
|
181
|
+
return asset_root / name
|
|
182
|
+
shared = asset_root / "prompts" / "profiles" / Path(name).name
|
|
183
|
+
local = profile_path.parent / name
|
|
184
|
+
if shared.is_file() and local.is_file():
|
|
185
|
+
raise PhaseAssetError(
|
|
186
|
+
f"profile include {name!r} exists both as a shared fragment ({shared}) "
|
|
187
|
+
f"and beside the phase profile ({local})"
|
|
188
|
+
)
|
|
189
|
+
if local.is_file():
|
|
190
|
+
return local
|
|
191
|
+
return shared
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
def _is_migrated_profile(profile_path: Path) -> bool:
|
|
195
|
+
if profile_path.name != "profile.md" or profile_path.parent.parent.name != "phases":
|
|
196
|
+
return False
|
|
197
|
+
package_name = profile_path.parent.name
|
|
198
|
+
return any(row.migrated and row.package_name == package_name for row in PHASES)
|
|
199
|
+
|
|
200
|
+
|
|
201
|
+
def _asset_root_containing(start: Path) -> Path:
|
|
202
|
+
root = walk_to_asset_root(("prompts", "profiles"), start=start, is_present=Path.is_dir)
|
|
203
|
+
if root is None:
|
|
204
|
+
raise PhaseAssetError(
|
|
205
|
+
f"could not locate prompts/profiles while resolving an include from {start}"
|
|
206
|
+
)
|
|
207
|
+
return root
|
|
208
|
+
|
|
209
|
+
|
|
210
|
+
def _report_template_filename(task_type: str, logical_name: str) -> str | None:
|
|
211
|
+
if logical_name == f"html/tasks/{task_type}.template.html":
|
|
212
|
+
return f"{task_type}.template.html"
|
|
213
|
+
if logical_name == f"md/tasks/{task_type}.template.md":
|
|
214
|
+
return f"{task_type}.template.md"
|
|
215
|
+
return None
|
|
216
|
+
|
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
"""final-verification 준비 중 이 단계가 소유하는 대상 고정.
|
|
2
|
+
|
|
3
|
+
공통 조립부는 CLI 값과 diff 요약을 넘긴다. 대상 요청, 렌더 문맥 기록,
|
|
4
|
+
스냅샷 다이제스트는 여기서 계산한다. 이 모듈은 공통 실행 조립부를 import
|
|
5
|
+
하지 않는다.
|
|
6
|
+
"""
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import hashlib
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
|
|
12
|
+
from okstra_ctl.phases.final_verification.target import (
|
|
13
|
+
FinalVerificationTargetAcquisition,
|
|
14
|
+
FinalVerificationTargetRequest,
|
|
15
|
+
)
|
|
16
|
+
from okstra_ctl.prepare_error import PrepareError
|
|
17
|
+
from okstra_ctl.stage_targets import FinalVerificationTarget
|
|
18
|
+
from okstra_ctl.worktree import WorktreeProvision
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def reject_prepare_flags(*, approve_plan_ack: bool, implementation_option: str) -> None:
|
|
22
|
+
"""승인과 구현 방향 적용은 implementation 준비의 일이다."""
|
|
23
|
+
if approve_plan_ack:
|
|
24
|
+
raise PrepareError(
|
|
25
|
+
"--approve is only meaningful with --task-type implementation "
|
|
26
|
+
"and --approved-plan <path>"
|
|
27
|
+
)
|
|
28
|
+
if implementation_option:
|
|
29
|
+
raise PrepareError(
|
|
30
|
+
"--implementation-option is only meaningful with --task-type "
|
|
31
|
+
"implementation and --approved-plan <path>"
|
|
32
|
+
)
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def single_stage_worktree() -> WorktreeProvision:
|
|
36
|
+
"""선택한 stage 원장 행이 정해지기 전까지 두는 자리."""
|
|
37
|
+
return WorktreeProvision(
|
|
38
|
+
status="deferred-final-verification",
|
|
39
|
+
note=(
|
|
40
|
+
"final-verification single-stage uses the selected implementation "
|
|
41
|
+
"stage worktree from the registry"
|
|
42
|
+
),
|
|
43
|
+
)
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def verification_target_request(
|
|
47
|
+
*,
|
|
48
|
+
project_root: str,
|
|
49
|
+
project_id: str,
|
|
50
|
+
task_group: str,
|
|
51
|
+
task_id: str,
|
|
52
|
+
work_category: str,
|
|
53
|
+
approved_plan_path: str,
|
|
54
|
+
stage: str,
|
|
55
|
+
stage_map: list[dict],
|
|
56
|
+
) -> FinalVerificationTargetRequest:
|
|
57
|
+
"""``stage`` 가 ``auto`` 이면 대상 선택을 비운다. 호출부가 기본값을 고르지 않는다."""
|
|
58
|
+
selected = int(stage) if stage and stage != "auto" else None
|
|
59
|
+
return FinalVerificationTargetRequest(
|
|
60
|
+
project_root=Path(project_root),
|
|
61
|
+
project_id=project_id,
|
|
62
|
+
task_group=task_group,
|
|
63
|
+
task_id=task_id,
|
|
64
|
+
work_category=work_category,
|
|
65
|
+
approved_plan_path=Path(approved_plan_path),
|
|
66
|
+
stage=selected,
|
|
67
|
+
stage_map=tuple(stage_map),
|
|
68
|
+
)
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def apply_verification_target(
|
|
72
|
+
ctx: dict,
|
|
73
|
+
acquisition: FinalVerificationTargetAcquisition,
|
|
74
|
+
diff_stat: str,
|
|
75
|
+
) -> None:
|
|
76
|
+
"""획득한 검증 대상을 렌더 문맥에 적는다. diff 요약은 호출부가 계산한다."""
|
|
77
|
+
target = acquisition.target
|
|
78
|
+
if target.scope == "single-stage":
|
|
79
|
+
stage = target.stages[0]
|
|
80
|
+
ctx["EXECUTOR_WORKTREE_PATH"] = target.worktree_path
|
|
81
|
+
ctx["EXECUTOR_WORKTREE_BRANCH"] = acquisition.worktree_branch
|
|
82
|
+
ctx["EXECUTOR_WORKTREE_BASE_REF"] = target.base
|
|
83
|
+
ctx["EXECUTOR_WORKTREE_STATUS"] = "reused-stage"
|
|
84
|
+
ctx["EXECUTOR_WORKTREE_NOTE"] = (
|
|
85
|
+
f"final-verification uses implementation stage {stage} worktree"
|
|
86
|
+
)
|
|
87
|
+
if acquisition.integration_result is not None:
|
|
88
|
+
ctx["STAGE_INTEGRATION"] = _format_integration(acquisition.integration_result)
|
|
89
|
+
if acquisition.relocations:
|
|
90
|
+
moved = "\n".join(
|
|
91
|
+
f"- stage {row['stage']}: `{row['from']}` → `{row['to']}`"
|
|
92
|
+
for row in acquisition.relocations
|
|
93
|
+
)
|
|
94
|
+
ctx["STAGE_INTEGRATION"] = (
|
|
95
|
+
f"{ctx.get('STAGE_INTEGRATION', '')}\n"
|
|
96
|
+
"Nested stage worktrees moved out of the verification worktree "
|
|
97
|
+
"(registry updated; contents and branches unchanged):\n"
|
|
98
|
+
f"{moved}"
|
|
99
|
+
).strip()
|
|
100
|
+
ctx["VERIFICATION_SCOPE"] = target.scope
|
|
101
|
+
ctx["VERIFICATION_WORKTREE_PATH"] = target.worktree_path
|
|
102
|
+
ctx["VERIFICATION_BASE_REF"] = target.base
|
|
103
|
+
ctx["VERIFICATION_HEAD_REF"] = target.head
|
|
104
|
+
ctx["VERIFICATION_TARGET"] = _format_verification_target(target, diff_stat)
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def write_verification_target_snapshot(
|
|
108
|
+
instruction_set: Path,
|
|
109
|
+
target_markdown: str,
|
|
110
|
+
) -> tuple[Path, str]:
|
|
111
|
+
"""정규화한 검증 대상 스냅샷을 쓰고 그 다이제스트를 돌려준다."""
|
|
112
|
+
normalized = target_markdown.replace("\r\n", "\n").replace("\r", "\n")
|
|
113
|
+
normalized = normalized.rstrip() + "\n"
|
|
114
|
+
digest = "sha256:" + hashlib.sha256(normalized.encode("utf-8")).hexdigest()
|
|
115
|
+
path = instruction_set / "verification-target.md"
|
|
116
|
+
# 계산 규칙을 값과 함께 적는다. 워커는 이 다이제스트를 프롬프트 앵커로도
|
|
117
|
+
# 받는데, 규칙을 모르면 파일 전체 바이트로 재고 반드시 불일치를 본다 —
|
|
118
|
+
# 실측(2026-08-26): 그렇게 잰 검증자가 스냅샷이 변조됐다고 보고했다.
|
|
119
|
+
# 다이제스트 줄은 줄머리 앵커로 잘리므로(`_TARGET_DIGEST_RE`) 그 뒤에
|
|
120
|
+
# 붙는 이 줄은 본문에 들어가지 않는다.
|
|
121
|
+
path.write_text(
|
|
122
|
+
normalized
|
|
123
|
+
+ f"- **Verification target digest:** `{digest}`\n"
|
|
124
|
+
+ "- **Digest rule:** `sha256` of the text above this line, with CRLF "
|
|
125
|
+
"and CR normalized to LF, trailing whitespace stripped, and exactly "
|
|
126
|
+
"one closing newline. This line and the digest line are not covered.\n",
|
|
127
|
+
encoding="utf-8",
|
|
128
|
+
)
|
|
129
|
+
return path, digest
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def _format_verification_target(
|
|
133
|
+
target: FinalVerificationTarget,
|
|
134
|
+
diff_stat: str,
|
|
135
|
+
) -> str:
|
|
136
|
+
reports = "\n".join(
|
|
137
|
+
f" - stage {s}: `{rp or '(report_path unrecorded)'}`"
|
|
138
|
+
for s, rp in zip(target.stages, target.reports)
|
|
139
|
+
)
|
|
140
|
+
return (
|
|
141
|
+
f"- **Verification scope:** `{target.scope}`\n"
|
|
142
|
+
f"- **Worktree:** `{target.worktree_path}`\n"
|
|
143
|
+
f"- **Verification base ref:** `{target.base}`\n"
|
|
144
|
+
f"- **Verification head ref:** `{target.head}`\n"
|
|
145
|
+
f"- **Stages under verification:** {target.stages}\n"
|
|
146
|
+
f"- **Source implementation reports:**\n{reports}\n"
|
|
147
|
+
f"- **Verification diff stat:**\n```\n{diff_stat}\n```"
|
|
148
|
+
)
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
def _format_integration(integ) -> str:
|
|
152
|
+
"""stage 자동 통합 결과를 사람이 읽을 한국어 마크다운 블록으로 만든다."""
|
|
153
|
+
def _csv(xs):
|
|
154
|
+
return ", ".join(str(x) for x in xs) if xs else "없음"
|
|
155
|
+
|
|
156
|
+
skipped = "; ".join(f"stage {n}({why})" for n, why in integ.teardown_skipped)
|
|
157
|
+
lines = [
|
|
158
|
+
"- **Stage 자동 통합 결과** (whole-task final-verification):",
|
|
159
|
+
"- **머지된 stage:** " + _csv(integ.merged),
|
|
160
|
+
"- **이미 머지됨:** " + _csv(integ.already_merged),
|
|
161
|
+
"- **정리(teardown)된 stage:** " + _csv(integ.torn_down),
|
|
162
|
+
"- **정리 보류:** " + (skipped or "없음"),
|
|
163
|
+
]
|
|
164
|
+
for warning in integ.warnings:
|
|
165
|
+
lines.append("- **경고:** " + warning)
|
|
166
|
+
return "\n".join(lines)
|
|
@@ -13,7 +13,7 @@
|
|
|
13
13
|
- Primary focus areas (each maps to a deliverable section below):
|
|
14
14
|
- Acceptance-gating — a failure here pushes the verdict toward `blocked` / `conditional-accept`:
|
|
15
15
|
- requirement & acceptance coverage — every must-pass point in the brief's `## Expected Behavior` / `## Preserved Behavior` / `## Expected Outcome` (and the approved plan's requirements) is covered with a cited artifact or raised as an Acceptance Blocker; no silent omissions
|
|
16
|
-
- over-delivery — every surface the merged diff **adds** is traced back to a requirement. Enumerate them: each identifier, module, and configuration entry the diff introduces, searched for its callers across the whole repository (a declaration, its own test, or a commented-out line is not a caller). Record one `addedSurfaceAudit` row per surface with its disposition. `traced` names the brief requirement it serves. `exempt` names one of the legitimate exits and cites it — the approved plan reserves it for a named later stage, or something outside project code calls it (framework entrypoint, implemented interface method, migration hook, published-package API). `over-delivery` is everything else, and it is graded by callers: **no caller anywhere** is an Acceptance Blocker (delete it, or fold an added parameter back into its single call site), while **called but serving no requirement** is a Conditional Acceptance Condition with `blocksReleaseHandoff: false` — the judgement there rests on reading intent, so it travels to the PR body instead of stopping the release. The baseline is the brief, not the approved plan: a surface the plan authorised but no requirement asked for is still over-delivery, and this is the last gate that can see it. **Enforced:** `
|
|
16
|
+
- over-delivery — every surface the merged diff **adds** is traced back to a requirement. Enumerate them: each identifier, module, and configuration entry the diff introduces, searched for its callers across the whole repository (a declaration, its own test, or a commented-out line is not a caller). Record one `addedSurfaceAudit` row per surface with its disposition. `traced` names the brief requirement it serves. `exempt` names one of the legitimate exits and cites it — the approved plan reserves it for a named later stage, or something outside project code calls it (framework entrypoint, implemented interface method, migration hook, published-package API). `over-delivery` is everything else, and it is graded by callers: **no caller anywhere** is an Acceptance Blocker (delete it, or fold an added parameter back into its single call site), while **called but serving no requirement** is a Conditional Acceptance Condition with `blocksReleaseHandoff: false` — the judgement there rests on reading intent, so it travels to the PR body instead of stopping the release. The baseline is the brief, not the approved plan: a surface the plan authorised but no requirement asked for is still over-delivery, and this is the last gate that can see it. **Enforced:** `scripts/okstra_ctl/phases/final_verification/validation.py` `_validate_added_surface_audit` refuses a `traced` row naming no requirement, and an `over-delivery` row whose note does not cite the `AB-NNN` / `CA-NNN` row it became.
|
|
17
17
|
- delivered artifacts match recorded expected values in `reference-expectations` (config files, deployment manifests, other recorded expected states); when reference-expectations are absent, record it as missing information rather than assuming a match
|
|
18
18
|
- test & validation suite pass status — independently re-run the read-only two-tier command set (Tier 1 = brief/approved-plan `validation`, Tier 2 = `project.json` `qaCommands`) and confirm each passes on the verified head, citing exact command + exit code
|
|
19
19
|
- test correctness — delivered tests actually assert the intended behaviour: no gutted/weakened assertions, no tautological or always-passing tests, no tests exercising only mocks; new behaviour has matching coverage. For an `external-interface` / `transformation-mapping` surface specifically, treat a test whose only oracle is a self-authored synthetic fixture (no captured-real-sample provenance) as NOT establishing external correctness — it shows only that the parser agrees with its author's assumed shape, never that the shape matches reality; record that surface's external correctness as a user-owned external advisory gap (a Residual Risk carrying the exact "capture a real sample and confirm" next step, per the Coverage check in the self-review pass below), never as covered — the same non-blocking treatment the External QA advisory policy gives a live external check
|
|
@@ -34,7 +34,7 @@
|
|
|
34
34
|
- **single-stage scope** (`--stage N`): prep verified stage N is `status:done` and its isolated stage worktree exists and is clean. Other stages' state is irrelevant. A single-stage run is a partial verification of one stage, and that is exactly the unit release-handoff ships: an `accepted` verdict here makes the stage PR-eligible, so `release-handoff` is the routing target. It says nothing about any other stage.
|
|
35
35
|
- the lead still captures `git status --short` from the injected worktree to confirm the analysis ran against the delivered work-tree state; an unexpected divergence (dirty tree outside `.okstra/`, missing worktree) is a `tool-failure`, not a silent proceed.
|
|
36
36
|
- Worker verification procedure:
|
|
37
|
-
- **Target confirmation:** analyse the injected target and nothing else. Read `verification-target.md` for the stage/report mapping and the complete diff stat. Prepare fixed that target and `validators/validate-run.py`
|
|
37
|
+
- **Target confirmation:** analyse the injected target and nothing else. Read `verification-target.md` for the stage/report mapping and the complete diff stat. Prepare fixed that target and `phases/final_verification/validation.py` `validate_verification_target_match` (called by `validators/validate-run.py`) re-checks the report against its digest, so the procedure to follow here is simply: if the worktree you can see does not match the injected target, record a `tool-failure` — never reselect a target.
|
|
38
38
|
- **Evidence:** attach file:line, exact command + exit code, log excerpt, or MCP SELECT evidence to every finding. Mark a requirement as covered only when the cited artifact demonstrates it.
|
|
39
39
|
- **Tier 1 and Tier 2 read-only validation:** Tier 1 is the originating brief/approved plan `validation` set. Tier 2 is the `Project QA Commands` section from `okstra model-io project-context --project-root <PROJECT_ROOT> --task-ref <task-ref>`. Do not auto-detect commands from package manifests. A missing tier is `qa-command not configured: <category>`. Before execution, reject commands containing source/lockfile mutation tokens such as `--fix`, `--write`, ` -w`, ` -u`, `--snapshot-update`, `INSTA_UPDATE=<not-no>`, `cargo update`, or `npm install` without `ci`; record the exact denied token. Tier 2 is already screened by prepare, so this check catches a Tier 1 command the brief or plan named.
|
|
40
40
|
- **External QA outcome policy:** continue to attempt every in-scope Tier 3
|
|
@@ -57,7 +57,7 @@
|
|
|
57
57
|
- **Could-not-verify honesty:** use `not-configured`, `env-unavailable`, `rejected`, `gap`, or `blocked` as appropriate. Never convert unavailable evidence into an executed/pass claim.
|
|
58
58
|
- **Source-mutation prohibition:** verification may write only assigned okstra run artifacts. Do not edit source, schema, deployment, lockfile, or configuration files; route detected defects to a later phase.
|
|
59
59
|
- Required deliverable shape (final report, in addition to the standard sections):
|
|
60
|
-
- **Source Implementation Report(s)** (**Enforced:** `validators/validate-run.py`
|
|
60
|
+
- **Source Implementation Report(s)** (**Enforced:** `phases/final_verification/validation.py` `validate_verification_target_match` (called by `validators/validate-run.py`) compares `verificationScope`, `worktreePath`, `implementationBaseRef`, `capturedHeadSha`, and the `stageReports` stage set against the digest-verified `instruction-set/verification-target.md`; a snapshot whose digest no longer checks out is ignored rather than trusted. `verificationScope` in particular gates both stage-group eligibility and release-handoff routing, so it is not the report's to restate): the `VERIFICATION_TARGET` snapshot verbatim — verification scope, worktree path, base/head refs, the list of stages under verification, and one row per stage citing its originating implementation final-report (`report_path` from `consumers.jsonl`; render `(report_path unrecorded)` when absent). Every analyser prompt carries the same compact target identity (`**Verification scope:** / **Worktree:** / **Verification base ref:** / **Verification head ref:** / **Verification target path:** / **Verification target digest:**`) and reads the sidecar on demand for the complete diff stat. A worker that cannot confirm its analysis ran against that worktree's delivered diff MUST record a `tool-failure`.
|
|
61
61
|
- **Verdict vocabulary**: Section 7 (`Final Verdict`) MUST include a `Verdict Token` field whose value is exactly one of `accepted`, `conditional-accept`, or `blocked`. `conditional-accept` requires an explicit, exhaustive list of conditions; ambiguous verdicts ("looks good", "mostly ready") are not allowed. Each condition MUST be recorded as a row in the **Conditional Acceptance Conditions** deliverable (`id` `CA-NNN`, `condition`, `evidenceRequired`, `blocksReleaseHandoff`). `blocksReleaseHandoff` is a gate, not a note: `false` means this condition alone would not stop the release, and a `conditional-accept` whose conditions are all `false` may route to `release-handoff` (the conditions travel into the PR body as unresolved items). Declare `true` for anything that must be settled before release. The validator enforces verdict↔deliverable consistency: `accepted` ⇒ zero acceptance blockers, `blocked` ⇒ at least one, `conditional-accept` ⇒ at least one condition, and a `release-handoff` routing recommendation is allowed only when the verdict is release-ready by `okstra_ctl.release_gate.release_handoff_allowed`. **Any Acceptance Blocker therefore forces the verdict off `accepted` (to `conditional-accept` or `blocked`); the gates below cite this rule instead of restating the arithmetic.**
|
|
62
62
|
- **Added-surface audit** (`finalVerification.addedSurfaceAudit`): the reverse of requirement coverage — one row per surface the merged diff added (`id` `AS-NNN`, `surface` as `path:line` + the added name, `callers`, `requirement`, `disposition`, `note`). Requirement coverage proves every requirement reached the diff; this table proves every addition answers a requirement. An empty array is a claim that the diff added no identifier, module, or configuration entry, not permission to skip the enumeration. Single-stage scope audits its own stage's diff; whole-task audits the merged diff, which is the only place a surface added by one stage and orphaned by another is visible.
|
|
63
63
|
- **Acceptance Blockers block** (under section 4): one row per blocker with `id`, `severity` (`critical` / `major`), evidence (file path, log excerpt, or test output), and the recommended follow-up phase: `error-analysis` for a cause problem, `implementation-option-selection` for a direction problem, or `implementation-planning` for a detailed-plan problem. Empty block is acceptable and preferred — render the single line `- No acceptance blockers found.`
|
|
@@ -65,7 +65,7 @@
|
|
|
65
65
|
- **Validation Evidence**: for every requirement in the originating plan or task brief, cite the artifact (commit SHA, test output, log line, MCP SELECT result) that demonstrates coverage. Paraphrased "verified" claims without an artifact are rejected.
|
|
66
66
|
- **Read-only command log**: any pre-existing test/validation command touched during this run MUST be listed with its exact command line and one honest status — `executed` (ran; carries its exit code) / `advisory` (external Tier 3 did not PASS; carries observed/expected results and remains user-owned) / `env-unavailable` (should run but cannot in this environment — missing replica DB, container, or service; carries the reason, never a faked pass) / `not-configured` (no such qa-command tier) / `rejected` (a mutating/denied token — skipped, carries the denied token). A check that could not run locally is recorded as `env-unavailable` or `advisory` according to the external QA policy — never silently dropped and never reported as `executed` with an invented exit code. Mutating-command prohibition is the shared read-only boundary (see Non-goals); it is not restated per row.
|
|
67
67
|
- **Could-not-verify roll-up (§5.8.9)**: the template mechanically aggregates every not-confirmed check into one scannable list — `gap` requirement-coverage rows, `advisory` / `not-configured` / `env-unavailable` / `rejected` command rows, and `blocked` manual tests. You do not hand-author it, but you MUST give those rows their honest status so nothing unverified hides across sections: a check silently recorded as `executed`/`covered` will not surface in the roll-up. This is okstra's answer to "say what could not be verified this run."
|
|
68
|
-
- **Routing recommendation**:
|
|
68
|
+
- **Routing recommendation**: this phase records the verdict, the blockers, the conditions, and the repair each blocker needs. The lead chooses the next phase from the `## final-verification` section of `prompts/lead/phase-routing.md`, which lists the allowed targets and the shape of `finalVerification.routingRecommendation`. Read that section before writing the field.
|
|
69
69
|
- **Verified-row recording** (both scopes): when the verdict is release-ready, the lead MUST run `okstra handoff record-verified --plan-run-root <plan-run-root> --stage <N> --report-path <final-report data.json path> --data-json <final-report data.json path>` and quote the command + exit code in the report. Pass the record path to both: the Markdown reading copy is rendered on request and does not exist in a finished run (ADR-0014), and the helper normalizes either path to the record anyway. A `whole-task` report clears every stage in its own `stageReports`, so run it **once per those stages** — each run writes that stage's row from this one report. Without those rows the stage is never offered a pull request, and release-handoff opens one PR per stage. The helper checks the latest verification manifest, task/stage identity, report pointer, prepared target, and recorded implementation commit. It records the captured commit and original verdict, including conditional acceptance conditions. A missing target or mismatched commit requires re-verification. Recording happens before final validation; eligibility is granted only after that verification passes validation. **Enforced:** `okstra_ctl.handoff_verification` validates the evidence, and `validators/validate-run.py` `_validate_verified_row_recorded` requires a `verified` row matching this report, captured commit, and verdict.
|
|
70
70
|
- Clarification request policy (phase-specific addendum — shared policy is in `_common-contract.md`):
|
|
71
71
|
- populate `## 1. Clarification Items` only when a blocker hinges on information only the user can supply (deployment intent, intended target environment, business-rule interpretation); use `Blocks=next-phase` for items that gate continuing to release-handoff
|
|
@@ -1,9 +1,12 @@
|
|
|
1
1
|
"""Human-first final-verification view model."""
|
|
2
2
|
from __future__ import annotations
|
|
3
3
|
|
|
4
|
-
from
|
|
5
|
-
|
|
6
|
-
from
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
|
|
6
|
+
from okstra_ctl.release_gate import release_handoff_allowed
|
|
7
|
+
from okstra_ctl.report_html.common import evidence_index
|
|
8
|
+
from okstra_ctl.report_html.models import HumanReportView
|
|
9
|
+
from okstra_ctl.verification_target import read_verification_target
|
|
7
10
|
|
|
8
11
|
# Record fields this template anchors as `id-<row id>` (see
|
|
9
12
|
# `HumanReportView.anchored_fields`); ids elsewhere land in the ledger.
|
|
@@ -16,6 +19,12 @@ ANCHORED_FIELDS = (
|
|
|
16
19
|
)
|
|
17
20
|
|
|
18
21
|
|
|
22
|
+
def verification_scope(project_root: Path, relative: str) -> str:
|
|
23
|
+
"""준비된 검증 대상에서 범위를 읽는다. 파서는 공통 계약이 소유한다."""
|
|
24
|
+
target = read_verification_target(project_root, relative)
|
|
25
|
+
return str((target or {}).get("scope") or "").strip()
|
|
26
|
+
|
|
27
|
+
|
|
19
28
|
def build_final_verification_view(data: dict) -> HumanReportView:
|
|
20
29
|
final = data["finalVerification"]
|
|
21
30
|
verdict_token = data["finalVerdict"]["verdictToken"]
|