okstra 0.205.1 → 0.206.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/commands/lifecycle/install.mjs +1 -0
- package/dist/commands/lifecycle/install.mjs.map +1 -1
- package/docs/architecture.md +6 -6
- package/docs/contributor-change-matrix.md +2 -2
- package/docs/project-structure-overview.md +8 -5
- package/package.json +2 -3
- package/runtime/BUILD.json +2 -2
- package/runtime/prompts/lead/okstra-lead-contract.md +1 -1
- package/runtime/prompts/lead/phase-routing.md +18 -0
- package/runtime/python/okstra_ctl/contract_graph.py +75 -10
- package/runtime/python/okstra_ctl/doctor.py +13 -3
- package/runtime/python/okstra_ctl/implementation_direction.py +9 -8
- package/runtime/python/okstra_ctl/next_phase.py +2 -2
- package/runtime/python/okstra_ctl/paths.py +14 -0
- package/runtime/python/okstra_ctl/phases/__init__.py +4 -0
- package/runtime/python/okstra_ctl/phases/catalog.py +216 -0
- package/runtime/python/okstra_ctl/phases/final_verification/__init__.py +4 -0
- package/runtime/python/okstra_ctl/phases/final_verification/entry.py +166 -0
- package/runtime/{prompts/profiles/final-verification.md → python/okstra_ctl/phases/final_verification/profile.md} +4 -4
- package/runtime/python/okstra_ctl/{report_html/view_models/final_verification.py → phases/final_verification/report.py} +12 -3
- package/{docs/task-process/final-verification.md → runtime/python/okstra_ctl/phases/final_verification/spec.md} +42 -25
- package/runtime/python/okstra_ctl/phases/final_verification/target.py +296 -0
- package/runtime/python/okstra_ctl/phases/final_verification/validation.py +190 -0
- package/runtime/python/okstra_ctl/phases/final_verification/wizard.py +38 -0
- package/runtime/python/okstra_ctl/profile_show.py +7 -1
- package/runtime/python/okstra_ctl/render_final_report.py +3 -2
- package/runtime/python/okstra_ctl/report_assembly.py +3 -3
- package/runtime/python/okstra_ctl/report_html/render.py +3 -2
- package/runtime/python/okstra_ctl/report_html/router.py +9 -33
- package/runtime/python/okstra_ctl/report_template_loader.py +35 -0
- package/runtime/python/okstra_ctl/report_views.py +17 -1
- package/runtime/python/okstra_ctl/run.py +46 -154
- package/runtime/python/okstra_ctl/stage_targets.py +9 -286
- package/runtime/python/okstra_ctl/user_response.py +199 -4
- package/runtime/python/okstra_ctl/verification_target.py +1 -1
- package/runtime/python/okstra_ctl/wizard/state.py +6 -2
- package/runtime/python/okstra_ctl/wizard/steps_plan.py +17 -15
- package/runtime/skills/okstra-user-response/SKILL.md +23 -4
- package/runtime/validators/validate-run.py +22 -185
- package/docs/task-process/README.md +0 -82
- package/docs/task-process/common-flow.md +0 -173
- package/docs/task-process/error-analysis.md +0 -103
- package/docs/task-process/implementation-option-selection.md +0 -70
- package/docs/task-process/implementation-planning.md +0 -180
- package/docs/task-process/implementation.md +0 -226
- package/docs/task-process/release-handoff.md +0 -220
- package/docs/task-process/requirements-discovery.md +0 -113
- /package/runtime/{prompts/profiles/final-verification.json → python/okstra_ctl/phases/final_verification/profile.json} +0 -0
- /package/runtime/{templates/reports/html/tasks → python/okstra_ctl/phases/final_verification/report_assets}/final-verification.template.html +0 -0
- /package/runtime/{templates/reports/md/tasks → python/okstra_ctl/phases/final_verification/report_assets}/final-verification.template.md +0 -0
|
@@ -1,6 +1,19 @@
|
|
|
1
|
-
# final-verification
|
|
1
|
+
# final-verification
|
|
2
2
|
|
|
3
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
120
|
+
### 5. Verdict and routing
|
|
108
121
|
|
|
109
122
|
```mermaid
|
|
110
123
|
flowchart TD
|
|
111
124
|
Verdict{Verdict Token}
|
|
112
|
-
Verdict -->|accepted|
|
|
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
|
-
|
|
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`:
|
|
122
|
-
- `conditional-accept`:
|
|
123
|
-
- `blocked`:
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
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
|
-
|
|
187
|
+
### 8. Verified code
|
|
174
188
|
|
|
175
|
-
-
|
|
176
|
-
-
|
|
177
|
-
-
|
|
178
|
-
-
|
|
179
|
-
-
|
|
180
|
-
-
|
|
181
|
-
-
|
|
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
|
|
@@ -19,6 +19,7 @@ import sys
|
|
|
19
19
|
from pathlib import Path
|
|
20
20
|
|
|
21
21
|
from .paths import find_asset_root
|
|
22
|
+
from .phases.catalog import PhaseAssetError, UnknownTaskType, profile_markdown
|
|
22
23
|
from .role_requirements import RoleProfileError, load_role_profile
|
|
23
24
|
from .run import PrepareError, _expand_profile_includes
|
|
24
25
|
|
|
@@ -44,7 +45,12 @@ def workspace_root(start: Path | None = None) -> Path:
|
|
|
44
45
|
|
|
45
46
|
|
|
46
47
|
def profile_path(root: Path, task_type: str) -> Path:
|
|
47
|
-
|
|
48
|
+
try:
|
|
49
|
+
path = profile_markdown(root, task_type)
|
|
50
|
+
except UnknownTaskType as exc:
|
|
51
|
+
raise PrepareError(f"unknown task-type: {task_type}") from exc
|
|
52
|
+
except PhaseAssetError as exc:
|
|
53
|
+
raise PrepareError(str(exc)) from exc
|
|
48
54
|
if not path.is_file():
|
|
49
55
|
raise PrepareError(f"unknown task-type: {task_type} (no {path})")
|
|
50
56
|
return path
|