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
@@ -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 normalize_direction_selection_identity
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
- coverage = option.get("coverageSummary")
313
- if not isinstance(coverage, Mapping) or coverage.get("coverageVerdict") != "exact":
314
- raise DirectionSelectionError("selected candidate must have exact coverage")
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], option: Mapping[str, Any]) -> None:
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, option)
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(_planning(dict(narrative)))
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(narrative, state_items)
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} 고르는 자리는 이 리포트의 HTML 열람본입니다 — 방향 선택 칸에서 "
287
- "후보를 고르고 Export 한 파일을 "
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,4 @@
1
+ """단계 패키지.
2
+
3
+ 등록표는 ``catalog`` 다. 이 패키지를 import 해도 단계 실행 모듈은 적재되지 않는다.
4
+ """
@@ -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,4 @@
1
+ """final-verification 단계 패키지.
2
+
3
+ 공통 실행 조립부와 다른 단계 패키지를 여기서 가져오지 않는다.
4
+ """
@@ -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:** `validators/validate-run.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.
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` `_validate_verification_target_match` 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.
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` `_validate_verification_target_match` 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`.
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**: `finalVerification.routingRecommendation` is an **object** with exactly two fields — `target`, one value of the enum below, and `rationale`, the sentence tying that choice to the verdict and the blocker list. Free routing prose is not the field; a target named only in the prose does not route the task, because Phase 7 projects `workflow.nextRecommendedPhase` from `target` alone. The eight allowed targets are `release-handoff`, `release-handoff(stage-group)`, `final-verification`, `error-analysis`, `implementation-option-selection`, `implementation-planning`, `implementation`, and `done`. `final-verification` re-runs this phase on the same head and is for exactly one situation: every remaining blocker is an environment or configuration fault whose cause this report already names — a `qaCommands` entry pointing at a path that no longer exists, a missing credential, a stale fixture — so nothing in the code, the plan, or the selected direction is being re-decided. Name the repair in the `rationale`. When any blocker needs a code, plan, or direction change, route to the phase that owns that change instead; routing a defect you have not diagnosed back into this phase re-runs the verification that already failed. Both `release-handoff` forms are allowed ONLY when the verdict is release-ready — `accepted`, or `conditional-accept` with every condition declaring `blocksReleaseHandoff: false`. Either verification scope may route there: release-handoff opens one PR per stage, so a release-ready `single-stage` run is the evidence for that stage's PR, and `release-handoff(stage-group)` is only a scope qualifier that projects onto the same phase. `done` ends the lifecycle here. Enforcement: `schemas/final-report-v2.0.schema.json` rejects a `target` outside the enum, a missing `rationale`, and a string in place of the object; `validators/validate-run.py` rejects a missing `target` and a verdict that is not release-ready routed to either `release-handoff` form (naming the condition ids that block it).
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 ...release_gate import release_handoff_allowed
5
- from ..common import evidence_index
6
- from ..models import HumanReportView
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"]