okstra 0.205.0 → 0.206.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (53) hide show
  1. package/dist/commands/lifecycle/install.mjs +1 -0
  2. package/dist/commands/lifecycle/install.mjs.map +1 -1
  3. package/docs/architecture.md +6 -6
  4. package/docs/contributor-change-matrix.md +2 -2
  5. package/docs/project-structure-overview.md +8 -5
  6. package/package.json +2 -3
  7. package/runtime/BUILD.json +2 -2
  8. package/runtime/prompts/lead/okstra-lead-contract.md +1 -1
  9. package/runtime/prompts/lead/phase-routing.md +18 -0
  10. package/runtime/python/okstra_ctl/contract_graph.py +75 -10
  11. package/runtime/python/okstra_ctl/doctor.py +13 -3
  12. package/runtime/python/okstra_ctl/implementation_direction.py +9 -8
  13. package/runtime/python/okstra_ctl/incremental_carry.py +19 -2
  14. package/runtime/python/okstra_ctl/next_phase.py +2 -2
  15. package/runtime/python/okstra_ctl/paths.py +14 -0
  16. package/runtime/python/okstra_ctl/phases/__init__.py +4 -0
  17. package/runtime/python/okstra_ctl/phases/catalog.py +216 -0
  18. package/runtime/python/okstra_ctl/phases/final_verification/__init__.py +4 -0
  19. package/runtime/python/okstra_ctl/phases/final_verification/entry.py +166 -0
  20. package/runtime/{prompts/profiles/final-verification.md → python/okstra_ctl/phases/final_verification/profile.md} +4 -4
  21. package/runtime/python/okstra_ctl/{report_html/view_models/final_verification.py → phases/final_verification/report.py} +12 -3
  22. package/{docs/task-process/final-verification.md → runtime/python/okstra_ctl/phases/final_verification/spec.md} +42 -25
  23. package/runtime/python/okstra_ctl/phases/final_verification/target.py +296 -0
  24. package/runtime/python/okstra_ctl/phases/final_verification/validation.py +190 -0
  25. package/runtime/python/okstra_ctl/phases/final_verification/wizard.py +38 -0
  26. package/runtime/python/okstra_ctl/plan_items_cli.py +9 -0
  27. package/runtime/python/okstra_ctl/profile_show.py +7 -1
  28. package/runtime/python/okstra_ctl/render_final_report.py +3 -2
  29. package/runtime/python/okstra_ctl/report_assembly.py +3 -3
  30. package/runtime/python/okstra_ctl/report_html/render.py +3 -2
  31. package/runtime/python/okstra_ctl/report_html/router.py +9 -33
  32. package/runtime/python/okstra_ctl/report_template_loader.py +35 -0
  33. package/runtime/python/okstra_ctl/report_views.py +17 -1
  34. package/runtime/python/okstra_ctl/run.py +46 -154
  35. package/runtime/python/okstra_ctl/stage_targets.py +9 -286
  36. package/runtime/python/okstra_ctl/user_response.py +199 -4
  37. package/runtime/python/okstra_ctl/verification_target.py +1 -1
  38. package/runtime/python/okstra_ctl/wizard/state.py +6 -2
  39. package/runtime/python/okstra_ctl/wizard/steps_plan.py +17 -15
  40. package/runtime/skills/okstra-user-response/SKILL.md +23 -4
  41. package/runtime/validators/validate-run.py +59 -187
  42. package/runtime/validators/validate_session_conformance.py +70 -1
  43. package/docs/task-process/README.md +0 -82
  44. package/docs/task-process/common-flow.md +0 -173
  45. package/docs/task-process/error-analysis.md +0 -103
  46. package/docs/task-process/implementation-option-selection.md +0 -70
  47. package/docs/task-process/implementation-planning.md +0 -180
  48. package/docs/task-process/implementation.md +0 -226
  49. package/docs/task-process/release-handoff.md +0 -220
  50. package/docs/task-process/requirements-discovery.md +0 -113
  51. /package/runtime/{prompts/profiles/final-verification.json → python/okstra_ctl/phases/final_verification/profile.json} +0 -0
  52. /package/runtime/{templates/reports/html/tasks → python/okstra_ctl/phases/final_verification/report_assets}/final-verification.template.html +0 -0
  53. /package/runtime/{templates/reports/md/tasks → python/okstra_ctl/phases/final_verification/report_assets}/final-verification.template.md +0 -0
@@ -1,6 +1,19 @@
1
- # final-verification process
1
+ # final-verification
2
2
 
