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.
Files changed (36) hide show
  1. package/docs/task-process/final-verification.md +5 -3
  2. package/package.json +1 -1
  3. package/runtime/BUILD.json +2 -2
  4. package/runtime/agents/workers/report-writer-worker.md +1 -1
  5. package/runtime/bin/okstra-provider-exec.py +2 -7
  6. package/runtime/prompts/coding-preflight/overview.md +2 -1
  7. package/runtime/prompts/lead/convergence.md +11 -6
  8. package/runtime/prompts/lead/okstra-lead-contract.md +2 -2
  9. package/runtime/prompts/lead/plan-body-verification.md +4 -2
  10. package/runtime/prompts/lead/report-writer.md +8 -4
  11. package/runtime/prompts/profiles/_common-contract.md +1 -1
  12. package/runtime/prompts/profiles/_implementation-verifier.md +1 -1
  13. package/runtime/prompts/profiles/final-verification.md +11 -9
  14. package/runtime/prompts/profiles/improvement-discovery.md +1 -1
  15. package/runtime/prompts/profiles/release-handoff.md +7 -6
  16. package/runtime/prompts/wizard/prompts.ko.json +2 -2
  17. package/runtime/python/okstra_ctl/agent_prompt_cli.py +2 -2
  18. package/runtime/python/okstra_ctl/dispatch_core.py +50 -6
  19. package/runtime/python/okstra_ctl/dispatch_state.py +28 -4
  20. package/runtime/python/okstra_ctl/execution_mutation_audit.py +49 -3
  21. package/runtime/python/okstra_ctl/handoff.py +27 -14
  22. package/runtime/python/okstra_ctl/release_gate.py +56 -0
  23. package/runtime/python/okstra_ctl/report_contract.py +1 -0
  24. package/runtime/python/okstra_ctl/report_finalize.py +54 -0
  25. package/runtime/python/okstra_ctl/report_html/view_models/final_verification.py +4 -1
  26. package/runtime/python/okstra_ctl/run.py +31 -16
  27. package/runtime/python/okstra_ctl/stage_targets.py +73 -1
  28. package/runtime/python/okstra_ctl/wizard.py +19 -10
  29. package/runtime/python/okstra_ctl/worker_liveness.py +3 -1
  30. package/runtime/python/okstra_ctl/wrapper_status.py +15 -0
  31. package/runtime/schemas/final-report-v2.0.schema.json +55 -18
  32. package/runtime/templates/reports/html/i18n/en.json +4 -0
  33. package/runtime/templates/reports/html/i18n/ko.json +4 -0
  34. package/runtime/templates/reports/html/tasks/final-verification.template.html +7 -1
  35. package/runtime/validators/validate-run.py +98 -12
  36. 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=True,
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 선택 역할: 기본은 추가 안 함, 추가 시 1..max 수량을 이 스텝에서 고른다."""
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(count=count),
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 _latest_whole_task_fv_accepted(state: WizardState) -> str:
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 latest_whole_task_fv_accepted
3871
- return latest_whole_task_fv_accepted(
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 = _latest_whole_task_fv_accepted(state)
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 _latest_whole_task_fv_accepted(state):
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 prompt.with_suffix(".log") if prompt.suffix == ".md" else Path(f"{prompt}.log")
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. Values MUST byte-match finalVerdict and recommendedNextSteps[0]; renderer fills both from this data structure so divergence is impossible.",
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-{{ verdict.verdictToken }}">{{ verdict.verdictToken }}</span></p>
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
- """An accepted single-stage verification must leave its `verified` row.
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
- token = str((data.get("finalVerdict") or {}).get("verdictToken") or "").strip()
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 accepted stage(s) {missing} but "
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("verdictCard") or {}).get("verdictToken") or "").strip()
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 `verdictCard.verdictToken` is `{token}`. A "
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
- routing_token in {"release-handoff", "release-handoff(stage-group)"}
7959
- and token != "accepted"
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 verdict is `{token}` — release-handoff routing is allowed only "
7964
- "when the verdict is `accepted`."
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
- str((data.get("verdictCard") or {}).get("verdictToken") or ""),
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: