okstra 0.176.1 → 0.177.1

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 (62) hide show
  1. package/dist/commands/execute/team.mjs +14 -4
  2. package/dist/commands/execute/team.mjs.map +1 -1
  3. package/dist/commands/lifecycle/install.mjs +0 -1
  4. package/dist/commands/lifecycle/install.mjs.map +1 -1
  5. package/docs/architecture.md +3 -3
  6. package/docs/cli.md +1 -1
  7. package/docs/project-structure-overview.md +2 -2
  8. package/docs/task-process/final-verification.md +5 -3
  9. package/package.json +1 -1
  10. package/runtime/BUILD.json +2 -2
  11. package/runtime/agents/workers/report-writer-worker.md +2 -2
  12. package/runtime/bin/okstra-compact-reminder.sh +2 -2
  13. package/runtime/bin/okstra-provider-exec.py +2 -7
  14. package/runtime/bin/okstra-render-report-views.py +13 -10
  15. package/runtime/prompts/coding-preflight/overview.md +2 -1
  16. package/runtime/prompts/lead/convergence.md +11 -6
  17. package/runtime/prompts/lead/okstra-lead-contract.md +3 -3
  18. package/runtime/prompts/lead/plan-body-verification.md +4 -2
  19. package/runtime/prompts/lead/report-writer.md +8 -4
  20. package/runtime/prompts/profiles/_common-contract.md +2 -2
  21. package/runtime/prompts/profiles/_implementation-verifier.md +1 -1
  22. package/runtime/prompts/profiles/final-verification.md +11 -9
  23. package/runtime/prompts/profiles/improvement-discovery.md +1 -1
  24. package/runtime/prompts/profiles/release-handoff.md +7 -6
  25. package/runtime/prompts/wizard/prompts.ko.json +2 -2
  26. package/runtime/python/okstra_ctl/adapters/hosts/claude-code/relay.md +5 -6
  27. package/runtime/python/okstra_ctl/adapters/providers/antigravity/adapter.py +23 -1
  28. package/runtime/python/okstra_ctl/agent_invocation.py +17 -0
  29. package/runtime/python/okstra_ctl/agent_prompt_cli.py +15 -2
  30. package/runtime/python/okstra_ctl/dispatch_core.py +140 -17
  31. package/runtime/python/okstra_ctl/dispatch_state.py +60 -3
  32. package/runtime/python/okstra_ctl/execution_mutation_audit.py +49 -3
  33. package/runtime/python/okstra_ctl/handoff.py +27 -14
  34. package/runtime/python/okstra_ctl/model_cli.py +11 -2
  35. package/runtime/python/okstra_ctl/model_discovery.py +12 -0
  36. package/runtime/python/okstra_ctl/pane_reclaim.py +49 -43
  37. package/runtime/python/okstra_ctl/release_gate.py +56 -0
  38. package/runtime/python/okstra_ctl/render.py +53 -0
  39. package/runtime/python/okstra_ctl/report_contract.py +1 -0
  40. package/runtime/python/okstra_ctl/report_finalize.py +54 -0
  41. package/runtime/python/okstra_ctl/report_html/render.py +7 -4
  42. package/runtime/python/okstra_ctl/report_html/view_models/final_verification.py +4 -1
  43. package/runtime/python/okstra_ctl/run.py +119 -18
  44. package/runtime/python/okstra_ctl/stage_targets.py +73 -1
  45. package/runtime/python/okstra_ctl/team.py +84 -14
  46. package/runtime/python/okstra_ctl/tmux.py +2 -3
  47. package/runtime/python/okstra_ctl/wizard.py +19 -10
  48. package/runtime/python/okstra_ctl/worker_liveness.py +3 -1
  49. package/runtime/python/okstra_ctl/worker_runner.py +2 -2
  50. package/runtime/python/okstra_ctl/wrapper_status.py +15 -0
  51. package/runtime/python/okstra_ctl/write_policy.py +9 -1
  52. package/runtime/schemas/final-report-v2.0.schema.json +58 -19
  53. package/runtime/skills/okstra-run/SKILL.md +3 -3
  54. package/runtime/templates/reports/html/base.template.html +1 -2
  55. package/runtime/templates/reports/html/i18n/en.json +4 -0
  56. package/runtime/templates/reports/html/i18n/ko.json +4 -0
  57. package/runtime/templates/reports/html/tasks/final-verification.template.html +7 -1
  58. package/runtime/validators/validate-report-views.py +30 -17
  59. package/runtime/validators/validate-run.py +221 -90
  60. package/runtime/validators/validate_analysis_report.py +2 -5
  61. package/runtime/validators/validate_session_conformance.py +1 -1
  62. package/runtime/bin/okstra-trace-cleanup.sh +0 -185
