okstra 0.176.0 → 0.177.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/docs/task-process/final-verification.md +5 -3
- package/package.json +1 -1
- package/runtime/BUILD.json +2 -2
- package/runtime/agents/workers/report-writer-worker.md +1 -1
- package/runtime/bin/okstra-provider-exec.py +2 -7
- package/runtime/prompts/coding-preflight/overview.md +2 -1
- package/runtime/prompts/lead/convergence.md +11 -6
- package/runtime/prompts/lead/okstra-lead-contract.md +2 -2
- package/runtime/prompts/lead/plan-body-verification.md +4 -2
- package/runtime/prompts/lead/report-writer.md +8 -4
- package/runtime/prompts/profiles/_common-contract.md +1 -1
- package/runtime/prompts/profiles/_implementation-verifier.md +1 -1
- package/runtime/prompts/profiles/final-verification.md +11 -9
- package/runtime/prompts/profiles/improvement-discovery.md +1 -1
- package/runtime/prompts/profiles/release-handoff.md +7 -6
- package/runtime/prompts/wizard/prompts.ko.json +2 -2
- package/runtime/python/okstra_ctl/agent_prompt_cli.py +2 -2
- package/runtime/python/okstra_ctl/dispatch_core.py +50 -6
- package/runtime/python/okstra_ctl/dispatch_state.py +28 -4
- package/runtime/python/okstra_ctl/execution_mutation_audit.py +49 -3
- package/runtime/python/okstra_ctl/handoff.py +27 -14
- package/runtime/python/okstra_ctl/release_gate.py +56 -0
- package/runtime/python/okstra_ctl/report_contract.py +1 -0
- package/runtime/python/okstra_ctl/report_finalize.py +54 -0
- package/runtime/python/okstra_ctl/report_html/view_models/final_verification.py +4 -1
- package/runtime/python/okstra_ctl/run.py +31 -16
- package/runtime/python/okstra_ctl/stage_targets.py +73 -1
- package/runtime/python/okstra_ctl/wizard.py +19 -10
- package/runtime/python/okstra_ctl/worker_liveness.py +3 -1
- package/runtime/python/okstra_ctl/wrapper_status.py +15 -0
- package/runtime/schemas/final-report-v2.0.schema.json +55 -18
- package/runtime/templates/reports/html/i18n/en.json +4 -0
- package/runtime/templates/reports/html/i18n/ko.json +4 -0
- package/runtime/templates/reports/html/tasks/final-verification.template.html +7 -1
- package/runtime/validators/validate-run.py +98 -12
- package/runtime/validators/validate_analysis_report.py +2 -5
|
@@ -767,7 +767,10 @@ def _acquire_whole_task_target(
|
|
|
767
767
|
)
|
|
768
768
|
or ""
|
|
769
769
|
),
|
|
770
|
-
teardown
|
|
770
|
+
# 정리는 판정 뒤로 미룬다(Phase 7 `teardown-stages`). 되돌릴 수 없는 정리를
|
|
771
|
+
# 판정 앞에 두면, 재작업이 가장 필요한 blocked 판정에서 stage 작업물이 이미
|
|
772
|
+
# 사라져 있다. 여기서는 통합만 하고 worktree/registry 키는 남긴다.
|
|
773
|
+
teardown=False,
|
|
771
774
|
)
|
|
772
775
|
return FinalVerificationTargetAcquisition(
|
|
773
776
|
target=whole["target"],
|
|
@@ -776,6 +779,75 @@ def _acquire_whole_task_target(
|
|
|
776
779
|
)
|
|
777
780
|
|
|
778
781
|
|
|
782
|
+
def integrate_and_teardown_whole_task(
|
|
783
|
+
*, project_root: Path, task_group: str, task_id: str,
|
|
784
|
+
) -> dict[str, Any]:
|
|
785
|
+
"""판정이 끝난 whole-task 검증의 stage worktree 와 registry 키를 회수한다.
|
|
786
|
+
|
|
787
|
+
진입은 통합만 하고 정리를 남겨 두므로(`_resolve_whole_task_acquisition`), 정리는
|
|
788
|
+
판정 뒤인 Phase 7 에서 여기로 들어온다. 통합은 이미 끝나 있어 Phase A 는 전부
|
|
789
|
+
`already_merged` 로 지나가고 Phase B 만 실제 일을 한다. 두 번 불려도 결과는 같다 —
|
|
790
|
+
사라진 worktree 는 건너뛰고, 미커밋 변경이 남은 stage 트리는 보존한다.
|
|
791
|
+
|
|
792
|
+
stage_map 은 done 행에서 만든다. 정리 대상은 완료된 stage 뿐이고, 계획에만 있고
|
|
793
|
+
완료되지 않은 stage 는 Phase A 가 어차피 건너뛰기 때문이다.
|
|
794
|
+
"""
|
|
795
|
+
from json import loads as _loads
|
|
796
|
+
|
|
797
|
+
from okstra_project.dirs import okstra_home, project_json_path
|
|
798
|
+
|
|
799
|
+
from . import consumers, worktree_registry
|
|
800
|
+
from .locks import worktree_provision_mutex
|
|
801
|
+
from .paths import task_runs_dir
|
|
802
|
+
from .stage_integrate import integrate_stages
|
|
803
|
+
|
|
804
|
+
try:
|
|
805
|
+
project_id = _loads(
|
|
806
|
+
project_json_path(project_root).read_text(encoding="utf-8")
|
|
807
|
+
).get("projectId", "")
|
|
808
|
+
except (OSError, ValueError):
|
|
809
|
+
project_id = ""
|
|
810
|
+
if not project_id:
|
|
811
|
+
return {"skipped": "project.json declares no projectId"}
|
|
812
|
+
|
|
813
|
+
plan_run_root = task_runs_dir(
|
|
814
|
+
project_root, task_group, task_id
|
|
815
|
+
) / "implementation-planning"
|
|
816
|
+
done_rows = [
|
|
817
|
+
row for row in consumers.read_consumers(plan_run_root)
|
|
818
|
+
if row.get("status") == "done"
|
|
819
|
+
]
|
|
820
|
+
if not done_rows:
|
|
821
|
+
return {"skipped": "no done stage rows to reclaim"}
|
|
822
|
+
stage_map = [
|
|
823
|
+
{"stage_number": stage}
|
|
824
|
+
for stage in sorted(consumers.latest_done_by_stage(done_rows))
|
|
825
|
+
]
|
|
826
|
+
|
|
827
|
+
entry = worktree_registry.lookup(project_id, task_group, task_id)
|
|
828
|
+
if entry is None:
|
|
829
|
+
return {"skipped": "task worktree is no longer registered"}
|
|
830
|
+
|
|
831
|
+
with worktree_provision_mutex(okstra_home(), project_id, task_group, task_id):
|
|
832
|
+
result = integrate_stages(
|
|
833
|
+
project_id=project_id,
|
|
834
|
+
task_group=task_group,
|
|
835
|
+
task_id=task_id,
|
|
836
|
+
task_worktree_path=entry.worktree_path,
|
|
837
|
+
stage_map=stage_map,
|
|
838
|
+
done_rows=done_rows,
|
|
839
|
+
teardown=True,
|
|
840
|
+
)
|
|
841
|
+
return {
|
|
842
|
+
"tornDown": result.torn_down,
|
|
843
|
+
"teardownSkipped": [
|
|
844
|
+
{"stage": stage, "reason": reason}
|
|
845
|
+
for stage, reason in result.teardown_skipped
|
|
846
|
+
],
|
|
847
|
+
"warnings": result.warnings,
|
|
848
|
+
}
|
|
849
|
+
|
|
850
|
+
|
|
779
851
|
def acquire_final_verification_target(
|
|
780
852
|
request: FinalVerificationTargetRequest,
|
|
781
853
|
) -> FinalVerificationTargetAcquisition:
|
|
@@ -1406,26 +1406,35 @@ def _role_add_prompt(
|
|
|
1406
1406
|
state: WizardState,
|
|
1407
1407
|
requirement: RoleRequirement,
|
|
1408
1408
|
) -> Prompt:
|
|
1409
|
-
"""min=0 선택 역할:
|
|
1409
|
+
"""min=0 선택 역할: 프로필의 적정 수량이 기본이고, 1..max 를 이 스텝에서 고른다.
|
|
1410
|
+
|
|
1411
|
+
적정이 0 이면 기본은 추가 안 함이다. 0 보다 큰 적정을 선언한 역할만 기본이 열린
|
|
1412
|
+
상태로 뜬다 — 어느 쪽이든 사용자는 이 화면에서 바꿀 수 있다.
|
|
1413
|
+
"""
|
|
1410
1414
|
prompt = _p(
|
|
1411
1415
|
state.workspace_root,
|
|
1412
1416
|
"role_add",
|
|
1413
1417
|
role=requirement.role,
|
|
1414
1418
|
maximum=str(requirement.max_count),
|
|
1415
1419
|
)
|
|
1420
|
+
suffix = prompt["options"].get("default_suffix", "")
|
|
1421
|
+
|
|
1422
|
+
def _default_suffix(count: int) -> str:
|
|
1423
|
+
return suffix if count == requirement.recommended_count else ""
|
|
1424
|
+
|
|
1416
1425
|
options = [
|
|
1417
1426
|
_opt(
|
|
1418
1427
|
"0",
|
|
1419
|
-
prompt["options"]["skip"].format(
|
|
1420
|
-
default_suffix=prompt["options"].get("default_suffix", ""),
|
|
1421
|
-
),
|
|
1428
|
+
prompt["options"]["skip"].format(default_suffix=_default_suffix(0)),
|
|
1422
1429
|
),
|
|
1423
1430
|
]
|
|
1424
1431
|
for count in range(1, requirement.max_count + 1):
|
|
1425
1432
|
options.append(
|
|
1426
1433
|
_opt(
|
|
1427
1434
|
str(count),
|
|
1428
|
-
prompt["options"]["add"].format(
|
|
1435
|
+
prompt["options"]["add"].format(
|
|
1436
|
+
count=count, default_suffix=_default_suffix(count),
|
|
1437
|
+
),
|
|
1429
1438
|
)
|
|
1430
1439
|
)
|
|
1431
1440
|
return Prompt(
|
|
@@ -3865,10 +3874,10 @@ def _handoff_eligibility(state: WizardState) -> list:
|
|
|
3865
3874
|
return compute_eligibility(stage_map, rows)
|
|
3866
3875
|
|
|
3867
3876
|
|
|
3868
|
-
def
|
|
3877
|
+
def _latest_whole_task_fv_release_ready(state: WizardState) -> str:
|
|
3869
3878
|
"""accepted whole-task final-verification 보고서 경로 — handoff 모듈 SSOT 위임."""
|
|
3870
|
-
from okstra_ctl.handoff import
|
|
3871
|
-
return
|
|
3879
|
+
from okstra_ctl.handoff import latest_whole_task_fv_release_ready
|
|
3880
|
+
return latest_whole_task_fv_release_ready(
|
|
3872
3881
|
state.project_root, state.project_id, state.task_group, state.task_id)
|
|
3873
3882
|
|
|
3874
3883
|
|
|
@@ -3876,7 +3885,7 @@ def _build_handoff_stage_pick(state: WizardState) -> Prompt:
|
|
|
3876
3885
|
elig = _handoff_eligibility(state)
|
|
3877
3886
|
eligible = [e for e in elig if e["eligible"]]
|
|
3878
3887
|
blocked = [e for e in elig if not e["eligible"]]
|
|
3879
|
-
whole_task_report =
|
|
3888
|
+
whole_task_report = _latest_whole_task_fv_release_ready(state)
|
|
3880
3889
|
msgs = _handoff_msgs(state)
|
|
3881
3890
|
blocked_summary = ("; ".join(
|
|
3882
3891
|
f"stage {e['stage']} ({', '.join(e['reasons'])})" for e in blocked)
|
|
@@ -3908,7 +3917,7 @@ def _submit_handoff_stage_pick(state: WizardState, value: str) -> Optional[str]:
|
|
|
3908
3917
|
if WHOLE_TASK_STAGE in picks:
|
|
3909
3918
|
if len(picks) > 1:
|
|
3910
3919
|
raise WizardError(t["errors"]["whole_task_exclusive"])
|
|
3911
|
-
if not
|
|
3920
|
+
if not _latest_whole_task_fv_release_ready(state):
|
|
3912
3921
|
raise WizardError(t["errors"]["whole_task_missing"])
|
|
3913
3922
|
state.handoff_mode = "whole-task"
|
|
3914
3923
|
state.handoff_stages = ""
|
|
@@ -37,6 +37,8 @@ from dataclasses import dataclass
|
|
|
37
37
|
from datetime import datetime, timezone
|
|
38
38
|
from pathlib import Path
|
|
39
39
|
|
|
40
|
+
from .wrapper_status import log_path_for_prompt
|
|
41
|
+
|
|
40
42
|
from okstra_ctl.dispatch_state import (
|
|
41
43
|
DispatchError,
|
|
42
44
|
LIVENESS_AUDIT_HEARTBEAT,
|
|
@@ -69,7 +71,7 @@ def _utc_now() -> datetime:
|
|
|
69
71
|
|
|
70
72
|
def _log_path(prompt: Path) -> Path:
|
|
71
73
|
"""The wrapper's live log, named as okstra-*-exec.sh names it."""
|
|
72
|
-
return
|
|
74
|
+
return log_path_for_prompt(prompt)
|
|
73
75
|
|
|
74
76
|
|
|
75
77
|
def probe_heartbeat(
|
|
@@ -25,6 +25,21 @@ def status_path_for_prompt(prompt_path: Path) -> Path:
|
|
|
25
25
|
return prompt_path.with_suffix(prompt_path.suffix + ".status.json")
|
|
26
26
|
|
|
27
27
|
|
|
28
|
+
def log_path_for_prompt(prompt_path: Path) -> Path:
|
|
29
|
+
"""Where the wrapper writes its live log for this prompt.
|
|
30
|
+
|
|
31
|
+
A `.md` prompt drops that suffix rather than stacking on it, so the log of
|
|
32
|
+
`…-009.md` is `…-009.log` and not `…-009.md.log`. Both spellings existed:
|
|
33
|
+
the entrypoint wrote the first while the dispatcher listed the second among
|
|
34
|
+
the write policy's allowed artifacts, so every CLI worker's own log read as
|
|
35
|
+
an unauthorized change to the artifact root. One function now, because the
|
|
36
|
+
disagreement stays invisible until an audit compares the two.
|
|
37
|
+
"""
|
|
38
|
+
if prompt_path.name.endswith(".md"):
|
|
39
|
+
return prompt_path.with_name(f"{prompt_path.name[:-3]}.log")
|
|
40
|
+
return Path(f"{prompt_path}.log")
|
|
41
|
+
|
|
42
|
+
|
|
28
43
|
def read_wrapper_status(path: Path) -> WrapperStatus | None:
|
|
29
44
|
try:
|
|
30
45
|
raw = json.loads(path.read_text(encoding="utf-8"))
|
|
@@ -230,10 +230,9 @@
|
|
|
230
230
|
},
|
|
231
231
|
"verdictCard": {
|
|
232
232
|
"type": "object",
|
|
233
|
-
"description": "Top-of-report at-a-glance card.
|
|
233
|
+
"description": "Top-of-report at-a-glance card. It carries no verdict token — the token lives once, in finalVerdict. `direction` and `nextStep` MUST byte-match finalVerdict and recommendedNextSteps[0].",
|
|
234
234
|
"required": [
|
|
235
235
|
"finalConclusion",
|
|
236
|
-
"verdictToken",
|
|
237
236
|
"direction",
|
|
238
237
|
"approvalRequired",
|
|
239
238
|
"nextStep"
|
|
@@ -245,9 +244,6 @@
|
|
|
245
244
|
"minLength": 1,
|
|
246
245
|
"description": "Conclusion SSOT for the whole report: at most 3 sentences. Other prose blocks reference this instead of restating it."
|
|
247
246
|
},
|
|
248
|
-
"verdictToken": {
|
|
249
|
-
"$ref": "#/$defs/VerdictToken"
|
|
250
|
-
},
|
|
251
247
|
"direction": {
|
|
252
248
|
"$ref": "#/$defs/Direction"
|
|
253
249
|
},
|
|
@@ -1406,6 +1402,7 @@
|
|
|
1406
1402
|
"description": "RENDER_IF taskType == final-verification. §5.8 deliverables.",
|
|
1407
1403
|
"required": [
|
|
1408
1404
|
"sourceImplementationReport",
|
|
1405
|
+
"addedSurfaceAudit",
|
|
1409
1406
|
"acceptanceBlockers",
|
|
1410
1407
|
"residualRisk",
|
|
1411
1408
|
"validationEvidence",
|
|
@@ -1475,6 +1472,13 @@
|
|
|
1475
1472
|
}
|
|
1476
1473
|
}
|
|
1477
1474
|
},
|
|
1475
|
+
"addedSurfaceAudit": {
|
|
1476
|
+
"type": "array",
|
|
1477
|
+
"description": "Over-delivery axis. One row per identifier / module / configuration entry the merged diff ADDS, each traced to the brief requirement it serves. An empty array states the diff added no such surface; it is not a licence to skip the enumeration.",
|
|
1478
|
+
"items": {
|
|
1479
|
+
"$ref": "#/$defs/AddedSurfaceRow"
|
|
1480
|
+
}
|
|
1481
|
+
},
|
|
1478
1482
|
"acceptanceBlockers": {
|
|
1479
1483
|
"type": "array",
|
|
1480
1484
|
"items": {
|
|
@@ -2049,17 +2053,6 @@
|
|
|
2049
2053
|
},
|
|
2050
2054
|
"then": {
|
|
2051
2055
|
"properties": {
|
|
2052
|
-
"verdictCard": {
|
|
2053
|
-
"properties": {
|
|
2054
|
-
"verdictToken": {
|
|
2055
|
-
"enum": [
|
|
2056
|
-
"analysis-complete",
|
|
2057
|
-
"analysis-partial",
|
|
2058
|
-
"blocked"
|
|
2059
|
-
]
|
|
2060
|
-
}
|
|
2061
|
-
}
|
|
2062
|
-
},
|
|
2063
2056
|
"finalVerdict": {
|
|
2064
2057
|
"not": {
|
|
2065
2058
|
"required": [
|
|
@@ -9576,6 +9569,51 @@
|
|
|
9576
9569
|
}
|
|
9577
9570
|
}
|
|
9578
9571
|
},
|
|
9572
|
+
"AddedSurfaceRow": {
|
|
9573
|
+
"type": "object",
|
|
9574
|
+
"description": "One surface the merged diff added, and what requirement it serves.",
|
|
9575
|
+
"required": [
|
|
9576
|
+
"id",
|
|
9577
|
+
"surface",
|
|
9578
|
+
"callers",
|
|
9579
|
+
"requirement",
|
|
9580
|
+
"disposition",
|
|
9581
|
+
"note"
|
|
9582
|
+
],
|
|
9583
|
+
"additionalProperties": false,
|
|
9584
|
+
"properties": {
|
|
9585
|
+
"id": {
|
|
9586
|
+
"type": "string",
|
|
9587
|
+
"pattern": "^AS-\\d{3,}$"
|
|
9588
|
+
},
|
|
9589
|
+
"surface": {
|
|
9590
|
+
"type": "string",
|
|
9591
|
+
"minLength": 1,
|
|
9592
|
+
"description": "path:line plus the added identifier / module / configuration key."
|
|
9593
|
+
},
|
|
9594
|
+
"callers": {
|
|
9595
|
+
"type": "string",
|
|
9596
|
+
"minLength": 1,
|
|
9597
|
+
"description": "The caller found by searching the whole repository, or `none`. A declaration, its own test, or a commented-out line is not a caller."
|
|
9598
|
+
},
|
|
9599
|
+
"requirement": {
|
|
9600
|
+
"type": "string",
|
|
9601
|
+
"description": "The brief requirement this surface serves. Empty unless disposition is `traced`."
|
|
9602
|
+
},
|
|
9603
|
+
"disposition": {
|
|
9604
|
+
"enum": [
|
|
9605
|
+
"traced",
|
|
9606
|
+
"exempt",
|
|
9607
|
+
"over-delivery"
|
|
9608
|
+
]
|
|
9609
|
+
},
|
|
9610
|
+
"note": {
|
|
9611
|
+
"type": "string",
|
|
9612
|
+
"minLength": 1,
|
|
9613
|
+
"description": "`traced`: how the surface serves the requirement. `exempt`: which exit applies and its cited evidence. `over-delivery`: the `AB-NNN` or `CA-NNN` row this became."
|
|
9614
|
+
}
|
|
9615
|
+
}
|
|
9616
|
+
},
|
|
9579
9617
|
"AcceptanceBlockerRow": {
|
|
9580
9618
|
"type": "object",
|
|
9581
9619
|
"required": [
|
|
@@ -9594,8 +9632,7 @@
|
|
|
9594
9632
|
"severity": {
|
|
9595
9633
|
"enum": [
|
|
9596
9634
|
"critical",
|
|
9597
|
-
"major"
|
|
9598
|
-
"minor"
|
|
9635
|
+
"major"
|
|
9599
9636
|
]
|
|
9600
9637
|
},
|
|
9601
9638
|
"statement": {
|
|
@@ -239,6 +239,10 @@
|
|
|
239
239
|
"final-verification": {
|
|
240
240
|
"verification-verdict": "Verification verdict",
|
|
241
241
|
"result-per-requirement": "Result per requirement",
|
|
242
|
+
"what-the-change-added": "What the change added",
|
|
243
|
+
"traced-to": "Serves",
|
|
244
|
+
"callers": "Callers",
|
|
245
|
+
"the-change-added-no-new-surface": "The change added no new identifier, module, or configuration entry.",
|
|
242
246
|
"what-blocks-acceptance": "What blocks acceptance",
|
|
243
247
|
"nothing-is-holding-acceptance": "Nothing is holding acceptance.",
|
|
244
248
|
"residual-risk": "Residual risk",
|
|
@@ -239,6 +239,10 @@
|
|
|
239
239
|
"final-verification": {
|
|
240
240
|
"verification-verdict": "검증 판정",
|
|
241
241
|
"result-per-requirement": "요구사항별 결과",
|
|
242
|
+
"what-the-change-added": "이 변경이 추가한 것",
|
|
243
|
+
"traced-to": "봉사하는 요구사항",
|
|
244
|
+
"callers": "호출자",
|
|
245
|
+
"the-change-added-no-new-surface": "이 변경이 새로 추가한 식별자·모듈·설정 항목은 없습니다.",
|
|
242
246
|
"what-blocks-acceptance": "수용을 막는 것",
|
|
243
247
|
"nothing-is-holding-acceptance": "수용을 막는 것이 없습니다.",
|
|
244
248
|
"residual-risk": "남은 위험",
|
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
{% block human_content %}
|
|
6
6
|
<section data-report-section="verification-verdict">
|
|
7
7
|
<h2>{{ t('tasks.final-verification.verification-verdict') }}</h2>
|
|
8
|
-
<p><span class="status status-{{
|
|
8
|
+
<p><span class="status status-{{ verdictToken }}">{{ verdictToken }}</span></p>
|
|
9
9
|
{{ render_narrative(narrative.verdictExplanation, "finalVerification.userNarrative.verdictExplanation") }}
|
|
10
10
|
</section>
|
|
11
11
|
|
|
@@ -15,6 +15,12 @@
|
|
|
15
15
|
{{ figure(coverageFigure, anchor_nodes=true) }}
|
|
16
16
|
</section>
|
|
17
17
|
|
|
18
|
+
<section data-report-section="added-surface" data-report-field="finalVerification.addedSurfaceAudit">
|
|
19
|
+
<h2>{{ t('tasks.final-verification.what-the-change-added') }}</h2>
|
|
20
|
+
<div class="table-scroll"><table><thead><tr><th>ID</th><th>{{ t('tasks.final-verification.what-the-change-added') }}</th><th>{{ t('tasks.final-verification.callers') }}</th><th>{{ t('tasks.final-verification.traced-to') }}</th><th>{{ t('tasks.final-verification.result-per-requirement') }}</th></tr></thead>
|
|
21
|
+
<tbody>{% for row in final.addedSurfaceAudit %}<tr id="id-{{ row.id }}"><td>{{ row.id }}</td><td>{{ row.surface | inline_code }}</td><td>{{ row.callers | inline_code }}</td><td>{{ row.requirement | inline_code }}</td><td><span class="status status-{{ row.disposition }}">{{ row.disposition }}</span> {{ row.note | inline_code }}</td></tr>{% else %}<tr><td colspan="5">{{ t('tasks.final-verification.the-change-added-no-new-surface') }}</td></tr>{% endfor %}</tbody></table></div>
|
|
22
|
+
</section>
|
|
23
|
+
|
|
18
24
|
<section data-report-section="blockers" data-report-field="finalVerification.acceptanceBlockers">
|
|
19
25
|
<h2>{{ t('tasks.final-verification.what-blocks-acceptance') }}</h2>
|
|
20
26
|
{{ render_narrative(narrative.blockerExplanation, "finalVerification.userNarrative.blockerExplanation") }}
|
|
@@ -53,6 +53,11 @@ from okstra_ctl.conformance import ( # noqa: E402
|
|
|
53
53
|
)
|
|
54
54
|
from okstra_ctl.paths import RunRef # noqa: E402
|
|
55
55
|
from okstra_ctl.report_contract import CURRENT_REPORT_SCHEMA_VERSION # noqa: E402
|
|
56
|
+
from okstra_ctl.release_gate import ( # noqa: E402
|
|
57
|
+
RELEASE_HANDOFF_TARGETS,
|
|
58
|
+
blocking_condition_ids,
|
|
59
|
+
release_handoff_allowed,
|
|
60
|
+
)
|
|
56
61
|
from okstra_ctl.domain.host import HostNotRegistered # noqa: E402
|
|
57
62
|
from okstra_ctl.registry.host_registry import default_host_registry # noqa: E402
|
|
58
63
|
from okstra_ctl.build_tools import ( # noqa: E402
|
|
@@ -6247,7 +6252,7 @@ def _validate_verified_row_recorded(
|
|
|
6247
6252
|
report_path: Path,
|
|
6248
6253
|
failures: list[str],
|
|
6249
6254
|
) -> None:
|
|
6250
|
-
"""
|
|
6255
|
+
"""A release-ready single-stage verification must leave its `verified` row.
|
|
6251
6256
|
|
|
6252
6257
|
`okstra handoff record-verified` validates its own inputs, but nothing
|
|
6253
6258
|
checked that it ever ran. Skipping it leaves the report saying `accepted`
|
|
@@ -6258,8 +6263,7 @@ def _validate_verified_row_recorded(
|
|
|
6258
6263
|
"""
|
|
6259
6264
|
if str(data.get("verificationScope") or "") != "single-stage":
|
|
6260
6265
|
return
|
|
6261
|
-
|
|
6262
|
-
if token.lower() != "accepted":
|
|
6266
|
+
if not release_handoff_allowed(data):
|
|
6263
6267
|
return
|
|
6264
6268
|
stages = {
|
|
6265
6269
|
row.get("stage")
|
|
@@ -6279,7 +6283,7 @@ def _validate_verified_row_recorded(
|
|
|
6279
6283
|
missing = sorted(s for s in stages if s not in verified)
|
|
6280
6284
|
if missing:
|
|
6281
6285
|
failures.append(
|
|
6282
|
-
f"final-verification
|
|
6286
|
+
f"final-verification cleared stage(s) {missing} for release but "
|
|
6283
6287
|
"`runs/implementation-planning/consumers.jsonl` carries no "
|
|
6284
6288
|
"`verified` row for them. Run `okstra handoff record-verified` "
|
|
6285
6289
|
"before finishing — without that row the report says accepted "
|
|
@@ -6308,11 +6312,11 @@ def _validate_verifier_fail_blocks_verdict(data: dict, failures: list[str]) -> N
|
|
|
6308
6312
|
})
|
|
6309
6313
|
if not failed:
|
|
6310
6314
|
return
|
|
6311
|
-
token = str((data.get("
|
|
6315
|
+
token = str((data.get("finalVerdict") or {}).get("verdictToken") or "").strip()
|
|
6312
6316
|
if token in _PASSING_VERDICT_TOKENS:
|
|
6313
6317
|
failures.append(
|
|
6314
6318
|
f"final-report data.json: verifier(s) {failed} recorded "
|
|
6315
|
-
f"`verdict: FAIL` but `
|
|
6319
|
+
f"`verdict: FAIL` but `finalVerdict.verdictToken` is `{token}`. A "
|
|
6316
6320
|
"verifier rejection MUST survive into the published verdict — "
|
|
6317
6321
|
"dropping it during synthesis is how rejected work reaches "
|
|
6318
6322
|
"`release-handoff`. Either carry the FAIL into a blocking verdict "
|
|
@@ -7914,6 +7918,82 @@ def _validate_stage_has_requirement(data: dict, failures: list[str]) -> None:
|
|
|
7914
7918
|
)
|
|
7915
7919
|
|
|
7916
7920
|
|
|
7921
|
+
_ADDED_SURFACE_NO_CALLER_RE = re.compile(r"^\s*none\b", re.IGNORECASE)
|
|
7922
|
+
|
|
7923
|
+
|
|
7924
|
+
def _validate_added_surface_audit(data: dict, failures: list[str]) -> None:
|
|
7925
|
+
"""Every surface the diff added is traced to a requirement, exempted, or paid for.
|
|
7926
|
+
|
|
7927
|
+
The coverage table proves each requirement reached the diff. Nothing proved
|
|
7928
|
+
the reverse — that each thing the diff added answers a requirement — so work
|
|
7929
|
+
nobody asked for passed every gate. This check reads the reverse table and
|
|
7930
|
+
refuses a row that calls itself over-delivery without the blocker or
|
|
7931
|
+
condition it became: a caller-less surface is an acceptance blocker, and a
|
|
7932
|
+
surface with callers but no requirement is a conditional-acceptance
|
|
7933
|
+
condition (ADR-0009 grades the two differently on purpose).
|
|
7934
|
+
"""
|
|
7935
|
+
fv = data.get("finalVerification")
|
|
7936
|
+
if not isinstance(fv, Mapping):
|
|
7937
|
+
return
|
|
7938
|
+
rows = fv.get("addedSurfaceAudit")
|
|
7939
|
+
if not isinstance(rows, list):
|
|
7940
|
+
return
|
|
7941
|
+
blocker_ids = {
|
|
7942
|
+
str(row.get("id"))
|
|
7943
|
+
for row in (fv.get("acceptanceBlockers") or [])
|
|
7944
|
+
if isinstance(row, Mapping)
|
|
7945
|
+
}
|
|
7946
|
+
condition_ids = {
|
|
7947
|
+
str(row.get("id"))
|
|
7948
|
+
for row in ((data.get("finalVerdict") or {}).get(
|
|
7949
|
+
"conditionalAcceptanceConditions") or [])
|
|
7950
|
+
if isinstance(row, Mapping)
|
|
7951
|
+
}
|
|
7952
|
+
for row in rows:
|
|
7953
|
+
if not isinstance(row, Mapping):
|
|
7954
|
+
continue
|
|
7955
|
+
row_id = str(row.get("id") or "<id 없음>")
|
|
7956
|
+
disposition = str(row.get("disposition") or "")
|
|
7957
|
+
note = str(row.get("note") or "")
|
|
7958
|
+
if disposition == "traced" and not str(row.get("requirement") or "").strip():
|
|
7959
|
+
failures.append(
|
|
7960
|
+
f"final-verification: addedSurfaceAudit {row_id} is `traced` but "
|
|
7961
|
+
"names no requirement — a surface is traced to something the "
|
|
7962
|
+
"brief asked for, or it is not traced."
|
|
7963
|
+
)
|
|
7964
|
+
continue
|
|
7965
|
+
if disposition != "over-delivery":
|
|
7966
|
+
continue
|
|
7967
|
+
caller_less = bool(
|
|
7968
|
+
_ADDED_SURFACE_NO_CALLER_RE.match(str(row.get("callers") or ""))
|
|
7969
|
+
)
|
|
7970
|
+
expected, known = (
|
|
7971
|
+
("AB", blocker_ids) if caller_less else ("CA", condition_ids)
|
|
7972
|
+
)
|
|
7973
|
+
cited = set(re.findall(rf"\b{expected}-\d{{3,}}\b", note))
|
|
7974
|
+
if not cited:
|
|
7975
|
+
failures.append(
|
|
7976
|
+
f"final-verification: addedSurfaceAudit {row_id} is "
|
|
7977
|
+
f"`over-delivery` with callers "
|
|
7978
|
+
f"{'none' if caller_less else 'recorded'}, so its note MUST cite "
|
|
7979
|
+
f"the `{expected}-NNN` row it became — "
|
|
7980
|
+
+ (
|
|
7981
|
+
"a caller-less surface is an acceptance blocker"
|
|
7982
|
+
if caller_less
|
|
7983
|
+
else "a surface with callers but no requirement is a "
|
|
7984
|
+
"conditional-acceptance condition"
|
|
7985
|
+
)
|
|
7986
|
+
+ "."
|
|
7987
|
+
)
|
|
7988
|
+
continue
|
|
7989
|
+
missing = sorted(cited - known)
|
|
7990
|
+
if missing:
|
|
7991
|
+
failures.append(
|
|
7992
|
+
f"final-verification: addedSurfaceAudit {row_id} cites "
|
|
7993
|
+
f"{missing}, which the report does not carry."
|
|
7994
|
+
)
|
|
7995
|
+
|
|
7996
|
+
|
|
7917
7997
|
def _validate_final_verification_consistency(data: dict, failures: list[str]) -> None:
|
|
7918
7998
|
"""Enforce verdict ↔ blocker/condition/routing consistency on the
|
|
7919
7999
|
final-verification data.json (SSOT). The schema guarantees field SHAPE;
|
|
@@ -7923,6 +8003,7 @@ def _validate_final_verification_consistency(data: dict, failures: list[str]) ->
|
|
|
7923
8003
|
"""
|
|
7924
8004
|
if (data.get("header") or {}).get("taskType") != "final-verification":
|
|
7925
8005
|
return
|
|
8006
|
+
_validate_added_surface_audit(data, failures)
|
|
7926
8007
|
verdict = data.get("finalVerdict") or {}
|
|
7927
8008
|
token = (verdict.get("verdictToken") or "").strip().lower()
|
|
7928
8009
|
fv = data.get("finalVerification") or {}
|
|
@@ -7954,14 +8035,19 @@ def _validate_final_verification_consistency(data: dict, failures: list[str]) ->
|
|
|
7954
8035
|
"final-verification: verdict `conditional-accept` but "
|
|
7955
8036
|
"conditionalAcceptanceConditions is empty — list every condition."
|
|
7956
8037
|
)
|
|
7957
|
-
if (
|
|
7958
|
-
|
|
7959
|
-
|
|
7960
|
-
|
|
8038
|
+
if routing_token in RELEASE_HANDOFF_TARGETS and not release_handoff_allowed(data):
|
|
8039
|
+
blocking = blocking_condition_ids(data)
|
|
8040
|
+
reason = (
|
|
8041
|
+
f"condition(s) {blocking} declare `blocksReleaseHandoff: true` "
|
|
8042
|
+
"(a condition with no declaration counts as blocking)"
|
|
8043
|
+
if blocking
|
|
8044
|
+
else f"verdict is `{token}`"
|
|
8045
|
+
)
|
|
7961
8046
|
failures.append(
|
|
7962
8047
|
f"final-verification: routingRecommendation cites `release-handoff` "
|
|
7963
|
-
f"but
|
|
7964
|
-
"
|
|
8048
|
+
f"but {reason} — release-handoff routing needs an `accepted` verdict, "
|
|
8049
|
+
"or a `conditional-accept` whose every condition declares "
|
|
8050
|
+
"`blocksReleaseHandoff: false`."
|
|
7965
8051
|
)
|
|
7966
8052
|
|
|
7967
8053
|
scope = data.get("verificationScope", "whole-task")
|
|
@@ -422,11 +422,8 @@ def _validate_analysis_verdict(
|
|
|
422
422
|
expected = "blocked" if worker_blocked or scope_blocked else (
|
|
423
423
|
"analysis-partial" if partial else "analysis-complete"
|
|
424
424
|
)
|
|
425
|
-
actual = {
|
|
426
|
-
|
|
427
|
-
str((data.get("finalVerdict") or {}).get("verdictToken") or ""),
|
|
428
|
-
}
|
|
429
|
-
if actual == {expected}:
|
|
425
|
+
actual = str((data.get("finalVerdict") or {}).get("verdictToken") or "")
|
|
426
|
+
if actual == expected:
|
|
430
427
|
return
|
|
431
428
|
reasons = []
|
|
432
429
|
if worker_blocked:
|