3
- ## Index
3
+ This directory is the canonical home of the final-verification phase. Report field shapes stay in `schemas/final-report-v2.0.schema.json` and `schemas/final-report-v3.0.schema.json` under `finalVerification`. The next phase is chosen in `prompts/lead/phase-routing.md`.
4
+
5
+ ## Guarantees
6
+
7
+ | ID | Guarantee | Enforcement |
8
+ |---|---|---|
9
+ | FV-1 | A report that does not match the prepared verification target is rejected | `scripts/okstra_ctl/phases/final_verification/validation.py::validate_verification_target_match` |
10
+ | FV-2 | A release-handoff route requires a release-ready verdict | `validators/validate-run.py::_refuse_unsuitable_final_verification_route` |
11
+ | FV-3 | The final-verification destination list matches the report schema | `tests/contract/test_phase_catalog.py::test_final_verification_routing_targets_match_the_report_schema` |
12
+ | FV-4 | The phase profile is not also stored under prompts/profiles | `tests/contract/test_phase_catalog.py::test_migrated_profile_is_absent_from_the_legacy_directory` |
13
+
14
+ ## Process
15
+
16
+ ### Index
4
17
 
5
18
  - [1. Purpose](#1-purpose)
6
19
  - [2. okstra-run wizard flow](#2-okstra-run-wizard-flow)
@@ -11,11 +24,11 @@
11
24
  - [7. Forbidden actions](#7-forbidden-actions)
12
25
  - [8. Verified code](#8-verified-code)
13
26
 
14
- ## 1. Purpose
27
+ ### 1. Purpose
15
28
 
16
29
  `final-verification` determines whether the committed diff produced by `implementation` can be finally accepted. Without performing any source edit or follow-up fix, it confirms that the implementation report matches the actual checkout, then records acceptance blockers, residual risk, and a release recommendation.
17
30
 
18
- ## 2. okstra-run wizard flow
31
+ ### 2. okstra-run wizard flow
19
32
 
20
33
  ```mermaid
21
34
  flowchart TD
@@ -36,7 +49,7 @@ Launch selection uses role slots and model refs only: current-session lead is th
36
49
 
37
50
  This phase does not ask for `base-ref` directly. The wizard selects whole-task or a single stage from the approved plan's Stage Map, and prepare resolves `VERIFICATION_TARGET` from the registry / `consumers.jsonl` / git state.
38
51
 
39
- ## 3. entry gate
52
+ ### 3. entry gate
40
53
 
41
54
  ```mermaid
42
55
  sequenceDiagram
@@ -69,7 +82,7 @@ prepare fixes the verification target before worker dispatch. If any of the foll
69
82
 
70
83
  Once started, the lead treats `VERIFICATION_TARGET` as authoritative. It does not re-pick worktree/base/head/stage/source report from the brief.
71
84
 
72
- ## 4. Verification execution flow
85
+ ### 4. Verification execution flow
73
86
 
74
87
  ```mermaid
75
88
  flowchart TD
@@ -104,29 +117,30 @@ flowchart LR
104
117
 
105
118
  The runtime validates the `project.json` `qaCommands` deny-list at the prepare stage for both `implementation` and `final-verification` (`okstra_ctl.run.validate_project_qa_commands`), so a Tier 2 declaration carrying a mutating token stops the run before it starts. Tier 1 comes from the brief or the approved plan and is not covered by that gate — the lead self-checks those commands right before execution.
106
119
 
107
- ## 5. Verdict and routing
120
+ ### 5. Verdict and routing
108
121
 
109
122
  ```mermaid
110
123
  flowchart TD
111
124
  Verdict{Verdict Token}
112
- Verdict -->|accepted| Release[route to release-handoff or done]
125
+ Verdict -->|accepted| Ready[release-ready verdict recorded]
113
126
  Verdict -->|conditional-accept| Conditions[conditions listed exhaustively]
114
- Conditions --> Followup[route by cause, direction, or detailed-plan defect]
115
127
  Verdict -->|blocked| Blockers[acceptance blockers with evidence]
116
- Blockers --> Followup
128
+ Ready --> Routing[lead reads phase-routing.md]
129
+ Conditions --> Routing
130
+ Blockers --> Routing
117
131
  ```
118
132
 
119
133
  `## 7. Final Verdict` must contain exactly one `Verdict Token` field, and its value is one of the following three.
120
134
 
121
- - `accepted`: a state that becomes a release-handoff candidate — whole-task for every stage it covers, single-stage for that one stage's PR
122
- - `conditional-accept`: all conditions must be stated explicitly, and when a condition is a gate it blocks the next phase
123
- - `blocked`: a state that has acceptance blockers and cannot proceed to release-handoff
135
+ - `accepted`: the delivered work meets the acceptance gates
136
+ - `conditional-accept`: every condition is stated
137
+ - `blocked`: at least one acceptance blocker is recorded
124
138
 
125
- A blocked or conditional run routes to the phase that owns the defect. When every remaining blocker is an environment or configuration fault this report already diagnosed - a `qaCommands` entry naming a path that no longer exists, a missing credential, a stale fixture - the target is `final-verification` itself: repair the configuration and re-verify the same head, rather than spending a root-cause phase on a cause that is already known.
139
+ The lead chooses the destination from `prompts/lead/phase-routing.md` using this verdict and the blocker facts. This phase does not choose it.
126
140
 
127
141
  Vague phrasings such as "looks good" or "mostly ready" are not allowed.
128
142
 
129
- ## 6. Deliverables
143
+ ### 6. Deliverables
130
144
 
131
145
  ```mermaid
132
146
  flowchart TD
@@ -155,7 +169,7 @@ The final report requires at least the following.
155
169
  - read-only command log and exit code
156
170
  - next safe phase recommendation
157
171
 
158
- ## 7. Forbidden actions
172
+ ### 7. Forbidden actions
159
173
 
160
174
  ```mermaid
161
175
  flowchart TD
@@ -168,14 +182,17 @@ flowchart TD
168
182
  FV -. forbidden .-> Hide[hide verifier dissent]
169
183
  ```
170
184
 
171
- The stage merge of whole-task mode is a runtime-owned integration step that prepare performs; the matching teardown is a runtime-owned step too, but it runs after the verdict (Phase 7 `teardown-stages`) and only when the verdict clears the work for release. After that, lead verification is read-only. Source edit, follow-up fix, and scope expansion are all forbidden. When a defect is found, it is not fixed within the current run. Cause defects route to `error-analysis`, selected-direction defects route to `implementation-option-selection`, and detailed-plan defects route to `implementation-planning`.
185
+ The stage merge of whole-task mode is a runtime-owned integration step that prepare performs; the matching teardown is a runtime-owned step too, but it runs after the verdict (Phase 7 `teardown-stages`) and only when the verdict clears the work for release. After that, lead verification is read-only. Source edit, follow-up fix, and scope expansion are all forbidden. When a defect is found, it is not fixed within the current run. The lead chooses the next phase from `prompts/lead/phase-routing.md` using the blocker facts this phase recorded.
172
186
 
173
- ## 8. Verified code
187
+ ### 8. Verified code
174
188
 
175
- - [`prompts/profiles/final-verification.md`](../../prompts/profiles/final-verification.md)
176
- - [`templates/reports/final-verification-input.template.md`](../../templates/reports/final-verification-input.template.md)
177
- - [`scripts/okstra_ctl/wizard/`](../../scripts/okstra_ctl/wizard/)
178
- - [`scripts/okstra_ctl/run.py`](../../scripts/okstra_ctl/run.py)
179
- - [`scripts/okstra_ctl/workflow.py`](../../scripts/okstra_ctl/workflow.py)
180
- - [`scripts/okstra_ctl/render.py`](../../scripts/okstra_ctl/render.py)
181
- - [`prompts/lead/okstra-lead-contract.md`](../../prompts/lead/okstra-lead-contract.md)
189
+ - `scripts/okstra_ctl/phases/final_verification/profile.md`
190
+ - `scripts/okstra_ctl/phases/final_verification/profile.json`
191
+ - `scripts/okstra_ctl/phases/final_verification/entry.py`
192
+ - `scripts/okstra_ctl/phases/final_verification/report.py`
193
+ - `scripts/okstra_ctl/phases/final_verification/validation.py` `_validate_added_surface_audit`
194
+ - `prompts/lead/phase-routing.md`
195
+ - `schemas/final-report-v2.0.schema.json` and `schemas/final-report-v3.0.schema.json` (`finalVerification`, `finalVerdict`, `routingRecommendation.target`)
196
+ - `templates/reports/final-verification-input.template.md`
197
+ - `validators/validate-run.py`
198
+ - `scripts/okstra_ctl/release_gate.py`
@@ -0,0 +1,296 @@
1
+ """final-verification 이 검증할 대상을 고른다.
2
+
3
+ 단일 stage 검증은 그 stage 의 worktree 를 읽기만 한다. 전체 작업 검증은 완료된
4
+ stage 전부를 담은 stage 브랜치가 있으면 그것을 쓰고, 없으면 task 브랜치에 통합한
5
+ 결과를 쓴다. 원장·registry·Git·통합은 공통 모듈이 제공하고, 여기서는 어느 것을
6
+ 언제 부를지만 정한다. 전체 과정은 task 키 잠금 하나 안에서 돈다.
7
+ """
8
+ from __future__ import annotations
9
+
10
+ from dataclasses import dataclass, replace
11
+ from pathlib import Path
12
+ from typing import Any
13
+
14
+ from okstra_project.dirs import okstra_home
15
+ from okstra_project.slug import slugify
16
+
17
+ from okstra_ctl import worktree_registry
18
+ from okstra_ctl.consumers import (
19
+ backfill_done_from_carry,
20
+ latest_done_by_stage,
21
+ read_stage_consumer_state,
22
+ )
23
+ from okstra_ctl.locks import worktree_provision_mutex
24
+ from okstra_ctl.plan_run_root import plan_run_root_from_approved_plan
25
+ from okstra_ctl.prepare_error import PrepareError
26
+ from okstra_ctl.stage_integrate import IntegrateResult
27
+ from okstra_ctl.stage_reconcile import auto_reconcile_best_effort
28
+ from okstra_ctl.stage_targets import (
29
+ FinalVerificationTarget,
30
+ StageTargetError,
31
+ _resolve_and_integrate_whole_task_unlocked,
32
+ containing_stage,
33
+ relocate_nested_stage_worktrees,
34
+ )
35
+ from okstra_ctl.worktree import _git, is_dirty_excluding_okstra
36
+
37
+
38
+ @dataclass(frozen=True)
39
+ class FinalVerificationTargetRequest:
40
+ """Semantic inputs needed to acquire one final-verification target."""
41
+
42
+ project_root: Path
43
+ project_id: str
44
+ task_group: str
45
+ task_id: str
46
+ work_category: str
47
+ approved_plan_path: Path
48
+ stage: int | None
49
+ stage_map: tuple[dict[str, Any], ...]
50
+
51
+
52
+ @dataclass(frozen=True)
53
+ class FinalVerificationTargetAcquisition:
54
+ """Resolved target facts returned to the prepare adapter."""
55
+
56
+ target: FinalVerificationTarget
57
+ worktree_branch: str
58
+ integration_result: IntegrateResult | None
59
+ # whole-task 진입이 task worktree 안에서 꺼낸 중첩 stage worktree.
60
+ # `{"stage": N, "from": <old path>, "to": <new path>}` 행.
61
+ relocations: tuple[dict[str, Any], ...] = ()
62
+
63
+
64
+ def _resolve_single_stage_target(
65
+ *,
66
+ requested_stage: int,
67
+ done_rows: list[dict[str, Any]],
68
+ stage_base: str,
69
+ stage_worktree_path: str,
70
+ stage_head: str,
71
+ stage_dirty: bool,
72
+ ) -> FinalVerificationTarget:
73
+ """Resolve single-stage final-verification target, enforcing all gates."""
74
+ n = requested_stage
75
+ done_by_stage = latest_done_by_stage(done_rows)
76
+ if n not in done_by_stage:
77
+ raise StageTargetError(
78
+ f"final-verification(single-stage): stage {n} not done — "
79
+ f"run implementation --stage {n} first"
80
+ )
81
+ if not stage_worktree_path:
82
+ raise StageTargetError(
83
+ f"final-verification(single-stage): stage worktree not found for "
84
+ f"stage {n} (torn down?) — use whole-task mode (--stage auto)"
85
+ )
86
+ if stage_dirty:
87
+ raise StageTargetError(
88
+ "final-verification: worktree has uncommitted source changes "
89
+ "(outside .okstra/) — commit or stash before verifying"
90
+ )
91
+ return FinalVerificationTarget(
92
+ scope="single-stage",
93
+ base=stage_base,
94
+ head=stage_head,
95
+ worktree_path=stage_worktree_path,
96
+ stages=[n],
97
+ reports=[done_by_stage[n].get("report_path", "")],
98
+ )
99
+
100
+
101
+ def _read_final_verification_done_rows(
102
+ request: FinalVerificationTargetRequest,
103
+ registry_coordinates: tuple[str, str, str],
104
+ ) -> list[dict[str, Any]]:
105
+ plan_run_root = plan_run_root_from_approved_plan(request.approved_plan_path)
106
+ backfill_done_from_carry(plan_run_root)
107
+ project_id, task_group, task_id = registry_coordinates
108
+ auto_reconcile_best_effort(
109
+ replace(
110
+ request,
111
+ project_id=project_id,
112
+ task_group=task_group,
113
+ task_id=task_id,
114
+ ),
115
+ plan_run_root,
116
+ )
117
+ return read_stage_consumer_state(plan_run_root).done_rows
118
+
119
+
120
+ def _final_verification_registry_coordinates(
121
+ request: FinalVerificationTargetRequest,
122
+ ) -> tuple[str, str, str]:
123
+
124
+ def segment(value: str) -> str:
125
+ return slugify(value) or "_"
126
+
127
+ return (
128
+ segment(request.project_id),
129
+ segment(request.task_group),
130
+ segment(request.task_id),
131
+ )
132
+
133
+
134
+ def _acquire_single_stage_target(
135
+ request: FinalVerificationTargetRequest,
136
+ done_rows: list[dict[str, Any]],
137
+ registry_coordinates: tuple[str, str, str],
138
+ ) -> FinalVerificationTargetAcquisition:
139
+ stage = request.stage
140
+ assert stage is not None
141
+ row = worktree_registry.get_stage_row(*registry_coordinates, stage)
142
+ worktree_path = (row or {}).get("worktree_path", "")
143
+ head = ""
144
+ if worktree_path and Path(worktree_path).is_dir():
145
+ head_result = _git(worktree_path, "rev-parse", "HEAD")
146
+ if head_result.returncode == 0:
147
+ head = head_result.stdout.strip()
148
+ if not head:
149
+ worktree_path = ""
150
+ target = _resolve_single_stage_target(
151
+ requested_stage=stage,
152
+ done_rows=done_rows,
153
+ stage_base=(row or {}).get("base_ref", ""),
154
+ stage_worktree_path=worktree_path,
155
+ stage_head=head,
156
+ stage_dirty=(
157
+ is_dirty_excluding_okstra(worktree_path) if worktree_path else False
158
+ ),
159
+ )
160
+ return FinalVerificationTargetAcquisition(
161
+ target=target,
162
+ worktree_branch=(row or {}).get("branch", ""),
163
+ integration_result=None,
164
+ )
165
+
166
+
167
+ def _acquire_tip_stage_target(
168
+ request: FinalVerificationTargetRequest,
169
+ done_rows: list[dict[str, Any]],
170
+ registry_coordinates: tuple[str, str, str],
171
+ ) -> FinalVerificationTargetAcquisition | None:
172
+ """전체를 담은 단일 stage 브랜치가 있으면 그것을 whole-task 검증 대상으로
173
+ 삼는다 — 머지 없이. 없거나 그 워크트리가 쓸 수 없으면 None 을 돌려 기존
174
+ task 브랜치 통합 경로로 넘긴다."""
175
+ done = latest_done_by_stage(done_rows)
176
+ planned = [int(s["stage_number"]) for s in request.stage_map]
177
+ if any(n not in done for n in planned):
178
+ return None # 미완 stage 의 거부 메시지는 기존 경로가 낸다
179
+ tip = containing_stage(request.project_root,
180
+ {n: done[n] for n in planned})
181
+ if tip is None:
182
+ return None
183
+ row = worktree_registry.get_stage_row(*registry_coordinates, tip) or {}
184
+ worktree_path = row.get("worktree_path", "")
185
+ if not worktree_path or not Path(worktree_path).is_dir():
186
+ return None
187
+ head_result = _git(worktree_path, "rev-parse", "HEAD")
188
+ if head_result.returncode != 0:
189
+ return None
190
+ head = head_result.stdout.strip()
191
+ if head != done[tip].get("head_commit", ""):
192
+ # 브랜치 tip 이 done 기록과 갈라져 있다. 검증 대상이 무엇인지 모호하므로
193
+ # 판정을 기존 경로로 넘긴다.
194
+ return None
195
+ if is_dirty_excluding_okstra(worktree_path):
196
+ raise StageTargetError(
197
+ "final-verification: worktree has uncommitted source changes "
198
+ "(outside .okstra/) — commit or stash before verifying"
199
+ )
200
+ anchor_base = worktree_registry.get_implementation_base(
201
+ *registry_coordinates) or ""
202
+ stages = sorted(planned)
203
+ return FinalVerificationTargetAcquisition(
204
+ target=FinalVerificationTarget(
205
+ scope="whole-task",
206
+ base=anchor_base,
207
+ head=head,
208
+ worktree_path=worktree_path,
209
+ stages=stages,
210
+ reports=[done[n].get("report_path", "") for n in stages],
211
+ ),
212
+ relocations=(),
213
+ worktree_branch=row.get("branch", ""),
214
+ integration_result=None,
215
+ )
216
+
217
+
218
+ def _acquire_whole_task_target(
219
+ request: FinalVerificationTargetRequest,
220
+ done_rows: list[dict[str, Any]],
221
+ registry_coordinates: tuple[str, str, str],
222
+ ) -> FinalVerificationTargetAcquisition:
223
+ tip = _acquire_tip_stage_target(request, done_rows, registry_coordinates)
224
+ if tip is not None:
225
+ return tip
226
+
227
+ project_id, task_group, task_id = registry_coordinates
228
+ entry = worktree_registry.lookup(project_id, task_group, task_id)
229
+ worktree_path = (
230
+ entry.worktree_path if entry is not None else str(request.project_root)
231
+ )
232
+ relocations = (
233
+ relocate_nested_stage_worktrees(
234
+ project_id, task_group, task_id, worktree_path,
235
+ stages=[int(row["stage_number"]) for row in request.stage_map],
236
+ )
237
+ if entry is not None
238
+ else []
239
+ )
240
+ whole = _resolve_and_integrate_whole_task_unlocked(
241
+ project_id=project_id,
242
+ task_group=task_group,
243
+ task_id=task_id,
244
+ task_worktree_path=worktree_path,
245
+ stage_map=list(request.stage_map),
246
+ done_rows=done_rows,
247
+ anchor_base=(
248
+ worktree_registry.get_implementation_base(
249
+ project_id, task_group, task_id
250
+ )
251
+ or ""
252
+ ),
253
+ # 정리는 판정 뒤로 미룬다(Phase 7 `teardown-stages`). 되돌릴 수 없는 정리를
254
+ # 판정 앞에 두면, 재작업이 가장 필요한 blocked 판정에서 stage 작업물이 이미
255
+ # 사라져 있다. 여기서는 통합만 하고 worktree/registry 키는 남긴다.
256
+ teardown=False,
257
+ )
258
+ return FinalVerificationTargetAcquisition(
259
+ target=whole["target"],
260
+ relocations=tuple(relocations),
261
+ worktree_branch=entry.branch if entry is not None else "",
262
+ integration_result=whole["integrate_result"],
263
+ )
264
+
265
+
266
+ def acquire_final_verification_target(
267
+ request: FinalVerificationTargetRequest,
268
+ ) -> FinalVerificationTargetAcquisition:
269
+ """Acquire a stable final-verification target behind one task-key lock."""
270
+ try:
271
+ with worktree_provision_mutex(
272
+ okstra_home(),
273
+ request.project_id,
274
+ slugify(request.task_group),
275
+ slugify(request.task_id),
276
+ ):
277
+ registry_coordinates = _final_verification_registry_coordinates(request)
278
+ done_rows = _read_final_verification_done_rows(
279
+ request,
280
+ registry_coordinates,
281
+ )
282
+ if request.stage is not None:
283
+ return _acquire_single_stage_target(
284
+ request,
285
+ done_rows,
286
+ registry_coordinates,
287
+ )
288
+ return _acquire_whole_task_target(
289
+ request,
290
+ done_rows,
291
+ registry_coordinates,
292
+ )
293
+ except PrepareError:
294
+ raise
295
+ except (OSError, RuntimeError, StageTargetError, ValueError) as exc:
296
+ raise PrepareError(str(exc)) from exc
@@ -0,0 +1,190 @@
1
+ """final-verification 이 소유하는 보고서 내용 판정.
2
+
3
+ 추가 표면·판정·검증 범위의 일관성과, 보고서가 준비된 검증 대상을 그대로
4
+ 비추는지를 본다. 이동 적합성은 공통 검증이 이어서 본다. 이 모듈은 공통 실행 조립부를
5
+ 가져오지 않는다.
6
+ """
7
+ from __future__ import annotations
8
+
9
+ import re
10
+ from collections.abc import Mapping
11
+ from pathlib import Path
12
+
13
+ from okstra_ctl.verification_target import TARGET_FIELD_RES, read_verification_target
14
+
15
+ _ADDED_SURFACE_NO_CALLER_RE = re.compile(r"^\s*none\b", re.IGNORECASE)
16
+
17
+
18
+ def validate_final_verification_content(data: dict, failures: list[str]) -> None:
19
+ """판정·차단 항목·검증 범위가 서로 맞는지 본다. 다른 작업 유형은 건너뛴다."""
20
+ if (data.get("header") or {}).get("taskType") != "final-verification":
21
+ return
22
+ _validate_added_surface_audit(data, failures)
23
+ _validate_verdict_consistency(data, failures)
24
+ _validate_verification_scope(data, failures)
25
+
26
+
27
+ def _validate_added_surface_audit(data: dict, failures: list[str]) -> None:
28
+ """Every surface the diff added is traced to a requirement, exempted, or paid for.
29
+
30
+ The coverage table proves each requirement reached the diff. Nothing proved
31
+ the reverse — that each thing the diff added answers a requirement — so work
32
+ nobody asked for passed every gate. This check reads the reverse table and
33
+ refuses a row that calls itself over-delivery without the blocker or
34
+ condition it became: a caller-less surface is an acceptance blocker, and a
35
+ surface with callers but no requirement is a conditional-acceptance
36
+ condition (ADR-0009 grades the two differently on purpose).
37
+ """
38
+ fv = data.get("finalVerification")
39
+ if not isinstance(fv, Mapping):
40
+ return
41
+ rows = fv.get("addedSurfaceAudit")
42
+ if not isinstance(rows, list):
43
+ return
44
+ blocker_ids = _row_ids(fv.get("acceptanceBlockers"))
45
+ condition_ids = _row_ids(
46
+ (data.get("finalVerdict") or {}).get("conditionalAcceptanceConditions")
47
+ )
48
+ for row in rows:
49
+ if isinstance(row, Mapping):
50
+ failures.extend(_added_surface_row_failures(row, blocker_ids, condition_ids))
51
+
52
+
53
+ def _row_ids(rows: object) -> set[str]:
54
+ if not isinstance(rows, list):
55
+ return set()
56
+ return {str(row.get("id")) for row in rows if isinstance(row, Mapping)}
57
+
58
+
59
+ def _added_surface_row_failures(
60
+ row: Mapping,
61
+ blocker_ids: set[str],
62
+ condition_ids: set[str],
63
+ ) -> list[str]:
64
+ row_id = str(row.get("id") or "<id 없음>")
65
+ disposition = str(row.get("disposition") or "")
66
+ note = str(row.get("note") or "")
67
+ if disposition == "traced" and not str(row.get("requirement") or "").strip():
68
+ return [
69
+ f"final-verification: addedSurfaceAudit {row_id} is `traced` but "
70
+ "names no requirement — a surface is traced to something the "
71
+ "brief asked for, or it is not traced."
72
+ ]
73
+ if disposition != "over-delivery":
74
+ return []
75
+ caller_less = bool(_ADDED_SURFACE_NO_CALLER_RE.match(str(row.get("callers") or "")))
76
+ expected, known = ("AB", blocker_ids) if caller_less else ("CA", condition_ids)
77
+ cited = set(re.findall(rf"\b{expected}-\d{{3,}}\b", note))
78
+ if not cited:
79
+ detail = (
80
+ "a caller-less surface is an acceptance blocker"
81
+ if caller_less
82
+ else "a surface with callers but no requirement is a "
83
+ "conditional-acceptance condition"
84
+ )
85
+ callers = "none" if caller_less else "recorded"
86
+ return [
87
+ f"final-verification: addedSurfaceAudit {row_id} is "
88
+ f"`over-delivery` with callers {callers}, so its note MUST cite "
89
+ f"the `{expected}-NNN` row it became — {detail}."
90
+ ]
91
+ missing = sorted(cited - known)
92
+ if missing:
93
+ return [
94
+ f"final-verification: addedSurfaceAudit {row_id} cites "
95
+ f"{missing}, which the report does not carry."
96
+ ]
97
+ return []
98
+
99
+
100
+ def _validate_verdict_consistency(data: dict, failures: list[str]) -> None:
101
+ verdict = data.get("finalVerdict") or {}
102
+ token = (verdict.get("verdictToken") or "").strip().lower()
103
+ blockers = (data.get("finalVerification") or {}).get("acceptanceBlockers") or []
104
+ conditions = verdict.get("conditionalAcceptanceConditions") or []
105
+ if token == "accepted" and blockers:
106
+ failures.append(
107
+ "final-verification: verdict `accepted` but acceptanceBlockers is "
108
+ "non-empty — an accepted verdict must have zero blockers."
109
+ )
110
+ if token == "blocked" and not blockers:
111
+ failures.append(
112
+ "final-verification: verdict `blocked` but acceptanceBlockers is "
113
+ "empty — a blocked verdict must list at least one blocker."
114
+ )
115
+ if token == "conditional-accept" and not conditions:
116
+ failures.append(
117
+ "final-verification: verdict `conditional-accept` but "
118
+ "conditionalAcceptanceConditions is empty — list every condition."
119
+ )
120
+
121
+
122
+ def _validate_verification_scope(data: dict, failures: list[str]) -> None:
123
+ scope = data.get("verificationScope", "whole-task")
124
+ if scope not in ("whole-task", "single-stage"):
125
+ failures.append(
126
+ f"final-verification: verificationScope must be `whole-task` or "
127
+ f"`single-stage`, got {scope!r}."
128
+ )
129
+
130
+
131
+ def validate_verification_target_match(
132
+ data: dict,
133
+ run_manifest: dict,
134
+ project_root: Path,
135
+ failures: list[str],
136
+ ) -> None:
137
+ """The verification report must mirror the target it was prepared against.
138
+
139
+ `verificationScope`, the worktree, and the base/head refs were entirely
140
+ self-declared: the schema required the fields to exist but nothing compared
141
+ them to the digest-verified snapshot written at prep time. That matters
142
+ because both `handoff.compute_eligibility` and the `release-handoff`
143
+ routing check read `verificationScope` — a single-stage run that writes
144
+ `whole-task` passes both, and an `accepted` verdict can be rendered against
145
+ a worktree or head nobody verified.
146
+ """
147
+ # 스냅샷의 다이제스트 규칙과 파싱은 `okstra_ctl.verification_target` 한 곳이
148
+ # 쥔다. 조립도 같은 파일을 읽어 `verificationScope` 를 기록한다.
149
+ # 최상위 `verificationTargetPath` 가 run 매니페스트의 실물 키다(render.py).
150
+ # `instructionSet` 블록은 active-run-context 의 것이라 여기서 읽으면 검사가
151
+ # 통째로 건너뛰어졌다(실측 2026-09-06, dev-10626 final-verification 001).
152
+ relative = str(run_manifest.get("verificationTargetPath") or "").strip()
153
+ if not relative:
154
+ return
155
+ target = read_verification_target(project_root, relative)
156
+ if target is None:
157
+ return
158
+
159
+ source = (data.get("finalVerification") or {}).get("sourceImplementationReport") or {}
160
+ declared = {
161
+ "scope": str(data.get("verificationScope") or "").strip(),
162
+ "worktree": str(source.get("worktreePath") or "").strip(),
163
+ "base": str(source.get("implementationBaseRef") or "").strip(),
164
+ "head": str(source.get("capturedHeadSha") or "").strip(),
165
+ }
166
+ for key, expected in ((k, target[k]) for k in TARGET_FIELD_RES):
167
+ actual = declared[key]
168
+ if expected and actual and actual != expected:
169
+ failures.append(
170
+ f"final-verification report declares {key} `{actual}` but the "
171
+ f"prepared verification target says `{expected}` "
172
+ f"(`{relative}`). The report must mirror the target it was "
173
+ "prepared against — `verificationScope` in particular gates "
174
+ "both stage-group eligibility and release-handoff routing, so "
175
+ "a self-declared value lets a run be judged as something it "
176
+ "was not."
177
+ )
178
+
179
+ declared_stages = {
180
+ row.get("stage")
181
+ for row in ((data.get("finalVerification") or {}).get("stageReports") or [])
182
+ if isinstance(row, dict) and isinstance(row.get("stage"), int)
183
+ }
184
+ if target["stages"] and declared_stages and declared_stages != target["stages"]:
185
+ failures.append(
186
+ f"final-verification report covers stages {sorted(declared_stages)} "
187
+ f"but the prepared target names {sorted(target['stages'])} "
188
+ f"(`{relative}`). A verdict must not be rendered for a stage set "
189
+ "nobody prepared evidence for."
190
+ )
@@ -0,0 +1,38 @@
1
+ """final-verification 이 위저드의 stage 선택에 거는 조건."""
2
+ from __future__ import annotations
3
+
4
+
5
+ def whole_task_verification_allowed(
6
+ *,
7
+ stage_numbers: tuple[int, ...],
8
+ done_stages: set[int],
9
+ ) -> bool:
10
+ """승인된 계획의 stage 가 전부 done 일 때만 전체 작업 검증을 고를 수 있다."""
11
+ if not stage_numbers:
12
+ return False
13
+ return all(number in done_stages for number in stage_numbers)
14
+
15
+
16
+ class StageAnswerError(ValueError):
17
+ """stage 선택 답이 final-verification 이 받을 수 있는 값이 아니다."""
18
+
19
+
20
+ def validate_stage_answer(
21
+ answer: str, *, whole_task_token: str, whole_task_allowed: bool
22
+ ) -> str:
23
+ """전체 작업 토큰 또는 stage 번호 하나만 받는다. 여러 개나 auto 는 받지 않는다."""
24
+ if not answer:
25
+ raise StageAnswerError("value required")
26
+ if answer == whole_task_token:
27
+ if not whole_task_allowed:
28
+ raise StageAnswerError(
29
+ "whole-task verification requires final-verification "
30
+ "with all stages done")
31
+ return answer
32
+ try:
33
+ int(answer)
34
+ except ValueError:
35
+ raise StageAnswerError(
36
+ f"answer must be whole-task or a stage number, got {answer!r}"
37
+ ) from None
38
+ return answer
@@ -1579,6 +1579,13 @@ def _reject_uncompleted_round_loss(
1579
1579
 
1580
1580
  이력이 없는 데이터(final-report `--data` 경로)는 대상이 아니다 — 라운드
1581
1581
  스냅샷을 갖는 것은 convergence 소유 상태 파일뿐이다.
1582
+
1583
+ 직전 run 에서 이월된 표도 대상이 아니다. 그 행의 라운드 번호는 **그 run 의**
1584
+ 번호라 이번 run 의 이력에는 없고, 그래서 열린 라운드로 읽혔다 — 안내하는
1585
+ `complete-round --round 2` 는 이번 run 에 존재하지도 않는 라운드라 실행할 수
1586
+ 없었다(2026-09-24, jobs implementation-planning 003: seed --prior-state 가
1587
+ 이월한 P-Dep·P-Var 5건이 round 1 적용을 막음). 그 표의 이력은 직전 run 의
1588
+ 상태 파일이 갖고 있다.
1582
1589
  """
1583
1590
  if not isinstance(history, list):
1584
1591
  return
@@ -1594,6 +1601,8 @@ def _reject_uncompleted_round_loss(
1594
1601
  for row in item.get("verdicts") or []:
1595
1602
  if not isinstance(row, Mapping):
1596
1603
  continue
1604
+ if str(row.get("carriedForwardFromSeq") or "").strip():
1605
+ continue
1597
1606
  recorded_round = row.get("round")
1598
1607
  if (
1599
1608
  isinstance(recorded_round, int)