@@ -1,12 +1,15 @@
1
- """활성 run 스코프 조회 — pane 정리 의무를 어느 run 에 걸지 고르는 데 쓴다.
1
+ """진행 중 run 조회 — pane 회수 의무를 어느 run 에 걸지 고르는 데 쓴다.
2
2
 
3
3
  호출자는 `SessionStart(compact)` 훅(`okstra-compact-reminder.sh`) 하나다. 압축
4
- 직후 리드에게 "이 run 의 완료 teammate pane 을 라운드 경계마다 회수하라"는 의무를
4
+ 직후 리드에게 "이 run 의 끝난 워커 pane 을 라운드 경계마다 회수하라"는 의무를
5
5
  재주입할 때, 그 대상이 되는 이 프로젝트의 진행 중 run 을 여기서 찾는다.
6
6
 
7
- 완료 판정(`is_completed_status`)과 프로젝트 무관 전체 조회(`active_run_dirs`)도
8
- 여기 있었으나, 그것을 쓰던 `okstra-trace-cleanup.sh --reclaim-completed` 와
9
- `okstra-subagent-reclaim.sh` 가 제거되면서 함께 사라졌다.
7
+ 정본은 run 디렉터리다(ADR-0011). 신호는 그 run 의 가장 최근 team-state 에
8
+ 비종결 배치가 남아 있는지다. 이전 구현은 `~/.okstra/active.jsonl` 을 읽었는데,
9
+ 그 원장은 `initial_status="running"` 으로 기록된 run 만 담고 in-session 경로는
10
+ `--render-only` 강제로 항상 `prepared` 가 되어 종결로 라우팅된다 — 실측에서 그
11
+ 파일은 0행이었고 이 훅은 한 번도 발화하지 않았다. `state/lead-pane.id` 도 신호가
12
+ 못 된다: 한 번 기록된 뒤 지워지지 않아 끝난 run 을 영구히 진행 중으로 읽는다.
10
13
  """
11
14
  from __future__ import annotations
12
15
 
@@ -14,54 +17,57 @@ import json
14
17
  import sys
15
18
  from pathlib import Path
16
19
 
17
- from .paths import resolve_under_root
18
- from .reconcile import NON_TERMINAL_RECENT_STATUSES
20
+ from .dispatch_state import NON_TERMINAL_WORKER_STATUSES
19
21
 
20
22
 
21
- def _iter_active_run_dirs(home: Path):
22
- """active.jsonl 의 진행 중 run 마다 (projectRoot_str, 절대 run_dir) 를 yield.
23
- runDirRel 의 base 는 projectRoot(paths.compute_run_paths 의 _rel 기준)."""
24
- path = home / "active.jsonl"
25
- if not path.is_file():
26
- return
27
- for line in path.read_text(encoding="utf-8").splitlines():
28
- line = line.strip()
29
- if not line:
30
- continue
31
- try:
32
- row = json.loads(line)
33
- except json.JSONDecodeError:
34
- continue
35
- # 대상(진행 중) 집합은 reconcile 의 NON_TERMINAL_RECENT_STATUSES 가
36
- # SSOT. reserving 은 아직 run-dir 산출물이 없어 이 집합에 들어있지
37
- # 않으므로 자연히 제외된다(allowlist).
38
- if row.get("status") not in NON_TERMINAL_RECENT_STATUSES:
39
- continue
40
- run_dir = resolve_under_root(row.get("projectRoot"), row.get("runDirRel"))
41
- if run_dir is not None:
42
- yield row.get("projectRoot"), run_dir
23
+ def _newest_team_state(state_dir: Path) -> Path | None:
24
+ """한 run 디렉터리의 최신 team-state.
25
+
26
+ seq 는 3자리 zero-pad 라 이름 정렬이 곧 순서다. run 하나에 seq 가 누적되고
27
+ 이전 라운드의 `in-progress` 행이 그 파일에 남으므로, 최신이 아닌 파일을 읽으면
28
+ 몇 시간 전에 끝난 run 을 진행 중으로 보고한다.
29
+ """
30
+ files = sorted(state_dir.glob("team-state-*.json"))
31
+ return files[-1] if files else None
43
32
 
44
33
 
45
- def active_run_dirs_for_project(home: Path, cwd: Path) -> list[Path]:
46
- """진행 중 run 중 projectRoot 이 cwd(또는 그 상위)인 것의 절대 run-dir 목록.
47
- SessionStart(compact) 훅이 이 프로젝트의 run 에만 리마인더를 걸도록 스코프한다."""
48
- cwd_r = Path(cwd).resolve()
34
+ def _has_live_dispatch(team_state_path: Path) -> bool:
35
+ try:
36
+ payload = json.loads(team_state_path.read_text(encoding="utf-8"))
37
+ except (OSError, json.JSONDecodeError):
38
+ return False
39
+ if not isinstance(payload, dict):
40
+ return False
41
+ return any(
42
+ isinstance(record, dict)
43
+ and record.get("status") in NON_TERMINAL_WORKER_STATUSES
44
+ for record in payload.get("workerDispatches", [])
45
+ )
46
+
47
+
48
+ def in_flight_run_dirs(project_root: Path) -> list[Path]:
49
+ """비종결 배치를 아직 들고 있는 이 프로젝트의 run 디렉터리 목록.
50
+
51
+ `runs/<task-type>/` 와 stage 격리 run 의 `runs/<task-type>/stage-<N>/` 을 모두
52
+ 훑는다. 후자를 빼면 `implementation` 과 단일 stage `final-verification` 이
53
+ 통째로 누락된다.
54
+ """
55
+ tasks_root = Path(project_root) / ".okstra" / "tasks"
56
+ if not tasks_root.is_dir():
57
+ return []
49
58
  out: list[Path] = []
50
- for project_root, run_dir in _iter_active_run_dirs(home):
51
- if not project_root:
52
- continue
53
- try:
54
- project_root_r = Path(project_root).resolve()
55
- except OSError:
59
+ for state_dir in sorted(tasks_root.glob("*/*/runs/**/state")):
60
+ if not state_dir.is_dir():
56
61
  continue
57
- if cwd_r == project_root_r or cwd_r.is_relative_to(project_root_r):
58
- out.append(run_dir)
62
+ newest = _newest_team_state(state_dir)
63
+ if newest is not None and _has_live_dispatch(newest):
64
+ out.append(state_dir.parent)
59
65
  return out
60
66
 
61
67
 
62
68
  def main(argv: list[str]) -> int:
63
- if len(argv) == 3 and argv[0] == "--active-dirs-for":
64
- for run_dir in active_run_dirs_for_project(Path(argv[1]), Path(argv[2])):
69
+ if len(argv) == 2 and argv[0] == "--in-flight-run-dirs-for":
70
+ for run_dir in in_flight_run_dirs(Path(argv[1])):
65
71
  print(run_dir)
66
72
  return 0
67
73
  return 1
@@ -0,0 +1,56 @@
1
+ """final-verification 판정이 release-handoff 진입을 허용하는지 판정한다.
2
+
3
+ 검증기(`validators/validate-run.py`)와 핸드오프(`handoff.py`), 그리고 HTML 리포트가
4
+ 모두 같은 답을 내야 하므로 규칙은 여기 한 번만 산다.
5
+
6
+ `accepted` 는 그대로 통과한다. `conditional-accept` 는 모든 조건이 스스로
7
+ `blocksReleaseHandoff: false` 라고 선언했을 때만 통과한다 — 릴리스를 막는다고 적힌
8
+ 조건이 하나라도 있으면 막힌다. 조건 목록이 비어 있는 `conditional-accept` 는
9
+ 그 자체가 계약 위반이므로(조건을 빠짐없이 적어야 한다) 여기서도 막는다.
10
+ """
11
+ from __future__ import annotations
12
+
13
+ from collections.abc import Mapping
14
+ from typing import Any
15
+
16
+ RELEASE_HANDOFF_TARGETS = frozenset({"release-handoff", "release-handoff(stage-group)"})
17
+
18
+
19
+ def verdict_token(data: Mapping[str, Any]) -> str:
20
+ """리포트의 유일한 판정 토큰 자리에서 읽은 값(소문자, 공백 제거)."""
21
+ final_verdict = data.get("finalVerdict")
22
+ if not isinstance(final_verdict, Mapping):
23
+ return ""
24
+ return str(final_verdict.get("verdictToken") or "").strip().lower()
25
+
26
+
27
+ def _conditions(data: Mapping[str, Any]) -> list[Mapping[str, Any]]:
28
+ final_verdict = data.get("finalVerdict")
29
+ if not isinstance(final_verdict, Mapping):
30
+ return []
31
+ rows = final_verdict.get("conditionalAcceptanceConditions")
32
+ if not isinstance(rows, list):
33
+ return []
34
+ return [row for row in rows if isinstance(row, Mapping)]
35
+
36
+
37
+ def blocking_condition_ids(data: Mapping[str, Any]) -> list[str]:
38
+ """릴리스를 막는다고 선언된 조건의 id. 선언이 없거나 참이면 막는 것으로 읽는다."""
39
+ return [
40
+ str(row.get("id") or "<id 없음>")
41
+ for row in _conditions(data)
42
+ if row.get("blocksReleaseHandoff") is not False
43
+ ]
44
+
45
+
46
+ def release_handoff_allowed(data: Mapping[str, Any]) -> bool:
47
+ """이 final-verification 리포트가 release-handoff 로 넘어가도 되는가."""
48
+ token = verdict_token(data)
49
+ if token == "accepted":
50
+ return True
51
+ if token != "conditional-accept":
52
+ return False
53
+ conditions = _conditions(data)
54
+ if not conditions:
55
+ return False
56
+ return not blocking_condition_ids(data)
@@ -735,6 +735,15 @@ def render_team_state(team_state_path: str, ctx: dict) -> None:
735
735
  catalog = _worker_catalog(ctx)
736
736
  worker_dispatch_plan = _worker_dispatch_plan(ctx)
737
737
  workers = []
738
+ for row in _optional_worker_roles(ctx):
739
+ workers.append({
740
+ **{key: row[key] for key in (
741
+ "workerId", "role", "agent", "provider", "runner",
742
+ "model", "modelExecutionValue", "resultPath", "promptPath",
743
+ )},
744
+ "status": "not-run",
745
+ "reason": "",
746
+ })
738
747
  for w in selected:
739
748
  m = catalog[w]
740
749
  workers.append(
@@ -1133,6 +1142,44 @@ def _required_worker_roles(ctx: dict, reviewers: list[str]) -> list[dict]:
1133
1142
  ]
1134
1143
 
1135
1144
 
1145
+ def _optional_worker_roles(ctx: dict) -> list[dict]:
1146
+ """Roles this run may dispatch but does not require — today, the critics.
1147
+
1148
+ A critic is opt-in, so it never belonged in `requiredWorkerRoles`. But it
1149
+ was absent from the roster entirely, and both `okstra team dispatch` and
1150
+ the liveness reader locate a worker by its team-state row: dispatching a
1151
+ critic failed with `team-state has no workerId=acceptance`, while adding
1152
+ the row by hand failed validation as an `unexpected worker role`. Declaring
1153
+ it here makes the roster say what the run may run, so both sides agree.
1154
+
1155
+ Keyed off `invocationAssignments`, which is where the run records the
1156
+ critic it actually resolved — an absent `critic/*` entry means no critic.
1157
+ """
1158
+ assignments = _invocation_assignments(ctx)
1159
+ roles: list[dict] = []
1160
+ for assignment_ref, assignment in sorted(assignments.items()):
1161
+ if not assignment_ref.startswith("critic/"):
1162
+ continue
1163
+ provider = str(assignment.get("provider", ""))
1164
+ roles.append({
1165
+ # `okstra team dispatch` projects a v2 worker's state key off the
1166
+ # assignment ref's last segment; the row it looks up must use it.
1167
+ "workerId": assignment_ref.rsplit("/", 1)[-1],
1168
+ "role": f"{provider_spec(provider).display_label} critic",
1169
+ "agent": provider,
1170
+ "provider": provider,
1171
+ "runner": str(assignment.get("runner", "")),
1172
+ "model": str(assignment.get("model", "")),
1173
+ "modelExecutionValue": str(assignment.get("modelExecutionValue", "")),
1174
+ # The lead names these when it materializes the invocation; a
1175
+ # critic has no prompt or result until it is dispatched.
1176
+ "resultPath": "",
1177
+ "promptPath": "",
1178
+ "attemptRequired": False,
1179
+ })
1180
+ return roles
1181
+
1182
+
1136
1183
  def _reporter_confirmation_status(brief_bytes: bytes) -> str:
1137
1184
  try:
1138
1185
  lines = brief_bytes.decode("utf-8").splitlines()
@@ -1448,6 +1495,7 @@ def render_task_manifest(manifest_path: str, ctx: dict) -> None:
1448
1495
  "minimumPreferredWorkerResults": len(reviewers),
1449
1496
  "requiredWorkerAttempts": reviewers,
1450
1497
  "requiredWorkerRoles": required_worker_roles,
1498
+ "optionalWorkerRoles": _optional_worker_roles(ctx),
1451
1499
  "requiredAgentStatusEntries": required_agent_status_entries,
1452
1500
  "requireDistinctLeadFromWorkerSession": True,
1453
1501
  "requireAllRequiredWorkerAttempts": True,
@@ -1644,6 +1692,10 @@ def render_run_manifest(run_manifest_path: str, ctx: dict) -> None:
1644
1692
  "taskId": ctx.get("TASK_ID", ""),
1645
1693
  "taskKey": ctx.get("TASK_KEY", ""),
1646
1694
  "taskType": ctx.get("TASK_TYPE", ""),
1695
+ # Every other path in this manifest is project-relative; this is the
1696
+ # anchor they resolve against. Convergence reads it to place run
1697
+ # artifacts, so a manifest without it cannot seed a round.
1698
+ "projectRoot": ctx.get("PROJECT_ROOT", ""),
1647
1699
  "hostRuntime": ctx.get("HOST_RUNTIME", "") or _lead_runtime(ctx),
1648
1700
  "leadRuntime": _lead_runtime(ctx),
1649
1701
  "leadRuntimeRequest": ctx.get("LEAD_RUNTIME_REQUEST", "") or _lead_runtime(ctx),
@@ -1765,6 +1817,7 @@ def render_run_manifest(run_manifest_path: str, ctx: dict) -> None:
1765
1817
  "finalSynthesisOwner": lead_role,
1766
1818
  "requiredWorkerAttempts": reviewers,
1767
1819
  "requiredWorkerRoles": required_worker_roles,
1820
+ "optionalWorkerRoles": _optional_worker_roles(ctx),
1768
1821
  "requiredAgentStatusEntries": [lead_role]
1769
1822
  + [catalog[item]["role"] for item in reviewers],
1770
1823
  "requireDistinctLeadFromWorkerSession": True,
@@ -111,6 +111,7 @@ TASK_TYPE_REQUIRED_HUMAN_FIELDS = {
111
111
  ),
112
112
  "final-verification": (
113
113
  "finalVerification.validationEvidence",
114
+ "finalVerification.addedSurfaceAudit",
114
115
  "finalVerification.acceptanceBlockers",
115
116
  "finalVerification.residualRisk",
116
117
  "finalVerification.manualUserTest",
@@ -42,6 +42,12 @@ from .report_contract import apply_execution_roles
42
42
  from .dispatch_state import DispatchError, link_agent_dispatch_result
43
43
  from .final_report_paths import final_report_data_path, final_report_markdown_path
44
44
  from .paths import task_dir, task_manifest_file
45
+ from .release_gate import release_handoff_allowed
46
+ from .stage_integrate import IntegrateError
47
+ from .stage_targets import (
48
+ StageTargetError,
49
+ integrate_and_teardown_whole_task,
50
+ )
45
51
  from .session import observe_lead_session
46
52
 
47
53
 
@@ -51,6 +57,7 @@ STEP_TOKEN_USAGE = "token-usage"
51
57
  STEP_RENDER_VIEWS = "render-views"
52
58
  STEP_SPAWN_FOLLOWUPS = "spawn-followups"
53
59
  STEP_VALIDATE_RUN = "validate-run"
60
+ STEP_TEARDOWN_STAGES = "teardown-stages"
54
61
 
55
62
  STEP_ORDER = (
56
63
  STEP_PROJECT_ACTIVITY,
@@ -62,6 +69,9 @@ STEP_ORDER = (
62
69
  STEP_RENDER_VIEWS,
63
70
  STEP_SPAWN_FOLLOWUPS,
64
71
  STEP_VALIDATE_RUN,
72
+ # Last, and only after the run validated: it removes the stage worktrees a
73
+ # blocked verdict would send the user straight back to.
74
+ STEP_TEARDOWN_STAGES,
65
75
  )
66
76
 
67
77
 
@@ -213,6 +223,7 @@ class FinalizeContext:
213
223
  task_key: str
214
224
  task_type: str
215
225
  task_group: str
226
+ task_id: str
216
227
  seq: str
217
228
  final_status_path: Path | None
218
229
 
@@ -241,6 +252,7 @@ class FinalizeContext:
241
252
  task_key=require_string(manifest, "taskKey"),
242
253
  task_type=require_string(manifest, "taskType"),
243
254
  task_group=task_group(manifest),
255
+ task_id=task_id(manifest),
244
256
  seq=report_seq(manifest),
245
257
  final_status_path=resolve_optional_path(
246
258
  project_root, manifest.get("finalStatusPath")
@@ -327,9 +339,49 @@ def build_commands(ctx: FinalizeContext) -> list[tuple[str, list[str]]]:
327
339
  ],
328
340
  ),
329
341
  (STEP_VALIDATE_RUN, _validate_run_command(ctx, markdown_path)),
342
+ (
343
+ STEP_TEARDOWN_STAGES,
344
+ ["<in-process>", "teardown-stages", str(ctx.data_path)],
345
+ ),
330
346
  ]
331
347
 
332
348
 
349
+ def _teardown_stage_worktrees(
350
+ ctx: FinalizeContext,
351
+ command: list[str],
352
+ ) -> subprocess.CompletedProcess:
353
+ """판정이 릴리스로 향할 때만 stage worktree 와 registry 키를 정리한다.
354
+
355
+ whole-task 진입이 통합만 하고 정리를 남겨두므로(`stage_targets`), 정리는 판정이
356
+ 나온 뒤인 여기서 한다. `blocked` 이거나 릴리스를 막는 조건이 남은 판정에서는
357
+ stage 작업물을 그대로 둬서 재작업이 바로 이어지게 한다. 이미 정리된 뒤 재실행돼도
358
+ 같은 결과를 낸다 — 병합은 `already_merged` 로, 없는 worktree 는 건너뛴다.
359
+ """
360
+ def _done(payload: Mapping[str, Any]) -> subprocess.CompletedProcess:
361
+ return subprocess.CompletedProcess(command, 0, json.dumps(payload), "")
362
+
363
+ if ctx.task_type != "final-verification":
364
+ return _done({"skipped": "not a final-verification run"})
365
+ try:
366
+ data = json.loads(Path(ctx.data_path).read_text(encoding="utf-8"))
367
+ except (OSError, json.JSONDecodeError) as exc:
368
+ return subprocess.CompletedProcess(
369
+ command, 1, "", f"cannot read final-report data.json: {exc}")
370
+ if data.get("verificationScope") != "whole-task":
371
+ return _done({"skipped": "single-stage verification owns no teardown"})
372
+ if not release_handoff_allowed(data):
373
+ return _done({"skipped": "verdict does not clear the work for release"})
374
+ try:
375
+ result = integrate_and_teardown_whole_task(
376
+ project_root=ctx.project_root,
377
+ task_group=ctx.task_group,
378
+ task_id=ctx.task_id,
379
+ )
380
+ except (IntegrateError, StageTargetError, OSError) as exc:
381
+ return subprocess.CompletedProcess(command, 1, "", str(exc))
382
+ return _done(result)
383
+
384
+
333
385
  def _validate_run_command(ctx: FinalizeContext, markdown_path: Path) -> list[str]:
334
386
  command = [
335
387
  sys.executable,
@@ -431,6 +483,8 @@ def run_finalize(
431
483
  )
432
484
  else:
433
485
  result = None
486
+ if name == STEP_TEARDOWN_STAGES:
487
+ result = _teardown_stage_worktrees(ctx, command)
434
488
  if name == STEP_VALIDATE_RUN:
435
489
  try:
436
490
  _link_lead_result_for_validation(ctx)
@@ -109,17 +109,21 @@ def _html_path(data_path: Path) -> Path:
109
109
 
110
110
  def render_v2_html_view(
111
111
  data_path: Path,
112
- markdown_path: Path,
113
112
  *,
114
113
  run_meta: HtmlRunMeta,
115
114
  templates_root: Path | None = None,
116
115
  ) -> Path:
116
+ """Render the human HTML from the data.json alone.
117
+
118
+ The AI-handoff markdown sibling is a second rendering of this same record,
119
+ never an input here: it used to be read for a `source-md-sha256` stamp that
120
+ no reader ever compared, which made a derived artifact a precondition for
121
+ another derived artifact.
122
+ """
117
123
  data = json.loads(data_path.read_text(encoding="utf-8"))
118
124
  errors = validate(data, load_schema_for_data(data))
119
125
  if errors:
120
126
  raise HtmlRenderError("invalid v2 final-report data: " + "; ".join(errors[:5]))
121
- if not markdown_path.is_file():
122
- raise HtmlRenderError(f"v2 markdown sibling not found: {markdown_path}")
123
127
  # Validate the SSOT, then localize — the sidecar carries presentation and
124
128
  # has no say in whether the report is well-formed.
125
129
  data, lang = _localize(data, data_path)
@@ -150,7 +154,6 @@ def render_v2_html_view(
150
154
  "taskType": view.task_type,
151
155
  "sourceData": source_data,
152
156
  "dataSha256": _sha256(data_path),
153
- "markdownSha256": _sha256(markdown_path),
154
157
  "clarificationItems": data.get("clarificationItems", []),
155
158
  # Every task type ends with the same run-cost section, so it is bound
156
159
  # here rather than in ten view models that would each rebuild it.
@@ -1,6 +1,7 @@
1
1
  """Human-first final-verification view model."""
2
2
  from __future__ import annotations
3
3
 
4
+ from ...release_gate import release_handoff_allowed
4
5
  from ..common import evidence_index
5
6
  from ..models import HumanReportView, VisualNode
6
7
  from ..visualizations import coverage_figure
@@ -25,13 +26,15 @@ def build_final_verification_view(data: dict) -> HumanReportView:
25
26
  figure = coverage_figure(
26
27
  rows=_coverage_nodes(final), title="Requirement verification coverage"
27
28
  )
29
+ verdict_token = data["finalVerdict"]["verdictToken"]
28
30
  context = {
29
31
  "humanSummary": data["humanSummary"],
30
32
  "verdict": data["verdictCard"],
33
+ "verdictToken": verdict_token,
31
34
  "final": final,
32
35
  "narrative": final["userNarrative"],
33
36
  "coverageFigure": figure,
34
- "releaseAllowed": data["verdictCard"]["verdictToken"] == "accepted",
37
+ "releaseAllowed": release_handoff_allowed(data),
35
38
  "evidenceIndex": evidence_index(data),
36
39
  }
37
40
  return HumanReportView(