okstra 0.202.0 → 0.205.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/README.md +7 -6
- package/dist/cli-registry.mjs +7 -7
- package/dist/cli-registry.mjs.map +1 -1
- package/dist/commands/lifecycle/install.mjs +50 -124
- package/dist/commands/lifecycle/install.mjs.map +1 -1
- package/dist/commands/memory/memory.mjs +41 -8
- package/dist/commands/memory/memory.mjs.map +1 -1
- package/dist/lib/install-assets.mjs +3 -0
- package/dist/lib/install-assets.mjs.map +1 -1
- package/dist/lib/runtime-manifest.mjs +2 -1
- package/dist/lib/runtime-manifest.mjs.map +1 -1
- package/dist/lib/types.d.mts +2 -1
- package/docs/architecture/storage-model.md +14 -11
- package/docs/architecture.md +26 -20
- package/docs/cli.md +15 -12
- package/docs/contributor-change-matrix.md +3 -2
- package/docs/performance-improvement-plan-v2.md +3 -9
- package/docs/project-structure-overview.md +39 -11
- package/docs/task-process/README.md +1 -1
- package/docs/task-process/common-flow.md +1 -1
- package/docs/task-process/final-verification.md +3 -1
- package/docs/task-process/implementation-option-selection.md +1 -1
- package/docs/task-process/implementation.md +1 -1
- package/docs/task-process/release-handoff.md +36 -39
- package/package.json +1 -2
- package/runtime/BUILD.json +2 -2
- package/runtime/agents/common.json +28 -0
- package/runtime/agents/operations/code-review.json +6 -0
- package/runtime/agents/operations/report-translation.json +6 -0
- package/runtime/agents/operations/schedule-verification.json +6 -0
- package/runtime/agents/roles/analyser.json +18 -0
- package/runtime/agents/roles/critic.json +18 -0
- package/runtime/agents/roles/designer.json +18 -0
- package/runtime/agents/roles/implementer.json +20 -0
- package/runtime/agents/roles/leader.json +20 -0
- package/runtime/agents/roles/planner.json +18 -0
- package/runtime/agents/roles/report-writer.json +19 -0
- package/runtime/agents/roles/translator.json +19 -0
- package/runtime/agents/roles/verifier.json +18 -0
- package/runtime/bin/lib/okstra/usage.sh +5 -5
- package/runtime/prompts/duties/acceptance-critic.json +32 -0
- package/runtime/prompts/duties/acceptance-verifier.json +32 -0
- package/runtime/prompts/duties/analysis-worker.json +32 -0
- package/runtime/prompts/duties/code-reviewer.json +32 -0
- package/runtime/prompts/duties/diagnosis-worker.json +32 -0
- package/runtime/prompts/duties/direction-selection-worker.json +32 -0
- package/runtime/prompts/duties/discovery-worker.json +32 -0
- package/runtime/prompts/duties/implementation-executor.json +32 -0
- package/runtime/prompts/duties/implementation-verifier.json +32 -0
- package/runtime/prompts/duties/lead.json +32 -0
- package/runtime/prompts/duties/planning-worker.json +36 -0
- package/runtime/prompts/duties/report-writer.json +32 -0
- package/runtime/prompts/duties/reverification-worker.json +32 -0
- package/runtime/prompts/duties/schedule-verifier.json +32 -0
- package/runtime/prompts/duties/scope-critic.json +32 -0
- package/runtime/prompts/duties/technical-verification-worker.json +32 -0
- package/runtime/prompts/duties/translator.json +32 -0
- package/runtime/prompts/launch.template.md +2 -1
- package/runtime/prompts/lead/adapters/cmux.md +1 -1
- package/runtime/prompts/lead/convergence.md +4 -4
- package/runtime/prompts/lead/okstra-lead-contract.md +115 -6
- package/runtime/prompts/lead/plan-body-verification.md +6 -6
- package/runtime/prompts/lead/report-writer.md +3 -3
- package/runtime/prompts/profiles/_common-contract.md +2 -2
- package/runtime/prompts/profiles/_implementation-diff-review.md +1 -1
- package/runtime/prompts/profiles/_implementation-executor.md +4 -1
- package/runtime/prompts/profiles/_implementation-self-check.md +1 -1
- package/runtime/prompts/profiles/_implementation-verifier.md +2 -2
- package/runtime/prompts/profiles/change-impact-analysis.json +31 -0
- package/runtime/prompts/profiles/change-impact-analysis.md +0 -20
- package/runtime/prompts/profiles/error-analysis.json +39 -0
- package/runtime/prompts/profiles/error-analysis.md +0 -25
- package/runtime/prompts/profiles/feature-analysis.json +31 -0
- package/runtime/prompts/profiles/feature-analysis.md +0 -20
- package/runtime/prompts/profiles/final-verification.json +30 -0
- package/runtime/prompts/profiles/final-verification.md +4 -23
- package/runtime/prompts/profiles/forbidden-actions.json +4 -3
- package/runtime/prompts/profiles/implementation-option-selection.json +31 -0
- package/runtime/prompts/profiles/implementation-option-selection.md +0 -20
- package/runtime/prompts/profiles/implementation-planning.json +40 -0
- package/runtime/prompts/profiles/implementation-planning.md +4 -29
- package/runtime/prompts/profiles/implementation.json +30 -0
- package/runtime/prompts/profiles/implementation.md +1 -20
- package/runtime/prompts/profiles/improvement-discovery.json +31 -0
- package/runtime/prompts/profiles/improvement-discovery.md +0 -20
- package/runtime/prompts/profiles/project-analysis.json +31 -0
- package/runtime/prompts/profiles/project-analysis.md +0 -20
- package/runtime/prompts/profiles/release-handoff.json +5 -0
- package/runtime/prompts/profiles/release-handoff.md +74 -74
- package/runtime/prompts/profiles/requirements-discovery.json +39 -0
- package/runtime/prompts/profiles/requirements-discovery.md +0 -25
- package/runtime/prompts/profiles/technical-verification.json +39 -0
- package/runtime/prompts/profiles/technical-verification.md +0 -25
- package/runtime/prompts/wizard/prompts.ko.json +14 -18
- package/runtime/python/okstra_ctl/adapters/hosts/antigravity/relay.md +1 -0
- package/runtime/python/okstra_ctl/adapters/hosts/claude-code/adapter.py +3 -0
- package/runtime/python/okstra_ctl/adapters/hosts/claude-code/manifest.json +1 -1
- package/runtime/python/okstra_ctl/adapters/hosts/claude-code/relay.md +4 -3
- package/runtime/python/okstra_ctl/adapters/hosts/claude-code/worker-session.md +108 -0
- package/runtime/python/okstra_ctl/adapters/hosts/codex/relay.md +1 -0
- package/runtime/python/okstra_ctl/adapters/hosts/grok/relay.md +2 -0
- package/runtime/python/okstra_ctl/adapters/hosts/kimi/relay.md +2 -0
- package/runtime/python/okstra_ctl/adapters/providers/antigravity/adapter.py +8 -1
- package/runtime/python/okstra_ctl/adapters/providers/claude/adapter.py +8 -0
- package/runtime/python/okstra_ctl/adapters/providers/codex/adapter.py +23 -6
- package/runtime/python/okstra_ctl/adapters/providers/grok/adapter.py +6 -2
- package/runtime/python/okstra_ctl/agent/invocation.py +168 -113
- package/runtime/python/okstra_ctl/agent/prompt_cli/cli.py +120 -0
- package/runtime/python/okstra_ctl/agent/prompt_cli/materialize.py +107 -2
- package/runtime/python/okstra_ctl/agent/prompt_cli/run_identity.py +0 -49
- package/runtime/python/okstra_ctl/analysis_packet.py +4 -1
- package/runtime/python/okstra_ctl/application/open_worker.py +6 -1
- package/runtime/python/okstra_ctl/assignment_resolver.py +16 -5
- package/runtime/python/okstra_ctl/cmux.py +69 -20
- package/runtime/python/okstra_ctl/code_review_target.py +16 -8
- package/runtime/python/okstra_ctl/conformance.py +43 -0
- package/runtime/python/okstra_ctl/consumers.py +23 -8
- package/runtime/python/okstra_ctl/container.py +31 -8
- package/runtime/python/okstra_ctl/context_cost.py +11 -15
- package/runtime/python/okstra_ctl/contract_refreeze.py +156 -0
- package/runtime/python/okstra_ctl/convergence_engine.py +38 -0
- package/runtime/python/okstra_ctl/convergence_provenance.py +7 -1
- package/runtime/python/okstra_ctl/design_prep.py +34 -1
- package/runtime/python/okstra_ctl/dispatch_core.py +53 -27
- package/runtime/python/okstra_ctl/domain/host.py +5 -0
- package/runtime/python/okstra_ctl/domain/worker_runtime.py +10 -0
- package/runtime/python/okstra_ctl/error_report.py +4 -3
- package/runtime/python/okstra_ctl/execution_manifest.py +71 -18
- package/runtime/python/okstra_ctl/handoff.py +384 -286
- package/runtime/python/okstra_ctl/handoff_verification.py +25 -6
- package/runtime/python/okstra_ctl/implementation_stage.py +9 -0
- package/runtime/python/okstra_ctl/initial_prompt_materialization.py +113 -0
- package/runtime/python/okstra_ctl/lead_progress.py +1 -1
- package/runtime/python/okstra_ctl/legacy_model_selection.py +2 -2
- package/runtime/python/okstra_ctl/manager_cli.py +92 -4
- package/runtime/python/okstra_ctl/manager_launch.py +1 -1
- package/runtime/python/okstra_ctl/manager_paths.py +14 -3
- package/runtime/python/okstra_ctl/manager_store.py +210 -3
- package/runtime/python/okstra_ctl/manager_sync.py +4 -1
- package/runtime/python/okstra_ctl/manager_view.py +2 -1
- package/runtime/python/okstra_ctl/model_discovery.py +30 -0
- package/runtime/python/okstra_ctl/model_io/lines.py +14 -1
- package/runtime/python/okstra_ctl/model_io/renderers.py +4 -3
- package/runtime/python/okstra_ctl/models.py +1 -1
- package/runtime/python/okstra_ctl/next_phase.py +16 -6
- package/runtime/python/okstra_ctl/operation_invocation.py +86 -0
- package/runtime/python/okstra_ctl/option_comparison.py +168 -0
- package/runtime/python/okstra_ctl/path_hints.py +9 -0
- package/runtime/python/okstra_ctl/paths.py +3 -0
- package/runtime/python/okstra_ctl/profile_show.py +42 -1
- package/runtime/python/okstra_ctl/registry/host_discovery.py +20 -12
- package/runtime/python/okstra_ctl/registry/host_registry.py +11 -0
- package/runtime/python/okstra_ctl/render.py +79 -0
- package/runtime/python/okstra_ctl/report_contract.py +1 -1
- package/runtime/python/okstra_ctl/report_html/view_models/release_handoff.py +21 -3
- package/runtime/python/okstra_ctl/report_synthesis_packet.py +177 -17
- package/runtime/python/okstra_ctl/report_translation.py +2 -1
- package/runtime/python/okstra_ctl/report_translation_dispatch.py +69 -9
- package/runtime/python/okstra_ctl/role_requirements.py +142 -129
- package/runtime/python/okstra_ctl/rollup.py +3 -1
- package/runtime/python/okstra_ctl/run.py +76 -29
- package/runtime/python/okstra_ctl/schedule_semantics.py +17 -6
- package/runtime/python/okstra_ctl/stage_fix_carry.py +23 -4
- package/runtime/python/okstra_ctl/stage_integrate.py +178 -18
- package/runtime/python/okstra_ctl/stage_map.py +16 -2
- package/runtime/python/okstra_ctl/stage_targets.py +209 -43
- package/runtime/python/okstra_ctl/team.py +22 -13
- package/runtime/python/okstra_ctl/time_report.py +2 -1
- package/runtime/python/okstra_ctl/usage_report.py +3 -1
- package/runtime/python/okstra_ctl/wizard/confirmation.py +3 -9
- package/runtime/python/okstra_ctl/wizard/ids.py +1 -1
- package/runtime/python/okstra_ctl/wizard/registry.py +1 -1
- package/runtime/python/okstra_ctl/wizard/state.py +3 -5
- package/runtime/python/okstra_ctl/wizard/steps_plan.py +12 -21
- package/runtime/python/okstra_ctl/worker_prompt_contract.py +5 -1
- package/runtime/python/okstra_ctl/worker_prompt_headers.py +35 -7
- package/runtime/python/okstra_ctl/worker_prompt_policy.py +66 -48
- package/runtime/python/okstra_ctl/workflow.py +1 -1
- package/runtime/python/okstra_ctl/worktree/__init__.py +3 -1
- package/runtime/python/okstra_ctl/worktree/naming.py +9 -0
- package/runtime/python/okstra_ctl/worktree_registry.py +38 -9
- package/runtime/python/okstra_token_usage/pricing.py +6 -4
- package/runtime/schemas/agent-common-v1.schema.json +34 -0
- package/runtime/schemas/agent-duty-v1.schema.json +38 -0
- package/runtime/schemas/agent-operation-v1.schema.json +11 -0
- package/runtime/schemas/agent-profile-v1.schema.json +46 -0
- package/runtime/schemas/agent-role-v1.schema.json +29 -0
- package/runtime/schemas/final-report-v2.0.schema.json +118 -97
- package/runtime/schemas/final-report-v3.0.schema.json +118 -97
- package/runtime/skills/okstra-brief-gen/SKILL.md +84 -4
- package/runtime/skills/okstra-chat/SKILL.md +2 -2
- package/runtime/skills/okstra-code-review/SKILL.md +23 -9
- package/runtime/skills/okstra-container-build/SKILL.md +10 -10
- package/runtime/skills/okstra-inspect/SKILL.md +1 -1
- package/runtime/skills/okstra-inspect/facets/cost.md +1 -1
- package/runtime/skills/okstra-inspect/facets/error-zip.md +9 -9
- package/runtime/skills/okstra-inspect/facets/errors.md +16 -16
- package/runtime/skills/okstra-inspect/facets/logs.md +7 -7
- package/runtime/skills/okstra-inspect/facets/recap.md +2 -2
- package/runtime/skills/okstra-inspect/facets/report.md +1 -1
- package/runtime/skills/okstra-inspect/facets/status.md +4 -3
- package/runtime/skills/okstra-inspect/facets/time.md +11 -10
- package/runtime/skills/okstra-manager/SKILL.md +18 -2
- package/runtime/skills/okstra-pr-gen/SKILL.md +6 -5
- package/runtime/skills/okstra-rollup/SKILL.md +5 -5
- package/runtime/skills/okstra-run/SKILL.md +31 -12
- package/runtime/skills/okstra-schedule-gen/SKILL.md +19 -14
- package/runtime/skills/okstra-setup/SKILL.md +12 -10
- package/runtime/skills/okstra-setup/references/project-config.md +7 -6
- package/runtime/skills/okstra-usage/SKILL.md +1 -1
- package/runtime/skills/okstra-user-response/SKILL.md +1 -1
- package/runtime/templates/manager/view.template.html +1 -0
- package/runtime/templates/report-writer-prompt-preamble.md +8 -0
- package/runtime/templates/reports/brief.template.md +14 -4
- package/runtime/templates/reports/html/i18n/en.json +5 -4
- package/runtime/templates/reports/html/i18n/ko.json +5 -4
- package/runtime/templates/reports/html/tasks/release-handoff.template.html +8 -5
- package/runtime/templates/reports/i18n/en.json +1 -1
- package/runtime/templates/reports/md/tasks/release-handoff.template.md +1 -1
- package/runtime/templates/reports/release-handoff-input.template.md +6 -4
- package/runtime/templates/translator-prompt-preamble.md +36 -0
- package/runtime/validators/checks/validate-assets-01.py +7 -8
- package/runtime/validators/validate-brief.py +70 -0
- package/runtime/validators/validate-implementation-plan-stages.py +2 -1
- package/runtime/validators/validate-run.py +72 -15
- package/runtime/validators/validate-schedule.py +9 -0
- package/docs/for-ai/README.md +0 -68
- package/docs/for-ai/skills/okstra-brief-gen.md +0 -262
- package/docs/for-ai/skills/okstra-chat.md +0 -34
- package/docs/for-ai/skills/okstra-code-review.md +0 -57
- package/docs/for-ai/skills/okstra-container-build.md +0 -129
- package/docs/for-ai/skills/okstra-inspect.md +0 -262
- package/docs/for-ai/skills/okstra-manager.md +0 -86
- package/docs/for-ai/skills/okstra-memory.md +0 -126
- package/docs/for-ai/skills/okstra-pr-gen.md +0 -49
- package/docs/for-ai/skills/okstra-rollup.md +0 -114
- package/docs/for-ai/skills/okstra-run.md +0 -250
- package/docs/for-ai/skills/okstra-schedule-gen.md +0 -240
- package/docs/for-ai/skills/okstra-setup.md +0 -167
- package/docs/for-ai/skills/okstra-usage.md +0 -29
- package/docs/for-ai/skills/okstra-user-response.md +0 -72
- package/runtime/agents/workers/claude-worker.md +0 -128
- package/runtime/agents/workers/report-writer-worker.md +0 -37
- package/runtime/agents/workers/translator-worker.md +0 -63
- package/runtime/prompts/duties/acceptance-critic.md +0 -44
- package/runtime/prompts/duties/acceptance-verifier.md +0 -44
- package/runtime/prompts/duties/analysis-worker.md +0 -44
- package/runtime/prompts/duties/code-reviewer.md +0 -44
- package/runtime/prompts/duties/common.md +0 -39
- package/runtime/prompts/duties/diagnosis-worker.md +0 -44
- package/runtime/prompts/duties/direction-selection-worker.md +0 -44
- package/runtime/prompts/duties/discovery-worker.md +0 -44
- package/runtime/prompts/duties/implementation-executor.md +0 -44
- package/runtime/prompts/duties/implementation-verifier.md +0 -44
- package/runtime/prompts/duties/lead.md +0 -44
- package/runtime/prompts/duties/planning-worker.md +0 -52
- package/runtime/prompts/duties/report-writer.md +0 -44
- package/runtime/prompts/duties/reverification-worker.md +0 -44
- package/runtime/prompts/duties/schedule-verifier.md +0 -44
- package/runtime/prompts/duties/scope-critic.md +0 -44
- package/runtime/prompts/duties/technical-verification-worker.md +0 -44
- package/runtime/prompts/duties/translator.md +0 -44
- package/runtime/python/okstra_ctl/pane_title.py +0 -154
|
@@ -1,8 +1,8 @@
|
|
|
1
|
-
"""release-handoff stage
|
|
1
|
+
"""release-handoff 의 강제 지점 — stage 하나가 PR 하나다.
|
|
2
2
|
|
|
3
|
-
자격 판정(eligible) ·
|
|
3
|
+
자격 판정(eligible) · stage 별 PR head/base 계산(pr-plan) · verified/pr 행 기록을
|
|
4
4
|
단일 모듈로 강제한다. lead 는 `okstra handoff <sub>` 로 호출만 한다.
|
|
5
|
-
설계: docs/
|
|
5
|
+
설계: .project-docs/specs/2026-09-24-release-handoff-per-stage-pr-design.md
|
|
6
6
|
"""
|
|
7
7
|
|
|
8
8
|
from __future__ import annotations
|
|
@@ -10,13 +10,13 @@ from __future__ import annotations
|
|
|
10
10
|
import json
|
|
11
11
|
import subprocess
|
|
12
12
|
from pathlib import Path
|
|
13
|
-
from typing import Any,
|
|
13
|
+
from typing import Any, Dict, List, Optional
|
|
14
14
|
|
|
15
15
|
from . import consumers, stage_targets, worktree_registry
|
|
16
|
-
from .final_report_paths import
|
|
16
|
+
from .final_report_paths import final_report_data_path
|
|
17
17
|
from .handoff_verification import (
|
|
18
18
|
VerificationEvidenceError, load_latest_verification,
|
|
19
|
-
|
|
19
|
+
verification_row_is_current,
|
|
20
20
|
)
|
|
21
21
|
from .paths import RunRef
|
|
22
22
|
from .json_boundary import JsonBoundaryError, load_owned_object
|
|
@@ -25,11 +25,16 @@ from .release_gate import (
|
|
|
25
25
|
release_handoff_allowed,
|
|
26
26
|
verdict_token,
|
|
27
27
|
)
|
|
28
|
+
from .stage_integrate import (IntegrateError, MergeBranchConflict,
|
|
29
|
+
ensure_stage_merge_branch)
|
|
28
30
|
from .stage_map import StageMapError, parse_stage_map_file, stage_map_records
|
|
29
|
-
from .worktree import (compute_branch_name,
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
31
|
+
from .worktree import (compute_branch_name, main_worktree_path,
|
|
32
|
+
is_dirty_excluding_okstra, nested_worktree_excludes,
|
|
33
|
+
is_ancestor, remove_worktree_force, _git)
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
SINGLE_STAGE_SCOPE = "single-stage"
|
|
37
|
+
WHOLE_TASK_SCOPE = "whole-task"
|
|
33
38
|
|
|
34
39
|
|
|
35
40
|
class HandoffError(Exception):
|
|
@@ -37,7 +42,7 @@ class HandoffError(Exception):
|
|
|
37
42
|
|
|
38
43
|
|
|
39
44
|
class HandoffConflict(Exception):
|
|
40
|
-
"""stage
|
|
45
|
+
"""선행 stage 들 사이의 merge 충돌 — exit 2, 충돌 경로 동봉."""
|
|
41
46
|
|
|
42
47
|
def __init__(self, stage: int, branch: str, paths: List[str]):
|
|
43
48
|
self.stage = stage
|
|
@@ -48,18 +53,18 @@ class HandoffConflict(Exception):
|
|
|
48
53
|
f"{', '.join(paths)}")
|
|
49
54
|
|
|
50
55
|
|
|
51
|
-
def group_id_for(stages: List[int]) -> str:
|
|
52
|
-
return "g" + "-".join(str(n) for n in sorted(stages))
|
|
53
|
-
|
|
54
|
-
|
|
55
56
|
def compute_eligibility(stage_map: List[Dict[str, Any]],
|
|
56
57
|
rows: List[Dict[str, Any]]) -> List[Dict[str, Any]]:
|
|
57
|
-
"""stage 별 PR 가능 여부와 차단 사유.
|
|
58
|
-
|
|
58
|
+
"""stage 별 PR 가능 여부와 차단 사유. 의존이 어느 브랜치로 내려앉는지는
|
|
59
|
+
선택 집합과 origin 의 상태가 정하므로 여기서는 판정하지 않는다
|
|
60
|
+
(`pr_plan` 의 base 해소가 담당)."""
|
|
59
61
|
eligibility = stage_targets.stage_lifecycle_snapshot_from_rows(
|
|
60
62
|
stage_map,
|
|
61
63
|
rows,
|
|
62
64
|
).handoff_eligibility()
|
|
65
|
+
# 재구현된 stage 의 커밋 동일성은 `consumers.verified_accepted_stages` 가
|
|
66
|
+
# 이미 본다(`handoff_eligibility` 가 그 집합을 읽는다). 여기 남는 판정은
|
|
67
|
+
# "그 승인 행이 가리키는 검증 run 이 아직 최신인가" 하나다.
|
|
63
68
|
last = {row["stage"]: row for row in rows if row.get("status") == "verified"}
|
|
64
69
|
for item in eligibility:
|
|
65
70
|
if item["eligible"] and not verification_row_is_current(last.get(item["stage"], {})):
|
|
@@ -68,50 +73,6 @@ def compute_eligibility(stage_map: List[Dict[str, Any]],
|
|
|
68
73
|
return eligibility
|
|
69
74
|
|
|
70
75
|
|
|
71
|
-
def latest_whole_task_fv_release_ready(project_root, project_id: str,
|
|
72
|
-
task_group: str, task_id: str) -> str:
|
|
73
|
-
"""release-handoff 로 넘어가도 되는 whole-task 검증 보고서 경로. 없으면 ''.
|
|
74
|
-
|
|
75
|
-
최신 실행을 먼저 고르고 대상·보고서·완료 판정을 확인한다.
|
|
76
|
-
실패한 최신 실행을 건너뛰어 과거 통과 보고서를 사용하지 않는다."""
|
|
77
|
-
from okstra_project.state import find_task_root
|
|
78
|
-
root = find_task_root(Path(project_root),
|
|
79
|
-
f"{project_id}:{task_group}:{task_id}")
|
|
80
|
-
if root is None:
|
|
81
|
-
return ""
|
|
82
|
-
try:
|
|
83
|
-
report, manifest, data = load_latest_verification(
|
|
84
|
-
RunRef.from_task_root(root, "final-verification").run_dir)
|
|
85
|
-
require_finished_verification(manifest, data)
|
|
86
|
-
if manifest.get("taskKey") != f"{project_id}:{task_group}:{task_id}":
|
|
87
|
-
return ""
|
|
88
|
-
return str(report)
|
|
89
|
-
except VerificationEvidenceError:
|
|
90
|
-
# expected-miss: 최신 검증 미완료·차단은 전체 인계 선택지가 없는 정상 분기다.
|
|
91
|
-
return ""
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
def check_dependency_closure(
|
|
95
|
-
selected: List[int],
|
|
96
|
-
stage_map: List[Dict[str, Any]],
|
|
97
|
-
done_by_stage: Dict[int, Dict[str, Any]],
|
|
98
|
-
is_merged_to_base: Callable[[str], bool],
|
|
99
|
-
) -> List[Tuple[int, int]]:
|
|
100
|
-
"""선택 집합의 의존 폐포 위반 목록 [(stage, 미충족 선행)].
|
|
101
|
-
선행이 같은 그룹에 선택됐거나 이미 base 에 머지된 경우만 허용."""
|
|
102
|
-
sel = set(selected)
|
|
103
|
-
by_n = {s["stage_number"]: s for s in stage_map}
|
|
104
|
-
violations = []
|
|
105
|
-
for n in sorted(selected):
|
|
106
|
-
for d in by_n[n]["depends_on"]:
|
|
107
|
-
if d in sel:
|
|
108
|
-
continue
|
|
109
|
-
head = (done_by_stage.get(d) or {}).get("head_commit", "")
|
|
110
|
-
if not head or not is_merged_to_base(head):
|
|
111
|
-
violations.append((n, d))
|
|
112
|
-
return violations
|
|
113
|
-
|
|
114
|
-
|
|
115
76
|
def _run_git(args: List[str], cwd, check: bool = True) -> subprocess.CompletedProcess:
|
|
116
77
|
"""worktree._git(공용 러너) 위에 check=True 시 HandoffError 변환만 얹는다."""
|
|
117
78
|
r = _git(Path(cwd), *args)
|
|
@@ -131,128 +92,275 @@ def _require_eligible(stage_map, rows, stages) -> Dict[int, Dict[str, Any]]:
|
|
|
131
92
|
return elig
|
|
132
93
|
|
|
133
94
|
|
|
134
|
-
def
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
violations = check_dependency_closure(stages, stage_map, done, merged)
|
|
138
|
-
if violations:
|
|
139
|
-
lines = [
|
|
140
|
-
f"stage {n} depends on stage {d} which is neither selected nor "
|
|
141
|
-
f"merged into origin/{base_branch} — include stage {d} in the "
|
|
142
|
-
"group or PR-merge it first"
|
|
143
|
-
for n, d in violations
|
|
144
|
-
]
|
|
145
|
-
raise HandoffError("dependency closure violated:\n" + "\n".join(lines))
|
|
95
|
+
def _branch_exists(project_root, branch: str) -> bool:
|
|
96
|
+
return _git(Path(project_root), "rev-parse", "--verify", "--quiet",
|
|
97
|
+
f"refs/heads/{branch}").returncode == 0
|
|
146
98
|
|
|
147
99
|
|
|
148
|
-
def
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
100
|
+
def _stage_branch(work_category: str, task_id: str, stage: int) -> str:
|
|
101
|
+
return compute_branch_name(work_category=work_category,
|
|
102
|
+
task_id_segment=task_id, stage_number=stage)
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def _done_head(done: Dict[int, Dict[str, Any]], stage: int) -> str:
|
|
106
|
+
head = (done.get(stage) or {}).get("head_commit", "")
|
|
107
|
+
if not head:
|
|
108
|
+
raise HandoffError(
|
|
109
|
+
f"stage {stage} has no done row with head_commit in "
|
|
110
|
+
"consumers.jsonl — finish that implementation stage first")
|
|
111
|
+
return head
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def _merge_base_for(*, project_root, project_id, task_group, task_id,
|
|
115
|
+
work_category, heads, anchor_base):
|
|
116
|
+
"""선행들을 담은 바닥 브랜치. 원시연산은 잠금 없이 돌므로 여기서 task-key
|
|
117
|
+
락을 잡는다 — implementation 경로는 이미 그 락 안에서 직접 부른다.
|
|
118
|
+
기존 브랜치에 빠진 commit 이 있으면 거부한다: 그 브랜치는 이미 열린 PR 의
|
|
119
|
+
base 일 수 있고, 말없이 옮기면 리뷰어가 본 diff 가 바뀐다."""
|
|
120
|
+
from okstra_project.dirs import okstra_home
|
|
121
|
+
|
|
122
|
+
from .locks import worktree_provision_mutex
|
|
123
|
+
|
|
124
|
+
try:
|
|
125
|
+
with worktree_provision_mutex(okstra_home(), project_id, task_group,
|
|
126
|
+
task_id):
|
|
127
|
+
return ensure_stage_merge_branch(
|
|
128
|
+
project_root=project_root, project_id=project_id,
|
|
129
|
+
task_group=task_group, task_id=task_id,
|
|
130
|
+
work_category=work_category, heads=heads,
|
|
131
|
+
anchor_base=anchor_base)
|
|
132
|
+
except MergeBranchConflict as exc:
|
|
133
|
+
raise HandoffConflict(stage=exc.stage, branch=exc.branch,
|
|
134
|
+
paths=exc.paths) from exc
|
|
135
|
+
except IntegrateError as exc:
|
|
136
|
+
raise HandoffError(str(exc)) from exc
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
def _resolve_stage_base(
|
|
140
|
+
*, stage: int, deps: List[int], selected: List[int], pr_covered: set,
|
|
141
|
+
done: Dict[int, Dict[str, Any]], project_root, base_branch: str,
|
|
142
|
+
work_category: str, task_id: str, project_id: str, task_group: str,
|
|
143
|
+
anchor_base: str,
|
|
144
|
+
) -> Dict[str, Any]:
|
|
145
|
+
"""한 stage 의 PR base. 이미 릴리스 base 에 들어간 선행은 base 를 올리지
|
|
146
|
+
않으므로 제외하고, 남은 선행의 개수가 모양을 정한다."""
|
|
147
|
+
live = [d for d in sorted(deps)
|
|
148
|
+
if not is_ancestor(project_root, _done_head(done, d),
|
|
149
|
+
f"origin/{base_branch}")]
|
|
150
|
+
if not live:
|
|
151
|
+
commit = _run_git(["rev-parse", f"origin/{base_branch}"],
|
|
152
|
+
project_root).stdout.strip()
|
|
153
|
+
return {"base_kind": "release-base", "base_branch": base_branch,
|
|
154
|
+
"base_commit": commit, "base_stages": []}
|
|
155
|
+
|
|
156
|
+
undelivered = [d for d in live if d not in selected and d not in pr_covered]
|
|
157
|
+
if undelivered:
|
|
158
|
+
raise HandoffError(
|
|
159
|
+
f"stage {stage} depends on stage(s) {undelivered}, which are "
|
|
160
|
+
f"neither selected in this run nor already PR'd nor merged into "
|
|
161
|
+
f"origin/{base_branch} — a stacked PR needs its base branch to "
|
|
162
|
+
"exist on origin, so include them in this handoff or merge them "
|
|
163
|
+
"first")
|
|
164
|
+
|
|
165
|
+
if len(live) == 1:
|
|
166
|
+
dep = live[0]
|
|
167
|
+
branch = _stage_branch(work_category, task_id, dep)
|
|
168
|
+
if not _branch_exists(project_root, branch):
|
|
153
169
|
raise HandoffError(
|
|
154
|
-
f"
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
def
|
|
170
|
-
|
|
171
|
-
"""
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
170
|
+
f"stage {stage} needs branch {branch} (stage {dep}) as its PR "
|
|
171
|
+
"base, but that branch no longer exists in this repository")
|
|
172
|
+
return {"base_kind": "stage", "base_branch": branch,
|
|
173
|
+
"base_commit": _done_head(done, dep), "base_stages": [dep]}
|
|
174
|
+
|
|
175
|
+
merged = _merge_base_for(
|
|
176
|
+
project_root=project_root, project_id=project_id,
|
|
177
|
+
task_group=task_group, task_id=task_id, work_category=work_category,
|
|
178
|
+
heads={n: _done_head(done, n) for n in live}, anchor_base=anchor_base)
|
|
179
|
+
return {"base_kind": "merge-base", "base_branch": merged.branch,
|
|
180
|
+
"base_commit": merged.commit, "base_stages": list(merged.stages),
|
|
181
|
+
"base_merge_commits": list(merged.merge_commits),
|
|
182
|
+
"base_branch_created": merged.created}
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
def _local_tip(project_root, branch: str) -> str:
|
|
186
|
+
"""로컬 브랜치의 현재 커밋. 없으면 빈 문자열."""
|
|
187
|
+
return _git(Path(project_root), "rev-parse", "--verify", "--quiet",
|
|
188
|
+
f"refs/heads/{branch}").stdout.strip()
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
def _remote_tips(project_root, branches: List[str]) -> Dict[str, str]:
|
|
192
|
+
"""origin 이 지금 가진 브랜치와 커밋. 없는 브랜치는 키가 없다.
|
|
193
|
+
|
|
194
|
+
`ls-remote` 로 묻는다 — 비교하려고 ref 를 쓰지 않고, 없는 브랜치를 fetch
|
|
195
|
+
하려다 실패하지도 않는다.
|
|
196
|
+
"""
|
|
197
|
+
if not branches:
|
|
198
|
+
return {}
|
|
199
|
+
res = _git(Path(project_root), "ls-remote", "--heads", "origin", *branches)
|
|
200
|
+
if res.returncode != 0:
|
|
201
|
+
raise HandoffError(f"cannot read origin branch state: {res.stderr.strip()}")
|
|
202
|
+
tips: Dict[str, str] = {}
|
|
203
|
+
for line in res.stdout.splitlines():
|
|
204
|
+
parts = line.split()
|
|
205
|
+
if len(parts) == 2 and parts[1].startswith("refs/heads/"):
|
|
206
|
+
tips[parts[1][len("refs/heads/"):]] = parts[0]
|
|
207
|
+
return tips
|
|
208
|
+
|
|
209
|
+
|
|
210
|
+
def _branch_worktree(project_root, branch: str) -> Optional[Path]:
|
|
211
|
+
"""그 브랜치를 체크아웃해 둔 워크트리. 없으면 None.
|
|
212
|
+
|
|
213
|
+
체크아웃된 브랜치는 `git branch -f` 로 옮길 수 없으므로, fast-forward 는 그
|
|
214
|
+
워크트리 안에서 해야 한다.
|
|
215
|
+
"""
|
|
216
|
+
res = _git(Path(project_root), "worktree", "list", "--porcelain")
|
|
217
|
+
if res.returncode != 0:
|
|
218
|
+
return None
|
|
219
|
+
current: Optional[Path] = None
|
|
220
|
+
for line in res.stdout.splitlines():
|
|
221
|
+
if line.startswith("worktree "):
|
|
222
|
+
current = Path(line[len("worktree "):].strip())
|
|
223
|
+
elif line.strip() == f"branch refs/heads/{branch}":
|
|
224
|
+
return current
|
|
225
|
+
return None
|
|
226
|
+
|
|
227
|
+
|
|
228
|
+
def _fast_forward_local(project_root, branch: str, remote_commit: str) -> None:
|
|
229
|
+
"""로컬 브랜치를 origin 의 커밋으로 fast-forward 한다.
|
|
230
|
+
|
|
231
|
+
호출자가 조상 관계를 이미 확인했으므로 내용이 사라질 수 없다. 체크아웃
|
|
232
|
+
상태면 그 워크트리에서 `merge --ff-only` 로, 아니면 ref 를 옮긴다.
|
|
233
|
+
"""
|
|
234
|
+
_run_git(["fetch", "origin", branch], project_root)
|
|
235
|
+
holder = _branch_worktree(project_root, branch)
|
|
236
|
+
if holder is None:
|
|
237
|
+
_run_git(["branch", "-f", branch, remote_commit], project_root)
|
|
238
|
+
return
|
|
239
|
+
if is_dirty_excluding_okstra(holder):
|
|
240
|
+
raise HandoffError(
|
|
241
|
+
f"branch {branch} is behind origin and is checked out in {holder}, "
|
|
242
|
+
"which has uncommitted changes — commit or stash them so the "
|
|
243
|
+
"branch can be fast-forwarded to origin")
|
|
244
|
+
_run_git(["merge", "--ff-only", remote_commit], holder)
|
|
245
|
+
|
|
246
|
+
|
|
247
|
+
# 계획 행이 부르는 브랜치를 실제로 푸시할 순서. merge-base 는 그것을 base 로
|
|
248
|
+
# 삼는 PR 보다 먼저 origin 에 있어야 한다(프로필 §"branch push" 와 같은 순서).
|
|
249
|
+
def _plan_branch_order(planned: List[Dict[str, Any]]) -> List[tuple[str, str]]:
|
|
250
|
+
ordered: List[tuple[str, str]] = []
|
|
251
|
+
seen: set[str] = set()
|
|
252
|
+
for row in planned:
|
|
253
|
+
if row["base_kind"] == "merge-base" and row["base_branch"] not in seen:
|
|
254
|
+
seen.add(row["base_branch"])
|
|
255
|
+
ordered.append((row["base_branch"], row["base_commit"]))
|
|
256
|
+
for row in planned:
|
|
257
|
+
if row["head_branch"] not in seen:
|
|
258
|
+
seen.add(row["head_branch"])
|
|
259
|
+
ordered.append((row["head_branch"], row["head_commit"]))
|
|
260
|
+
return ordered
|
|
261
|
+
|
|
262
|
+
|
|
263
|
+
def _reconcile_with_origin(
|
|
264
|
+
project_root, planned: List[Dict[str, Any]],
|
|
265
|
+
) -> tuple[List[Dict[str, Any]], List[str]]:
|
|
266
|
+
"""PR 이 올라앉을 브랜치를 origin 기준으로 맞춘다.
|
|
267
|
+
|
|
268
|
+
PR 의 head 와 base 는 GitHub 이 **이름으로 origin 에서** 찾는다. 계획이
|
|
269
|
+
로컬 ref 만 보고 짜이던 동안, origin 의 같은 이름이 다른 커밋에 있어도
|
|
270
|
+
계획은 그 사실을 몰랐다 — push 단계에서 거절되거나, PR 의 diff 가 계획서의
|
|
271
|
+
커밋과 달랐다.
|
|
272
|
+
|
|
273
|
+
네 가지 상태를 가른다. origin 에 없거나 뒤처져 있으면 push 가 해결하므로
|
|
274
|
+
무엇을 어떤 순서로 올려야 하는지만 계획에 싣는다. origin 이 앞서 있으면
|
|
275
|
+
로컬을 fast-forward 하고, 그 추가 커밋이 이 run 이 검증한 범위 밖이라는
|
|
276
|
+
사실을 경고로 남긴다. 갈라져 있으면 거절한다 — 그 상태를 push 로 넘기는
|
|
277
|
+
길은 force 뿐이고, 그건 열린 PR 의 리뷰 커밋을 지운다.
|
|
278
|
+
"""
|
|
279
|
+
order = _plan_branch_order(planned)
|
|
280
|
+
tips = _remote_tips(project_root, [branch for branch, _ in order])
|
|
281
|
+
push_plan: List[Dict[str, Any]] = []
|
|
282
|
+
warnings: List[str] = []
|
|
283
|
+
for branch, recorded in order:
|
|
284
|
+
local = _local_tip(project_root, branch)
|
|
285
|
+
if not local:
|
|
188
286
|
raise HandoffError(
|
|
189
|
-
f"
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
287
|
+
f"branch {branch} does not exist in this repository — there is "
|
|
288
|
+
"nothing to open a PR from")
|
|
289
|
+
# tip 이 기록된 커밋보다 앞선 것은 정상 상태다 — git-reconcile 이 done
|
|
290
|
+
# 이후의 커밋을 그 브랜치에 남긴다. 담기지 않은 것만 거절한다: 검증한
|
|
291
|
+
# 커밋이 브랜치에 없으면 그 PR 은 검증된 것을 나르지 않는다.
|
|
292
|
+
if recorded and local != recorded:
|
|
293
|
+
if not is_ancestor(project_root, recorded, local):
|
|
294
|
+
raise HandoffError(
|
|
295
|
+
f"branch {branch} is at {local}, which does not contain the "
|
|
296
|
+
f"commit this task's ledger verified for it ({recorded}) — "
|
|
297
|
+
"the branch was rewritten, so its PR would not carry the "
|
|
298
|
+
"verified work")
|
|
299
|
+
warnings.append(
|
|
300
|
+
f"branch {branch} is ahead of the commit this run verified "
|
|
301
|
+
f"({recorded} -> {local}); the commits it adds are outside the "
|
|
302
|
+
"verified scope")
|
|
303
|
+
remote = tips.get(branch)
|
|
304
|
+
if remote is None:
|
|
305
|
+
action = "create"
|
|
306
|
+
elif remote == local:
|
|
307
|
+
action = "up-to-date"
|
|
308
|
+
elif is_ancestor(project_root, remote, local):
|
|
309
|
+
action = "fast-forward"
|
|
310
|
+
elif is_ancestor(project_root, local, remote):
|
|
311
|
+
_fast_forward_local(project_root, branch, remote)
|
|
312
|
+
action = "up-to-date"
|
|
313
|
+
warnings.append(
|
|
314
|
+
f"branch {branch} was behind origin ({local} -> {remote}) and "
|
|
315
|
+
"was fast-forwarded to it; the commits origin adds are outside "
|
|
316
|
+
f"what this run verified ({recorded or local})")
|
|
317
|
+
else:
|
|
318
|
+
raise HandoffError(
|
|
319
|
+
f"branch {branch} and origin/{branch} have diverged (local "
|
|
320
|
+
f"{local}, origin {remote}) — only a force push could deliver "
|
|
321
|
+
"this branch, which would drop the commits origin holds")
|
|
322
|
+
push_plan.append({"branch": branch, "commit": local, "action": action})
|
|
323
|
+
return push_plan, warnings
|
|
324
|
+
|
|
325
|
+
|
|
326
|
+
def pr_plan(*, project_root, plan_run_root, stage_map, stages, base_branch,
|
|
327
|
+
work_category, project_id, task_group, task_id) -> Dict[str, Any]:
|
|
328
|
+
"""선택한 stage 각각의 PR head/base 를 정하고, 필요한 선행 머지 브랜치를
|
|
329
|
+
만든다. PR 은 stage 당 하나이고 head 는 그 stage 의 스택 브랜치다 — 커밋
|
|
330
|
+
히스토리를 접지 않으므로 이 경로는 stage 브랜치를 고쳐 쓰지 않는다."""
|
|
203
331
|
stages = sorted(set(stages))
|
|
204
332
|
rows = consumers.read_consumers(Path(plan_run_root))
|
|
205
333
|
_require_eligible(stage_map, rows, stages)
|
|
206
334
|
_run_git(["fetch", "origin", base_branch], project_root)
|
|
207
335
|
done = consumers.latest_done_by_stage(rows)
|
|
208
|
-
|
|
336
|
+
pr_covered = consumers.pr_covered_stages(rows)
|
|
337
|
+
by_n = {s["stage_number"]: s for s in stage_map}
|
|
338
|
+
anchor_base = worktree_registry.get_implementation_base(
|
|
339
|
+
project_id, task_group, task_id) or ""
|
|
209
340
|
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
worktree_registry.reserve(
|
|
234
|
-
project_id=project_id, task_group=task_group, task_id=task_id,
|
|
235
|
-
worktree_path=str(wt_path), branch=branch, base_ref=base_commit,
|
|
236
|
-
phase="release-handoff", group_id=gid, stages=stages)
|
|
237
|
-
except RuntimeError as exc:
|
|
238
|
-
raise HandoffError(str(exc))
|
|
239
|
-
try:
|
|
240
|
-
_run_git(["worktree", "add", "-b", branch, str(wt_path), base_commit],
|
|
241
|
-
project_root)
|
|
242
|
-
merge_commits = _merge_stages(wt_path, stages, work_category, task_id,
|
|
243
|
-
project_root, project_id, task_group, gid,
|
|
244
|
-
branch, done)
|
|
245
|
-
except HandoffConflict:
|
|
246
|
-
raise # _merge_stages 가 이미 _cleanup_group 으로 예약·worktree 를 되돌림
|
|
247
|
-
except Exception:
|
|
248
|
-
# worktree add 실패 등 비-충돌 경로 — 예약이 orphan 으로 남지 않도록 역전
|
|
249
|
-
_cleanup_group(project_root, wt_path, branch, project_id, task_group,
|
|
250
|
-
task_id, gid)
|
|
251
|
-
raise
|
|
252
|
-
head = merge_commits[-1] if merge_commits else base_commit
|
|
253
|
-
return {"ok": True, "reused": False, "group_id": gid, "branch": branch,
|
|
254
|
-
"worktree_path": str(wt_path), "head": head, "stages": stages,
|
|
255
|
-
"merge_commits": merge_commits}
|
|
341
|
+
planned: List[Dict[str, Any]] = []
|
|
342
|
+
for n in stages:
|
|
343
|
+
head_branch = _stage_branch(work_category, task_id, n)
|
|
344
|
+
if not _branch_exists(project_root, head_branch):
|
|
345
|
+
raise HandoffError(
|
|
346
|
+
f"stage {n} branch {head_branch} does not exist in this "
|
|
347
|
+
"repository — it was deleted, so there is nothing to open a "
|
|
348
|
+
"PR from")
|
|
349
|
+
row = {
|
|
350
|
+
"stage": n,
|
|
351
|
+
"head_branch": head_branch,
|
|
352
|
+
"head_commit": _done_head(done, n),
|
|
353
|
+
**_resolve_stage_base(
|
|
354
|
+
stage=n, deps=list(by_n[n]["depends_on"]), selected=stages,
|
|
355
|
+
pr_covered=pr_covered, done=done, project_root=project_root,
|
|
356
|
+
base_branch=base_branch, work_category=work_category,
|
|
357
|
+
task_id=task_id, project_id=project_id, task_group=task_group,
|
|
358
|
+
anchor_base=anchor_base),
|
|
359
|
+
}
|
|
360
|
+
planned.append(row)
|
|
361
|
+
push_plan, warnings = _reconcile_with_origin(project_root, planned)
|
|
362
|
+
return {"ok": True, "release_base": base_branch, "stages": planned,
|
|
363
|
+
"push_plan": push_plan, "warnings": warnings}
|
|
256
364
|
|
|
257
365
|
|
|
258
366
|
def _remove_task_worktree(main_wt, target: str) -> Optional[str]:
|
|
@@ -273,70 +381,17 @@ def _remove_task_worktree(main_wt, target: str) -> Optional[str]:
|
|
|
273
381
|
return None
|
|
274
382
|
|
|
275
383
|
|
|
276
|
-
def
|
|
277
|
-
|
|
278
|
-
"""
|
|
384
|
+
def _local_checkout_target(project_id, task_group, task_id,
|
|
385
|
+
stage: int) -> tuple:
|
|
386
|
+
"""Resolve (branch, worktree_path) for a stage's local checkout. The
|
|
387
|
+
returned path is "" only when the row carries no `worktree_path`; teardown
|
|
388
|
+
does not clear it, so a torn-down stage yields a stale path to a directory
|
|
389
|
+
that no longer exists. The caller must probe the path before acting on it.
|
|
279
390
|
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
매니페스트는 판정을 열어 두지 않고 그대로 오류로 올린다 — 점유 여부를 모르는
|
|
283
|
-
채 워크트리를 지우는 것보다 한 번 더 묻는 편이 낫다.
|
|
284
|
-
"""
|
|
285
|
-
from .paths import task_runs_dir
|
|
286
|
-
|
|
287
|
-
runs = task_runs_dir(Path(project_root), task_group, task_id)
|
|
288
|
-
if not runs.is_dir():
|
|
289
|
-
return []
|
|
290
|
-
live: List[str] = []
|
|
291
|
-
for manifest in sorted(runs.rglob("run-manifest-*.json")):
|
|
292
|
-
if manifest.parent.name != "manifests":
|
|
293
|
-
continue
|
|
294
|
-
try:
|
|
295
|
-
data = load_owned_object(manifest, artifact="run manifest")
|
|
296
|
-
except JsonBoundaryError as exc:
|
|
297
|
-
raise HandoffError(
|
|
298
|
-
f"run 매니페스트를 읽을 수 없어 점유 여부를 판정할 수 없습니다 "
|
|
299
|
-
f"({manifest}): {exc}") from exc
|
|
300
|
-
if not isinstance(data, dict) or data.get("status") != "in-progress":
|
|
301
|
-
continue
|
|
302
|
-
if data.get("taskType") == exclude_task_type:
|
|
303
|
-
continue
|
|
304
|
-
live.append(f"{data.get('taskType')} seq {manifest.stem.rsplit('-', 1)[-1]}")
|
|
305
|
-
return live
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
def _local_checkout_target(project_root, project_id, task_group, task_id,
|
|
309
|
-
stage: Optional[int]) -> Tuple[str, str]:
|
|
310
|
-
"""Resolve (branch, worktree_path) for a local checkout. `stage` selects
|
|
311
|
-
the stage-key row; None selects the task-key row. The returned path is ""
|
|
312
|
-
only when the row carries no `worktree_path`; teardown does not clear it,
|
|
313
|
-
so a torn-down stage yields a stale path to a directory that no longer
|
|
314
|
-
exists. The caller must probe the path before acting on it.
|
|
315
|
-
|
|
316
|
-
Occupancy is judged differently for the two row kinds. A stage row's
|
|
317
|
-
`status == "active"` is a live reservation: done-time release flips it
|
|
318
|
-
(`consumers._release_stage_occupancy_keeping_branch`,
|
|
391
|
+
A stage row's `status == "active"` is a live reservation: done-time release
|
|
392
|
+
flips it (`consumers._release_stage_occupancy_keeping_branch`,
|
|
319
393
|
`stage_integrate._teardown_stage`), and `list_active_stage_numbers` feeds the
|
|
320
|
-
stage resolver, so an active stage row means a run holds it.
|
|
321
|
-
row has no such lifecycle — `reserve` writes `active` when the worktree is
|
|
322
|
-
created and nothing but this checkout ever releases it (observed 2026-09-07,
|
|
323
|
-
fontsninja-v3-site dev-10626: the row stayed `active` from 09-02 through five
|
|
324
|
-
completed phases, so whole-task local checkout could never be reached and
|
|
325
|
-
its "retry after the run ends" message could never come true). For the
|
|
326
|
-
task-key row the occupancy question is therefore asked of the runs
|
|
327
|
-
themselves: another run of this task still `in-progress` refuses the
|
|
328
|
-
checkout; the release-handoff run that is asking does not count."""
|
|
329
|
-
if stage is None:
|
|
330
|
-
entry = worktree_registry.lookup(project_id, task_group, task_id)
|
|
331
|
-
if not entry or not entry.branch or not entry.worktree_path:
|
|
332
|
-
raise HandoffError(
|
|
333
|
-
"task-key worktree registry 엔트리가 없습니다 — local checkout 대상 없음")
|
|
334
|
-
live = _runs_in_progress(project_root, task_group, task_id)
|
|
335
|
-
if live:
|
|
336
|
-
raise HandoffError(
|
|
337
|
-
"task 워크트리를 다른 run 이 사용 중입니다 (in-progress: "
|
|
338
|
-
f"{', '.join(live)}) — 그 run 이 끝난 뒤 다시 시도하세요")
|
|
339
|
-
return entry.branch, entry.worktree_path
|
|
394
|
+
stage resolver, so an active stage row means a run holds it."""
|
|
340
395
|
row = worktree_registry.get_stage_row(project_id, task_group, task_id, stage)
|
|
341
396
|
if not row or not row.get("branch"):
|
|
342
397
|
raise HandoffError(
|
|
@@ -350,13 +405,12 @@ def _local_checkout_target(project_root, project_id, task_group, task_id,
|
|
|
350
405
|
|
|
351
406
|
|
|
352
407
|
def local_checkout(*, project_root, project_id, task_group, task_id,
|
|
353
|
-
stage:
|
|
354
|
-
"""okstra 워크트리를 제거하고
|
|
355
|
-
(브랜치 보존).
|
|
356
|
-
브랜치가 대상이다. 메인·대상 워크트리가 dirty 면 거부. base 브랜치는
|
|
408
|
+
stage: int) -> Dict[str, Any]:
|
|
409
|
+
"""그 stage 의 okstra 워크트리를 제거하고 stage 브랜치를 메인 워크트리에
|
|
410
|
+
checkout 한다(브랜치 보존). 메인·대상 워크트리가 dirty 면 거부. base 브랜치는
|
|
357
411
|
건드리지 않는다."""
|
|
358
412
|
branch, wt_path = _local_checkout_target(
|
|
359
|
-
|
|
413
|
+
project_id, task_group, task_id, stage)
|
|
360
414
|
main_wt = main_worktree_path(Path(project_root))
|
|
361
415
|
if is_dirty_excluding_okstra(main_wt):
|
|
362
416
|
raise HandoffError(
|
|
@@ -407,26 +461,20 @@ def _impl_task_key_for(rows: List[Dict[str, Any]], stage: int) -> str:
|
|
|
407
461
|
return row.get("impl_task_key", "")
|
|
408
462
|
|
|
409
463
|
|
|
410
|
-
def _impl_task_key_for_any(rows: List[Dict[str, Any]], stages: List[int]) -> str:
|
|
411
|
-
"""task 를 식별하는 키 — stages 중 done 행이 있는 아무 stage 에서나 승계한다.
|
|
412
|
-
PR 기록은 특정 stage 의 done 에 묶일 이유가 없으므로 최저 stage 가 비어도
|
|
413
|
-
다른 stage 의 done 으로 충족한다."""
|
|
414
|
-
done = consumers.latest_done_by_stage(rows)
|
|
415
|
-
for n in sorted(stages):
|
|
416
|
-
row = done.get(n)
|
|
417
|
-
if row:
|
|
418
|
-
return row.get("impl_task_key", "")
|
|
419
|
-
raise HandoffError(
|
|
420
|
-
f"none of stages {sorted(stages)} have a done row in consumers.jsonl — "
|
|
421
|
-
"finish at least one implementation stage first")
|
|
422
|
-
|
|
423
|
-
|
|
424
464
|
def record_verified(*, plan_run_root, stage: int, report_path: str,
|
|
425
465
|
data_json) -> Dict[str, Any]:
|
|
426
|
-
"""릴리스로 넘어갈 수 있는
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
466
|
+
"""릴리스로 넘어갈 수 있는 검증 판정을 stage 별로 기록.
|
|
467
|
+
|
|
468
|
+
범위는 둘 다 받는다. 단독-stage 판정은 그 stage 만의 근거이고, 전체 task
|
|
469
|
+
판정은 자기 `stageReports` 에 실린 stage 들의 근거다 — 검증 대상이 tip
|
|
470
|
+
stage 의 브랜치이므로(설계 결정 7) 그 브랜치는 완료된 다른 stage 의 커밋을
|
|
471
|
+
담고 있고, 그 사실은 커밋 조상 관계로 확인한다. 전체 task 로 검증한 task 가
|
|
472
|
+
stage 별 자격을 얻을 길이 없던 동안, 모든 stage 를 검증해 놓고도 stage 마다
|
|
473
|
+
단독 검증을 다시 돌려야 PR 을 열 수 있었다(2026-09-24, dev-10860).
|
|
474
|
+
|
|
475
|
+
data.json 의 taskType/scope/verdict 를 검증해 lead 가 임의 보고서를
|
|
476
|
+
verified 로 올리는 것을 막는다. 기록되는 verdict 는 보고서가 실제로 실은
|
|
477
|
+
토큰이다."""
|
|
430
478
|
try:
|
|
431
479
|
data = load_owned_object(Path(data_json), artifact="final verification report")
|
|
432
480
|
except JsonBoundaryError as exc:
|
|
@@ -434,10 +482,10 @@ def record_verified(*, plan_run_root, stage: int, report_path: str,
|
|
|
434
482
|
if (data.get("header") or {}).get("taskType") != "final-verification":
|
|
435
483
|
raise HandoffError("data.json is not a final-verification report")
|
|
436
484
|
scope = data.get("verificationScope")
|
|
437
|
-
if scope
|
|
485
|
+
if scope not in (SINGLE_STAGE_SCOPE, WHOLE_TASK_SCOPE):
|
|
438
486
|
raise HandoffError(
|
|
439
|
-
f"record-verified requires verificationScope
|
|
440
|
-
f"got {scope!r}")
|
|
487
|
+
f"record-verified requires verificationScope "
|
|
488
|
+
f"{SINGLE_STAGE_SCOPE} or {WHOLE_TASK_SCOPE}, got {scope!r}")
|
|
441
489
|
token = verdict_token(data)
|
|
442
490
|
if not release_handoff_allowed(data):
|
|
443
491
|
blocking = blocking_condition_ids(data)
|
|
@@ -450,17 +498,23 @@ def record_verified(*, plan_run_root, stage: int, report_path: str,
|
|
|
450
498
|
f"condition declares `blocksReleaseHandoff: false` — {detail}")
|
|
451
499
|
rows = consumers.read_consumers(Path(plan_run_root))
|
|
452
500
|
key = _impl_task_key_for(rows, stage)
|
|
453
|
-
_record_verified_target(Path(plan_run_root), stage, key, report_path,
|
|
501
|
+
_record_verified_target(Path(plan_run_root), stage, key, report_path,
|
|
502
|
+
Path(data_json), data, rows)
|
|
454
503
|
return {"ok": True, "stage": stage, "report_path": report_path}
|
|
455
504
|
|
|
456
505
|
|
|
457
506
|
def _record_verified_target(plan_run_root: Path, stage: int, key: str,
|
|
458
|
-
report_path: str, data_json: Path, data: dict
|
|
507
|
+
report_path: str, data_json: Path, data: dict,
|
|
508
|
+
rows: List[Dict[str, Any]]) -> None:
|
|
459
509
|
"""검증한 실행과 커밋을 입증한 뒤 같은 원장 잠금 안에서 기록한다."""
|
|
460
510
|
try:
|
|
461
511
|
ref = RunRef.from_run_dir(Path(plan_run_root).resolve()).sibling("final-verification")
|
|
462
|
-
|
|
463
|
-
|
|
512
|
+
scope = str(data.get("verificationScope") or "")
|
|
513
|
+
# 전체 task 검증은 stage 범위 run 이 아니라 task 범위 run 에 산다.
|
|
514
|
+
verification_ref = RunRef.from_task_root(
|
|
515
|
+
ref.task_root, ref.task_type,
|
|
516
|
+
stage=stage if scope == SINGLE_STAGE_SCOPE else None)
|
|
517
|
+
latest, manifest, verified_data = load_latest_verification(verification_ref.run_dir)
|
|
464
518
|
supplied = final_report_data_path(Path(data_json).resolve())
|
|
465
519
|
report_arg = Path(report_path)
|
|
466
520
|
if not report_arg.is_absolute():
|
|
@@ -468,25 +522,68 @@ def _record_verified_target(plan_run_root: Path, stage: int, key: str,
|
|
|
468
522
|
if (latest != supplied or final_report_data_path(report_arg.resolve()) != latest
|
|
469
523
|
or manifest.get("taskKey") != key or verified_data != data):
|
|
470
524
|
raise VerificationEvidenceError("report does not match latest verification of this task/stage")
|
|
525
|
+
captured = data["finalVerification"]["sourceImplementationReport"]["capturedHeadSha"]
|
|
526
|
+
extra: Dict[str, Any] = {}
|
|
527
|
+
head_commit = captured
|
|
528
|
+
if scope == WHOLE_TASK_SCOPE:
|
|
529
|
+
head_commit = _whole_task_stage_head(
|
|
530
|
+
rows, stage, captured,
|
|
531
|
+
Path(str(manifest.get("projectRoot") or "")), data)
|
|
532
|
+
extra = {"verification_scope": WHOLE_TASK_SCOPE,
|
|
533
|
+
"verified_head_commit": captured}
|
|
471
534
|
consumers.append_verified(
|
|
472
535
|
Path(plan_run_root), impl_task_key=key, stage=stage, verdict=verdict_token(data),
|
|
473
536
|
report_path=str(latest),
|
|
474
|
-
head_commit=
|
|
537
|
+
head_commit=head_commit,
|
|
475
538
|
final_verdict=data["finalVerdict"],
|
|
539
|
+
**extra,
|
|
476
540
|
)
|
|
477
541
|
except ValueError as exc:
|
|
478
542
|
raise HandoffError(str(exc)) from exc
|
|
479
543
|
|
|
480
544
|
|
|
481
|
-
def
|
|
545
|
+
def _whole_task_stage_head(rows: List[Dict[str, Any]], stage: int, captured: str,
|
|
546
|
+
project_root: Path, data: dict) -> str:
|
|
547
|
+
"""전체 task 검증이 이 stage 를 담았는지 확인하고, 원장에 적을 커밋을 낸다.
|
|
548
|
+
|
|
549
|
+
`stageReports` 는 보고서 자신의 주장이다. 그 stage 가 완료로 기록한 커밋이
|
|
550
|
+
검증된 head 안에 있는지까지 봐야 사실이 된다 — 검증 뒤에 갈라진 커밋은
|
|
551
|
+
검증되지 않았고, 조상 관계가 그것을 가른다.
|
|
552
|
+
|
|
553
|
+
원장에 적는 것은 검증된 head 가 아니라 이 stage 자신의 완료 커밋이다. 그
|
|
554
|
+
값이 `append_verified` 가 요구하는 "완료된 구현과 같은 커밋" 이고, PR head
|
|
555
|
+
도 그 커밋이다. 검증된 head 는 `verified_head_commit` 으로 따로 남는다.
|
|
556
|
+
"""
|
|
557
|
+
reported = (data.get("finalVerification") or {}).get("stageReports") or ()
|
|
558
|
+
covered = sorted(
|
|
559
|
+
row.get("stage") for row in reported
|
|
560
|
+
if isinstance(row, dict) and isinstance(row.get("stage"), int)
|
|
561
|
+
)
|
|
562
|
+
if stage not in covered:
|
|
563
|
+
raise HandoffError(
|
|
564
|
+
f"whole-task verification does not report stage {stage} — "
|
|
565
|
+
f"it covers {covered}")
|
|
566
|
+
head = (consumers.latest_done_by_stage(rows).get(stage) or {}).get("head_commit") or ""
|
|
567
|
+
if not head:
|
|
568
|
+
raise HandoffError(
|
|
569
|
+
f"stage {stage} has no completed implementation commit to verify")
|
|
570
|
+
if head != captured and not is_ancestor(project_root, head, captured):
|
|
571
|
+
raise HandoffError(
|
|
572
|
+
f"stage {stage} completed at {head}, which the verified head "
|
|
573
|
+
f"{captured} does not contain — re-run final-verification on a "
|
|
574
|
+
"target that includes this stage")
|
|
575
|
+
return head
|
|
576
|
+
|
|
577
|
+
|
|
578
|
+
def record_pr(*, plan_run_root, stage: int, branch: str, base: str,
|
|
482
579
|
url: str) -> Dict[str, Any]:
|
|
483
|
-
|
|
484
|
-
raise HandoffError("record-pr requires at least one stage")
|
|
580
|
+
"""한 stage 의 PR 기록. PR 은 stage 당 하나이므로 행도 stage 당 하나다."""
|
|
485
581
|
rows = consumers.read_consumers(Path(plan_run_root))
|
|
486
|
-
key =
|
|
582
|
+
key = _impl_task_key_for(rows, stage)
|
|
487
583
|
consumers.append_pr(Path(plan_run_root), impl_task_key=key,
|
|
488
|
-
stages=
|
|
489
|
-
return {"ok": True, "
|
|
584
|
+
stages=[stage], branch=branch, base=base, url=url)
|
|
585
|
+
return {"ok": True, "stage": stage, "branch": branch, "base": base,
|
|
586
|
+
"url": url}
|
|
490
587
|
|
|
491
588
|
|
|
492
589
|
def _parse_stages_csv(raw: str) -> List[int]:
|
|
@@ -503,19 +600,19 @@ _CLI_EPILOG = r"""JSON 출력.
|
|
|
503
600
|
|
|
504
601
|
Usage:
|
|
505
602
|
okstra handoff eligible --plan-run-root <dir> --approved-plan <md>
|
|
506
|
-
okstra handoff
|
|
603
|
+
okstra handoff pr-plan --plan-run-root <dir> --approved-plan <md> \
|
|
507
604
|
--project-root <dir> --project-id <id> --task-group <g> --task-id <t> \
|
|
508
605
|
--work-category <c> --stages 2,3 --base <branch>
|
|
509
606
|
okstra handoff record-verified --plan-run-root <dir> --stage <N> \
|
|
510
|
-
--report-path <
|
|
511
|
-
okstra handoff record-pr --plan-run-root <dir> --
|
|
512
|
-
--branch <b> --url <u>
|
|
607
|
+
--report-path <report> --data-json <json>
|
|
608
|
+
okstra handoff record-pr --plan-run-root <dir> --stage <N> \
|
|
609
|
+
--branch <b> --base <b> --url <u>
|
|
513
610
|
okstra handoff local-checkout --project-root <dir> --project-id <id> \
|
|
514
|
-
--task-group <g> --task-id <t>
|
|
611
|
+
--task-group <g> --task-id <t> --stage <N>
|
|
515
612
|
|
|
516
|
-
Exit codes: 0 ok / 1 자격·전제 위반 / 2 stage 간 merge 충돌(conflicts 동봉)
|
|
613
|
+
Exit codes: 0 ok / 1 자격·전제 위반 / 2 선행 stage 간 merge 충돌(conflicts 동봉)
|
|
517
614
|
"""
|
|
518
|
-
_CLI_DESCRIPTION = "Release-handoff stage
|
|
615
|
+
_CLI_DESCRIPTION = "Release-handoff stage 단위 보조 (자격/PR 계획/기록)."
|
|
519
616
|
|
|
520
617
|
|
|
521
618
|
def main(argv: Optional[list] = None) -> int:
|
|
@@ -536,7 +633,7 @@ def main(argv: Optional[list] = None) -> int:
|
|
|
536
633
|
common(sp)
|
|
537
634
|
sp.add_argument("--approved-plan", required=True)
|
|
538
635
|
|
|
539
|
-
sp = sub.add_parser("
|
|
636
|
+
sp = sub.add_parser("pr-plan")
|
|
540
637
|
common(sp)
|
|
541
638
|
sp.add_argument("--approved-plan", required=True)
|
|
542
639
|
sp.add_argument("--project-root", default=".")
|
|
@@ -555,8 +652,9 @@ def main(argv: Optional[list] = None) -> int:
|
|
|
555
652
|
|
|
556
653
|
sp = sub.add_parser("record-pr")
|
|
557
654
|
common(sp)
|
|
558
|
-
sp.add_argument("--
|
|
655
|
+
sp.add_argument("--stage", type=int, required=True)
|
|
559
656
|
sp.add_argument("--branch", required=True)
|
|
657
|
+
sp.add_argument("--base", required=True)
|
|
560
658
|
sp.add_argument("--url", required=True)
|
|
561
659
|
|
|
562
660
|
sp = sub.add_parser("local-checkout")
|
|
@@ -565,7 +663,7 @@ def main(argv: Optional[list] = None) -> int:
|
|
|
565
663
|
sp.add_argument("--project-id", required=True)
|
|
566
664
|
sp.add_argument("--task-group", required=True)
|
|
567
665
|
sp.add_argument("--task-id", required=True)
|
|
568
|
-
sp.add_argument("--stage", type=int,
|
|
666
|
+
sp.add_argument("--stage", type=int, required=True)
|
|
569
667
|
|
|
570
668
|
a = p.parse_args(argv)
|
|
571
669
|
try:
|
|
@@ -573,8 +671,8 @@ def main(argv: Optional[list] = None) -> int:
|
|
|
573
671
|
stage_map = stage_map_records(parse_stage_map_file(Path(a.approved_plan)))
|
|
574
672
|
rows = consumers.read_consumers(Path(a.plan_run_root))
|
|
575
673
|
out = {"stages": compute_eligibility(stage_map, rows)}
|
|
576
|
-
elif a.cmd == "
|
|
577
|
-
out =
|
|
674
|
+
elif a.cmd == "pr-plan":
|
|
675
|
+
out = pr_plan(
|
|
578
676
|
project_root=Path(a.project_root).resolve(),
|
|
579
677
|
plan_run_root=Path(a.plan_run_root),
|
|
580
678
|
stage_map=stage_map_records(parse_stage_map_file(Path(a.approved_plan))),
|
|
@@ -592,8 +690,8 @@ def main(argv: Optional[list] = None) -> int:
|
|
|
592
690
|
task_id=a.task_id, stage=a.stage)
|
|
593
691
|
else:
|
|
594
692
|
out = record_pr(plan_run_root=Path(a.plan_run_root),
|
|
595
|
-
|
|
596
|
-
|
|
693
|
+
stage=a.stage, branch=a.branch, base=a.base,
|
|
694
|
+
url=a.url)
|
|
597
695
|
except HandoffConflict as exc:
|
|
598
696
|
print(json.dumps({"error": str(exc), "stage": exc.stage,
|
|
599
697
|
"branch": exc.branch, "conflicts": exc.paths},
|