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.
- package/dist/commands/execute/team.mjs +14 -4
- package/dist/commands/execute/team.mjs.map +1 -1
- package/dist/commands/lifecycle/install.mjs +0 -1
- package/dist/commands/lifecycle/install.mjs.map +1 -1
- package/docs/architecture.md +3 -3
- package/docs/cli.md +1 -1
- package/docs/project-structure-overview.md +2 -2
- 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 +2 -2
- package/runtime/bin/okstra-compact-reminder.sh +2 -2
- package/runtime/bin/okstra-provider-exec.py +2 -7
- package/runtime/bin/okstra-render-report-views.py +13 -10
- 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 +3 -3
- 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 +2 -2
- 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/adapters/hosts/claude-code/relay.md +5 -6
- package/runtime/python/okstra_ctl/adapters/providers/antigravity/adapter.py +23 -1
- package/runtime/python/okstra_ctl/agent_invocation.py +17 -0
- package/runtime/python/okstra_ctl/agent_prompt_cli.py +15 -2
- package/runtime/python/okstra_ctl/dispatch_core.py +140 -17
- package/runtime/python/okstra_ctl/dispatch_state.py +60 -3
- 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/model_cli.py +11 -2
- package/runtime/python/okstra_ctl/model_discovery.py +12 -0
- package/runtime/python/okstra_ctl/pane_reclaim.py +49 -43
- package/runtime/python/okstra_ctl/release_gate.py +56 -0
- package/runtime/python/okstra_ctl/render.py +53 -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/render.py +7 -4
- package/runtime/python/okstra_ctl/report_html/view_models/final_verification.py +4 -1
- package/runtime/python/okstra_ctl/run.py +119 -18
- package/runtime/python/okstra_ctl/stage_targets.py +73 -1
- package/runtime/python/okstra_ctl/team.py +84 -14
- package/runtime/python/okstra_ctl/tmux.py +2 -3
- package/runtime/python/okstra_ctl/wizard.py +19 -10
- package/runtime/python/okstra_ctl/worker_liveness.py +3 -1
- package/runtime/python/okstra_ctl/worker_runner.py +2 -2
- package/runtime/python/okstra_ctl/wrapper_status.py +15 -0
- package/runtime/python/okstra_ctl/write_policy.py +9 -1
- package/runtime/schemas/final-report-v2.0.schema.json +58 -19
- package/runtime/skills/okstra-run/SKILL.md +3 -3
- package/runtime/templates/reports/html/base.template.html +1 -2
- 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-report-views.py +30 -17
- package/runtime/validators/validate-run.py +221 -90
- package/runtime/validators/validate_analysis_report.py +2 -5
- package/runtime/validators/validate_session_conformance.py +1 -1
- package/runtime/bin/okstra-trace-cleanup.sh +0 -185
|
@@ -1,12 +1,15 @@
|
|
|
1
|
-
"""
|
|
1
|
+
"""진행 중 run 조회 — pane 회수 의무를 어느 run 에 걸지 고르는 데 쓴다.
|
|
2
2
|
|
|
3
3
|
호출자는 `SessionStart(compact)` 훅(`okstra-compact-reminder.sh`) 하나다. 압축
|
|
4
|
-
직후 리드에게 "이 run 의
|
|
4
|
+
직후 리드에게 "이 run 의 끝난 워커 pane 을 라운드 경계마다 회수하라"는 의무를
|
|
5
5
|
재주입할 때, 그 대상이 되는 이 프로젝트의 진행 중 run 을 여기서 찾는다.
|
|
6
6
|
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
`
|
|
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 .
|
|
18
|
-
from .reconcile import NON_TERMINAL_RECENT_STATUSES
|
|
20
|
+
from .dispatch_state import NON_TERMINAL_WORKER_STATUSES
|
|
19
21
|
|
|
20
22
|
|
|
21
|
-
def
|
|
22
|
-
"""
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
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
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
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
|
|
51
|
-
if not
|
|
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
|
-
|
|
58
|
-
|
|
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) ==
|
|
64
|
-
for run_dir in
|
|
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
|
|
37
|
+
"releaseAllowed": release_handoff_allowed(data),
|
|
35
38
|
"evidenceIndex": evidence_index(data),
|
|
36
39
|
}
|
|
37
40
|
return HumanReportView(
|