okstra 0.218.0 → 0.219.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 (92) hide show
  1. package/README.md +5 -5
  2. package/dist/cli-registry.mjs +0 -10
  3. package/dist/cli-registry.mjs.map +1 -1
  4. package/dist/commands/lifecycle/config.mjs +2 -3
  5. package/dist/commands/lifecycle/config.mjs.map +1 -1
  6. package/dist/commands/pr/pr.d.mts +6 -0
  7. package/dist/commands/pr/pr.mjs +58 -0
  8. package/dist/commands/pr/pr.mjs.map +1 -1
  9. package/dist/lib/runtime-payload.mjs +1 -2
  10. package/dist/lib/runtime-payload.mjs.map +1 -1
  11. package/docs/architecture/storage-model.md +1 -1
  12. package/docs/architecture.md +9 -7
  13. package/docs/cli.md +3 -4
  14. package/docs/project-structure-overview.md +4 -5
  15. package/package.json +1 -1
  16. package/runtime/BUILD.json +2 -2
  17. package/runtime/prompts/launch.template.md +1 -1
  18. package/runtime/prompts/lead/okstra-lead-contract.md +1 -3
  19. package/runtime/prompts/lead/report-writer.md +10 -17
  20. package/runtime/prompts/lead/team-contract.md +1 -2
  21. package/runtime/prompts/wizard/prompts.ko.json +0 -1
  22. package/runtime/python/okstra_ctl/adapters/hosts/codex/relay.md +1 -1
  23. package/runtime/python/okstra_ctl/adapters/providers/antigravity/adapter.py +1 -1
  24. package/runtime/python/okstra_ctl/adapters/providers/claude/adapter.py +0 -1
  25. package/runtime/python/okstra_ctl/adapters/providers/codex/adapter.py +1 -1
  26. package/runtime/python/okstra_ctl/adapters/providers/grok/adapter.py +0 -1
  27. package/runtime/python/okstra_ctl/adapters/providers/kimi/adapter.py +0 -1
  28. package/runtime/python/okstra_ctl/agent/invocation.py +1 -2
  29. package/runtime/python/okstra_ctl/agent/prompt_cli/cli.py +2 -2
  30. package/runtime/python/okstra_ctl/agent/prompt_cli/jobs.py +0 -2
  31. package/runtime/python/okstra_ctl/agent/prompt_cli/materialize.py +2 -27
  32. package/runtime/python/okstra_ctl/agent/prompt_cli/run_identity.py +0 -4
  33. package/runtime/python/okstra_ctl/dispatch_core.py +3 -349
  34. package/runtime/python/okstra_ctl/dispatch_state.py +0 -13
  35. package/runtime/python/okstra_ctl/domain/provider.py +1 -1
  36. package/runtime/python/okstra_ctl/domain/role.py +0 -6
  37. package/runtime/python/okstra_ctl/final_report_paths.py +0 -16
  38. package/runtime/python/okstra_ctl/i18n.py +2 -2
  39. package/runtime/python/okstra_ctl/path_hints.py +0 -4
  40. package/runtime/python/okstra_ctl/paths.py +0 -3
  41. package/runtime/python/okstra_ctl/phases/final_verification/profile.md +2 -0
  42. package/runtime/python/okstra_ctl/phases/final_verification/spec.md +2 -0
  43. package/runtime/python/okstra_ctl/phases/final_verification/target.py +17 -1
  44. package/runtime/python/okstra_ctl/phases/implementation/entry.py +12 -0
  45. package/runtime/python/okstra_ctl/phases/implementation/instructions/_implementation-deliverable.md +1 -0
  46. package/runtime/python/okstra_ctl/phases/implementation/instructions/_implementation-executor.md +1 -0
  47. package/runtime/python/okstra_ctl/phases/implementation/spec.md +4 -0
  48. package/runtime/python/okstra_ctl/phases/release_handoff/boundary.json +1 -1
  49. package/runtime/python/okstra_ctl/phases/release_handoff/operations.py +24 -3
  50. package/runtime/python/okstra_ctl/phases/release_handoff/profile.md +11 -11
  51. package/runtime/python/okstra_ctl/phases/release_handoff/spec.md +4 -2
  52. package/runtime/python/okstra_ctl/render.py +0 -10
  53. package/runtime/python/okstra_ctl/render_final_report.py +4 -4
  54. package/runtime/python/okstra_ctl/report_finalize.py +37 -76
  55. package/runtime/python/okstra_ctl/report_html/render.py +4 -44
  56. package/runtime/python/okstra_ctl/report_language.py +3 -3
  57. package/runtime/python/okstra_ctl/report_prose_keys.py +153 -0
  58. package/runtime/python/okstra_ctl/report_synthesis_packet.py +1 -1
  59. package/runtime/python/okstra_ctl/run.py +1 -21
  60. package/runtime/python/okstra_ctl/stage_integrate.py +37 -2
  61. package/runtime/python/okstra_ctl/stage_rebase.py +189 -0
  62. package/runtime/python/okstra_ctl/stage_targets.py +29 -62
  63. package/runtime/python/okstra_ctl/team.py +1 -6
  64. package/runtime/python/okstra_ctl/wizard/confirmation.py +0 -8
  65. package/runtime/python/okstra_ctl/wizard/registry.py +2 -3
  66. package/runtime/python/okstra_ctl/wizard/sources.py +10 -0
  67. package/runtime/python/okstra_ctl/worker_prompt_policy.py +3 -12
  68. package/runtime/python/okstra_ctl/worktree/__init__.py +1 -1
  69. package/runtime/python/okstra_ctl/worktree/decisions.py +3 -4
  70. package/runtime/python/okstra_ctl/worktree/provision.py +27 -0
  71. package/runtime/python/okstra_ctl/worktree/rebase.py +108 -0
  72. package/runtime/python/okstra_ctl/worktree_registry.py +14 -0
  73. package/runtime/skills/_fragments/wizard-render-bundle.md +1 -1
  74. package/runtime/skills/okstra-code-review/SKILL.md +1 -1
  75. package/runtime/skills/okstra-discussion/SKILL.md +1 -1
  76. package/runtime/skills/okstra-pr-gen/SKILL.md +9 -0
  77. package/runtime/skills/okstra-run/SKILL.md +1 -1
  78. package/runtime/skills/okstra-setup/SKILL.md +2 -2
  79. package/runtime/skills/okstra-setup/references/project-config.md +2 -2
  80. package/runtime/templates/pr/drafting-rules.md +4 -0
  81. package/runtime/templates/report-writer-prompt-preamble.md +1 -1
  82. package/runtime/templates/reports/html/i18n/en.json +0 -1
  83. package/runtime/templates/reports/html/i18n/ko.json +0 -1
  84. package/runtime/templates/worker-prompt-preamble.md +1 -1
  85. package/runtime/validators/validate-run.py +1 -28
  86. package/runtime/agents/operations/report-translation.json +0 -6
  87. package/runtime/agents/roles/translator.json +0 -19
  88. package/runtime/bin/okstra-report-translate.py +0 -404
  89. package/runtime/prompts/duties/translator.json +0 -32
  90. package/runtime/python/okstra_ctl/report_translation.py +0 -509
  91. package/runtime/python/okstra_ctl/report_translation_dispatch.py +0 -356
  92. package/runtime/templates/translator-prompt-preamble.md +0 -36
@@ -0,0 +1,153 @@
1
+ """Final-report leaf keys whose values a reader reads as prose and nothing else.
2
+
3
+ A value under one of these keys is displayed text; ids, enum values, paths and
4
+ anchor targets live under other keys.
5
+ """
6
+ from __future__ import annotations
7
+
8
+ PROSE_KEYS = frozenset({
9
+ "acceptance",
10
+ "action",
11
+ "actions",
12
+ "addedWork",
13
+ "alternativesConsidered",
14
+ "answer",
15
+ "approach",
16
+ "approvalDisposition",
17
+ "approvalEvidence",
18
+ "architectureBoundaries",
19
+ "assumptions",
20
+ "behavior",
21
+ "blastRadius",
22
+ "blockReason",
23
+ "blockedReason",
24
+ "blockers",
25
+ "body",
26
+ "boundary",
27
+ "briefEvidence",
28
+ "canonical",
29
+ "carryIn",
30
+ "causeSummary",
31
+ "change",
32
+ "check",
33
+ "claim",
34
+ "commitment",
35
+ "complexity",
36
+ "condition",
37
+ "confirmingSignal",
38
+ "conformanceExemption",
39
+ "consequences",
40
+ "constraint",
41
+ "context",
42
+ "coreMechanism",
43
+ "coreReason",
44
+ "counterevidence",
45
+ "decision",
46
+ "decisions",
47
+ "declinedFixRecommendations",
48
+ "description",
49
+ "details",
50
+ "directionChange",
51
+ "disagreement",
52
+ "discrepancy",
53
+ "disproveWith",
54
+ "environmentSetup",
55
+ "escalationTrigger",
56
+ "evidence",
57
+ "evidenceRequired",
58
+ "exemptionReaffirm",
59
+ "exemptionReason",
60
+ "exitContract",
61
+ "exitContractSummary",
62
+ "expected",
63
+ "expectedBehaviorAfter",
64
+ "expectedChangeAreas",
65
+ "expectedForm",
66
+ "expectedOutcome",
67
+ "expectedResult",
68
+ "expectedVerification",
69
+ "explanation",
70
+ "fact",
71
+ "failureHandling",
72
+ "falsifyingEvidenceChecked",
73
+ "finalConclusion",
74
+ "goal",
75
+ "guardrails",
76
+ "headline",
77
+ "howToStart",
78
+ "hypothesis",
79
+ "ifStillOpen",
80
+ "injectedAs",
81
+ "interfaces",
82
+ "item",
83
+ "justification",
84
+ "knownFacts",
85
+ "label",
86
+ "mitigation",
87
+ "motivation",
88
+ "name",
89
+ "need",
90
+ "nextStep",
91
+ "noVariationRationale",
92
+ "notApplicableReason",
93
+ "note",
94
+ "observableFailure",
95
+ "observed",
96
+ "option",
97
+ "outcome",
98
+ "outputSummary",
99
+ "performed",
100
+ "planItem",
101
+ "position",
102
+ "problem",
103
+ "question",
104
+ "rationale",
105
+ "readyWhen",
106
+ "reason",
107
+ "rejectedSummary",
108
+ "rejectingSignal",
109
+ "relation",
110
+ "replacedInTest",
111
+ "replacedWith",
112
+ "requestKind",
113
+ "requiredDecision",
114
+ "requiredWork",
115
+ "requirement",
116
+ "resolution",
117
+ "responsibility",
118
+ "reversibility",
119
+ "risk",
120
+ "rolloutCost",
121
+ "routingRecommendation",
122
+ "satisfaction",
123
+ "scope",
124
+ "selfFixNote",
125
+ "sliceValue",
126
+ "stageTitle",
127
+ "stageValidation",
128
+ "statement",
129
+ "suggestedAction",
130
+ "summary",
131
+ "supersededStatement",
132
+ "symptom",
133
+ "systemInterpretation",
134
+ "testCaseBoundary",
135
+ "testCaseFailure",
136
+ "testCaseSuccess",
137
+ "testCoverageCost",
138
+ "text",
139
+ "title",
140
+ "topic",
141
+ "trigger",
142
+ "triggerSignal",
143
+ "unblockCondition",
144
+ "userChoice",
145
+ "userConstraints",
146
+ "userInput",
147
+ "userText",
148
+ "verification",
149
+ "verificationMethod",
150
+ "verificationSignal",
151
+ "whyItMatters",
152
+ "workingAssumption",
153
+ })
@@ -866,7 +866,7 @@ def _attempt_result_specs(
866
866
  for row in invocations_value
867
867
  if isinstance(row, Mapping) and _string(row.get("invocationRef"))
868
868
  }
869
- excluded_duties = {"lead", "report-writer", "translator"}
869
+ excluded_duties = {"lead", "report-writer"}
870
870
  specs: list[tuple[str, str, str, bool]] = []
871
871
  seen: set[str] = set()
872
872
  for attempt in attempts_value:
@@ -1896,19 +1896,12 @@ def _build_static_execution_manifest(
1896
1896
  plan: AssignmentPlan,
1897
1897
  context: AssignmentContext,
1898
1898
  lead: ResolvedAssignment,
1899
- translator: ResolvedAssignment,
1900
1899
  ) -> ExecutionManifest:
1901
1900
  participants: list[ParticipantAssignment] = []
1902
1901
  roles: list[RoleExecution] = []
1903
- # The translator is resolved outside the role plan, like the lead, and both
1904
- # are named in `invocationAssignments`. Without a role execution here,
1905
- # `agent-prompt materialize --audience translator` has no canonical
1906
- # identity to bind to and refuses — so a `reportLanguage: ko` run could
1907
- # never write its translation sidecar and rendered the English source.
1908
1902
  assignments = (
1909
1903
  lead,
1910
1904
  *(row for row in plan.assignments if row.role != "leader"),
1911
- translator,
1912
1905
  )
1913
1906
  for index, assignment in enumerate(assignments, start=1):
1914
1907
  participant_ref = f"participant-{index:03d}"
@@ -2387,7 +2380,6 @@ class _ModelBindings:
2387
2380
  lead_assignment: RoleAssignment
2388
2381
  worker_assignments: tuple[RoleAssignment, ...]
2389
2382
  invocation_assignments: dict[str, dict[str, object]]
2390
- translator: ResolvedAssignment
2391
2383
 
2392
2384
 
2393
2385
  @dataclass(frozen=True)
@@ -2622,13 +2614,6 @@ def _project_model_bindings(
2622
2614
  if executor_rows
2623
2615
  else None
2624
2616
  )
2625
- translator = _model_for_role(
2626
- context=context,
2627
- provider=report_writer.provider_id,
2628
- raw_value=report_writer.model_id,
2629
- role="translator",
2630
- duty_id="translator",
2631
- )
2632
2617
  projections = {
2633
2618
  provider: _legacy_projection_from_plan(provider, plan, context)
2634
2619
  for provider in ("claude", "codex", "antigravity")
@@ -2637,7 +2622,6 @@ def _project_model_bindings(
2637
2622
  lead_assignment,
2638
2623
  workers,
2639
2624
  critic_assignment,
2640
- _role_assignment(resolved=translator, role="translator"),
2641
2625
  inp.task_type,
2642
2626
  )
2643
2627
  worker_by_provider = {row.provider: row for row in workers}
@@ -2668,7 +2652,6 @@ def _project_model_bindings(
2668
2652
  lead_assignment=lead_assignment,
2669
2653
  worker_assignments=workers,
2670
2654
  invocation_assignments=invocation_assignments,
2671
- translator=translator,
2672
2655
  )
2673
2656
 
2674
2657
 
@@ -2759,7 +2742,6 @@ def _build_invocation_assignments(
2759
2742
  lead: RoleAssignment,
2760
2743
  workers: tuple[RoleAssignment, ...],
2761
2744
  critic: RoleAssignment | None,
2762
- translator: RoleAssignment,
2763
2745
  task_type: str,
2764
2746
  ) -> dict[str, dict[str, object]]:
2765
2747
  assignments = {"lead": lead.to_model_payload()}
@@ -2776,7 +2758,6 @@ def _build_invocation_assignments(
2776
2758
  # role 라벨을 만들기 때문에 두 행의 이름이 겹쳐 validate-run 이
2777
2759
  # `duplicate worker role detected` 로 런을 실패시킨다.
2778
2760
  assignments[critic_assignment_ref(task_type)] = critic.to_model_payload()
2779
- assignments["translator"] = translator.to_model_payload()
2780
2761
  return assignments
2781
2762
 
2782
2763
 
@@ -3660,7 +3641,7 @@ def _allowed_agent_audiences(
3660
3641
  inp: PrepareInputs,
3661
3642
  models: _ModelBindings,
3662
3643
  ) -> list[str]:
3663
- audiences = {"lead", "translator"}
3644
+ audiences = {"lead"}
3664
3645
  if models.worker_assignments:
3665
3646
  audiences.add("reverification-worker")
3666
3647
  if any(item.worker_id == "report-writer" for item in models.worker_assignments):
@@ -4162,7 +4143,6 @@ def prepare_task_bundle(inp: PrepareInputs) -> PrepareOutputs:
4162
4143
  assignment_plan,
4163
4144
  assignment_context,
4164
4145
  models.lead,
4165
- models.translator,
4166
4146
  )
4167
4147
  dynamic_roles = tuple(
4168
4148
  requirement.role
@@ -283,8 +283,14 @@ def _teardown_all(project_id, task_group, task_id, task_wt: str,
283
283
 
284
284
  def _remove_clean_worktree(task_wt: str, wt: str, stage_n: int) -> tuple[str, str] | None:
285
285
  """clean stage worktree 를 물리 제거. dirty/실패면 (outcome, detail), 성공이면 None."""
286
- from .worktree import is_dirty_excluding_okstra, remove_worktree_force
287
-
286
+ from .worktree import (
287
+ is_dirty_excluding_okstra,
288
+ nested_worktree_excludes,
289
+ remove_worktree_force,
290
+ )
291
+
292
+ if nested_worktree_excludes(wt):
293
+ return ("skipped", "nested worktrees remain — preserved")
288
294
  if is_dirty_excluding_okstra(wt):
289
295
  return ("skipped", "dirty — 미커밋 변경 보존")
290
296
  rm = remove_worktree_force(task_wt, wt)
@@ -293,6 +299,35 @@ def _remove_clean_worktree(task_wt: str, wt: str, stage_n: int) -> tuple[str, st
293
299
  return None
294
300
 
295
301
 
302
+ def reclaim_completed_stage(
303
+ project_root: Path, project_id: str, task_group: str, task_id: str,
304
+ stage: int, expected_head: str, done: dict | None, status: str,
305
+ ) -> tuple[str, str]:
306
+ from . import worktree_registry
307
+ from .worktree import main_worktree_path
308
+
309
+ row = worktree_registry.get_stage_row(project_id, task_group, task_id, stage)
310
+ if not row:
311
+ return "skipped", "stage worktree is not registered"
312
+ if status != "done" or row.get("status") == "active":
313
+ return "skipped", "stage is unfinished or active"
314
+ if not expected_head or (done or {}).get("head_commit") != expected_head:
315
+ return "skipped", "completion commit does not match this report"
316
+ tip = _git(project_root, "rev-parse", "--verify", f"refs/heads/{row['branch']}")
317
+ if tip.returncode or tip.stdout.strip() != expected_head:
318
+ return "skipped", "stage branch moved after completion"
319
+ wt = Path(row["worktree_path"])
320
+ if wt.resolve() == main_worktree_path(project_root).resolve():
321
+ return "skipped", "main worktree is preserved"
322
+ if wt.exists():
323
+ current = _git(wt, "symbolic-ref", "--short", "HEAD")
324
+ if current.returncode or current.stdout.strip() != row["branch"]:
325
+ return "skipped", "worktree no longer holds the registered stage branch"
326
+ return _teardown_stage(
327
+ project_id, task_group, task_id, str(project_root), stage, row, expected_head,
328
+ )
329
+
330
+
296
331
  def _unmerged_branch_tip_warning(task_wt: str, stage_n: int, branch: str,
297
332
  verified_head: str = "") -> str:
298
333
  """Warn text when the stage branch tip is not an ancestor of the commit
@@ -0,0 +1,189 @@
1
+ """선행 기준이 이동한 단계 브랜치를 의존 순서로 재배치한다."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from pathlib import Path
6
+
7
+ from . import consumers, worktree_registry
8
+ from .stage_targets import (
9
+ StageWorktreeIdentity,
10
+ order_stage_closure,
11
+ resolve_stage_base_commit,
12
+ )
13
+ from .worktree import is_ancestor
14
+ from .worktree.rebase import _checked_git, rebase_stage_branch
15
+
16
+
17
+ def _rebase_completed_stage(
18
+ project_root: Path,
19
+ plan_run_root: Path,
20
+ identity: StageWorktreeIdentity,
21
+ row: dict,
22
+ done_row: dict,
23
+ base: str,
24
+ ) -> dict:
25
+ number, branch = row["stage"], row["branch"]
26
+ tip = _checked_git(project_root, "rev-parse", f"refs/heads/{branch}")
27
+ if tip != done_row.get("head_commit"):
28
+ raise RuntimeError(
29
+ f"stage {number} branch moved beyond its done commit; reconcile it before rebasing"
30
+ )
31
+ head = rebase_stage_branch(project_root, branch, row["base_ref"], base)
32
+ worktree_registry.update_stage_base(
33
+ identity.project_id,
34
+ identity.task_group,
35
+ identity.task_id,
36
+ number,
37
+ expected_base=row["base_ref"],
38
+ base_ref=base,
39
+ )
40
+ consumers.append_consumer(
41
+ plan_run_root,
42
+ impl_task_key=done_row["impl_task_key"],
43
+ stage=number,
44
+ status="done",
45
+ force_reappend=True,
46
+ head_commit=head,
47
+ replaced_commit=tip,
48
+ reconciled=True,
49
+ reconcile_reason="dependency-rebase",
50
+ report_path=done_row.get("report_path", ""),
51
+ )
52
+ return {**done_row, "head_commit": head}
53
+
54
+
55
+ def _refresh_predecessor_head(
56
+ root: Path,
57
+ plan: Path,
58
+ row: dict,
59
+ done_row: dict,
60
+ active: bool,
61
+ ) -> dict:
62
+ tip = _checked_git(root, "rev-parse", f"refs/heads/{row['branch']}")
63
+ previous = done_row["head_commit"]
64
+ if tip == previous:
65
+ return done_row
66
+ if active or not is_ancestor(root, previous, tip):
67
+ raise RuntimeError(
68
+ f"predecessor stage {row['stage']} is active or rewritten; finish or reconcile it first"
69
+ )
70
+ consumers.append_consumer(
71
+ plan,
72
+ impl_task_key=done_row["impl_task_key"],
73
+ stage=row["stage"],
74
+ status="done",
75
+ force_reappend=True,
76
+ head_commit=tip,
77
+ replaced_commit=previous,
78
+ reconciled=True,
79
+ reconcile_reason="predecessor-advance",
80
+ report_path=done_row.get("report_path", ""),
81
+ )
82
+ return {**done_row, "head_commit": tip}
83
+
84
+
85
+ def synchronize_stage_stack(
86
+ *,
87
+ project_root: Path,
88
+ plan_run_root: Path,
89
+ stage_map: list[dict],
90
+ identity: StageWorktreeIdentity,
91
+ ) -> list[int]:
92
+ """호출자가 task-key provision 잠금을 잡은 상태에서 실행한다."""
93
+ rows = consumers.read_consumers(plan_run_root)
94
+ done = consumers.latest_done_by_stage(rows)
95
+ statuses = consumers.last_lifecycle_status_by_stage(rows)
96
+ anchor = (
97
+ worktree_registry.get_implementation_base(
98
+ identity.project_id,
99
+ identity.task_group,
100
+ identity.task_id,
101
+ )
102
+ or ""
103
+ )
104
+ graph = [(s["stage_number"], s.get("depends_on") or []) for s in stage_map]
105
+ order = order_stage_closure(graph, {n for n, _ in graph}, set())
106
+ if len(order) != len(graph):
107
+ raise RuntimeError("cannot synchronize a cyclic Stage Map")
108
+ by_number = {s["stage_number"]: s for s in stage_map}
109
+ predecessors = {dep for _, deps in graph for dep in deps}
110
+ changed = []
111
+ for number in order:
112
+ row = worktree_registry.get_stage_row(
113
+ identity.project_id,
114
+ identity.task_group,
115
+ identity.task_id,
116
+ number,
117
+ )
118
+ stage = by_number[number]
119
+ if not row or number not in done or stage.get("cancelled"):
120
+ continue
121
+ active = statuses.get(number) == "started" or row.get("status") == "active"
122
+ if _synchronize_recorded_stage(
123
+ project_root,
124
+ plan_run_root,
125
+ identity,
126
+ stage,
127
+ row,
128
+ done,
129
+ anchor,
130
+ active,
131
+ number in predecessors,
132
+ ):
133
+ changed.append(number)
134
+ return changed
135
+
136
+
137
+ def _synchronize_recorded_stage(
138
+ root: Path,
139
+ plan: Path,
140
+ identity: StageWorktreeIdentity,
141
+ stage: dict,
142
+ row: dict,
143
+ done: dict[int, dict],
144
+ anchor: str,
145
+ active: bool,
146
+ predecessor: bool,
147
+ ) -> bool:
148
+ number = stage["stage_number"]
149
+ previous = done[number]["head_commit"]
150
+ if predecessor:
151
+ done[number] = _refresh_predecessor_head(root, plan, row, done[number], active)
152
+ if stage.get("depends_on"):
153
+ done[number] = _synchronize_dependent(
154
+ root,
155
+ plan,
156
+ identity,
157
+ stage,
158
+ row,
159
+ done,
160
+ anchor,
161
+ active,
162
+ )
163
+ return previous != done[number]["head_commit"]
164
+
165
+
166
+ def _synchronize_dependent(
167
+ root: Path,
168
+ plan: Path,
169
+ identity: StageWorktreeIdentity,
170
+ stage: dict,
171
+ row: dict,
172
+ done: dict[int, dict],
173
+ anchor: str,
174
+ active: bool,
175
+ ) -> dict:
176
+ number = stage["stage_number"]
177
+ base = resolve_stage_base_commit(
178
+ stage,
179
+ list(done.values()),
180
+ anchor,
181
+ project_root=root,
182
+ plan_run_root=plan,
183
+ identity=identity,
184
+ )
185
+ if row["base_ref"] == base:
186
+ return done[number]
187
+ if active:
188
+ raise RuntimeError(f"cannot rebase active stage {number}; finish its run first")
189
+ return _rebase_completed_stage(root, plan, identity, row, done[number], base)
@@ -831,76 +831,43 @@ def containing_stage(project_root: Path,
831
831
  return single_containing_head(project_root, heads)
832
832
 
833
833
 
834
- def integrate_and_teardown_whole_task(
834
+ def teardown_completed_stage_worktrees(
835
835
  *, project_root: Path, task_group: str, task_id: str,
836
+ expected_heads: dict[int, str] | None = None,
836
837
  ) -> dict[str, Any]:
837
- """판정이 끝난 whole-task 검증의 stage worktree 와 registry 키를 회수한다.
838
-
839
- 진입은 통합만 하고 정리를 남겨 두므로(`phases/final_verification/target.py`), 정리는
840
- 판정 뒤인 Phase 7 에서 여기로 들어온다. 통합은 이미 끝나 있어 Phase A 는 전부
841
- `already_merged` 로 지나가고 Phase B 만 실제 일을 한다. 두 번 불려도 결과는 같다 —
842
- 사라진 worktree 는 건너뛰고, 미커밋 변경이 남은 stage 트리는 보존한다.
843
-
844
- stage_map 은 done 행에서 만든다. 정리 대상은 완료된 stage 뿐이고, 계획에만 있고
845
- 완료되지 않은 stage 는 Phase A 가 어차피 건너뛰기 때문이다.
846
- """
838
+ """완료된 단계의 작업 트리만 회수하고 브랜치와 기준 기록은 보존한다."""
847
839
  from okstra_project.dirs import okstra_home, project_json_path
848
840
 
849
- from . import consumers, worktree_registry
841
+ from . import consumers
850
842
  from .json_boundary import load_owned_object
851
843
  from .locks import worktree_provision_mutex
852
844
  from .paths import task_runs_dir
853
- from .stage_integrate import integrate_stages
845
+ from .stage_integrate import reclaim_completed_stage
854
846
 
855
- try:
856
- project_id = load_owned_object(
857
- project_json_path(project_root), artifact="project config"
858
- ).get("projectId", "")
859
- except (OSError, ValueError):
860
- project_id = ""
847
+ config = project_json_path(project_root)
848
+ if not config.is_file():
849
+ return {"skipped": "project.json is missing"}
850
+ project_id = load_owned_object(config, artifact="project config").get("projectId", "")
861
851
  if not project_id:
862
852
  return {"skipped": "project.json declares no projectId"}
863
-
864
- plan_run_root = task_runs_dir(
865
- project_root, task_group, task_id
866
- ) / "implementation-planning"
867
- done_rows = [
868
- row for row in consumers.read_consumers(plan_run_root)
869
- if row.get("status") == "done"
870
- ]
871
- if not done_rows:
872
- return {"skipped": "no done stage rows to reclaim"}
873
- stage_map = [
874
- {"stage_number": stage}
875
- for stage in sorted(consumers.latest_done_by_stage(done_rows))
876
- ]
877
-
878
- entry = worktree_registry.lookup(project_id, task_group, task_id)
879
- if entry is None:
880
- return {"skipped": "task worktree is no longer registered"}
881
-
882
- done_by_stage = consumers.latest_done_by_stage(done_rows)
883
- tip = containing_stage(project_root, done_by_stage)
853
+ plan = task_runs_dir(project_root, task_group, task_id) / "implementation-planning"
854
+ removed, skipped, warnings = [], [], []
884
855
  with worktree_provision_mutex(okstra_home(), project_id, task_group, task_id):
885
- result = integrate_stages(
886
- project_id=project_id,
887
- task_group=task_group,
888
- task_id=task_id,
889
- task_worktree_path=entry.worktree_path,
890
- stage_map=stage_map,
891
- done_rows=done_rows,
892
- teardown=True,
893
- # 한 stage 브랜치가 이미 전체를 담고 있으면 검증은 거기서 돌았다
894
- # (`phases/final_verification/target.py`). 그 경우 task 브랜치로의 머지는 아무도
895
- # 읽지 않는 산출물이므로 정리만 한다.
896
- merge=tip is None,
897
- verified_head=(done_by_stage.get(tip) or {}).get("head_commit", ""),
898
- )
899
- return {
900
- "tornDown": result.torn_down,
901
- "teardownSkipped": [
902
- {"stage": stage, "reason": reason}
903
- for stage, reason in result.teardown_skipped
904
- ],
905
- "warnings": result.warnings,
906
- }
856
+ rows = consumers.read_consumers(plan)
857
+ done = consumers.latest_done_by_stage(rows)
858
+ statuses = consumers.last_lifecycle_status_by_stage(rows)
859
+ heads = expected_heads if expected_heads is not None else {
860
+ stage: row.get("head_commit", "") for stage, row in done.items()
861
+ }
862
+ for stage, head in sorted(heads.items()):
863
+ outcome, detail = reclaim_completed_stage(
864
+ project_root, project_id, task_group, task_id, stage, head,
865
+ done.get(stage), statuses.get(stage, ""),
866
+ )
867
+ if outcome == "torn_down":
868
+ removed.append(stage)
869
+ elif outcome == "skipped":
870
+ skipped.append({"stage": stage, "reason": detail})
871
+ else:
872
+ warnings.append(detail)
873
+ return {"tornDown": removed, "teardownSkipped": skipped, "warnings": warnings}
@@ -228,12 +228,7 @@ def _record_dispatch(args, manifest: Mapping[str, Any], prepared) -> list[str]:
228
228
 
229
229
 
230
230
  def team_dispatch_port(manifest: Mapping[str, Any]) -> WorkerDispatchPort:
231
- """이 run 의 워커를 띄울 포트. cmux run 이면 pane, 아니면 리드 호스트의 포트.
232
-
233
- `team dispatch` 와 finalize 의 `translate` 단계가 같이 쓴다 — 번역자만
234
- CLI 래퍼 디스패처로 띄우던 동안 cmux run 에서도 번역자는 pane 없이 돌았다
235
- (실측 2026-09-22, dev-10860 implementation-planning-001: 18건 중 번역자 1건).
236
- """
231
+ """이 run 의 워커를 띄울 포트. cmux run 이면 pane, 아니면 리드 호스트의 포트."""
237
232
  lead_runtime = _require_string(manifest, "leadRuntime")
238
233
  fallback_port = default_host_registry().resolve(
239
234
  lead_runtime
@@ -333,14 +333,6 @@ def confirmation_block(state: WizardState) -> str:
333
333
  lines.append(f" pr-template : {state.pr_template_path} ({state.pr_template_scope or 'once'})")
334
334
  if state.fix_cycle:
335
335
  lines.append(f" fix-cycle : {state.fix_cycle}")
336
- report_writer_models = state.role_models.get("report-writer", [])
337
- translator_model = (
338
- report_writer_models[0] if report_writer_models
339
- else "(selection required)"
340
- )
341
- lines.append(_msg(
342
- state.workspace_root, "confirmation", "translation_scope", model=translator_model,
343
- ))
344
336
  lines.append(_msg(
345
337
  state.workspace_root, "confirmation", "provider_data_scope",
346
338
  project_root=state.project_root,
@@ -93,10 +93,10 @@ from .state import (
93
93
  from .prompts import _opt, _p
94
94
  from .sources import (
95
95
  _brief_resolved,
96
+ _clarification_pick_required,
96
97
  _has_prior_run_inputs,
97
98
  _latest_implementation_planning_report,
98
99
  _reverify_scope_pick_required,
99
- _suggest_latest_final_report,
100
100
  )
101
101
  from .roles import _clear_role_selection, _reset_role_selection_from, next_role_prompt
102
102
  from .steps_identity import (
@@ -535,8 +535,7 @@ STEPS: list[Step] = [
535
535
  Step(S_CLARIFICATION_PICK,
536
536
  applies=lambda s: (S_CLARIFICATION_PICK not in s.answered
537
537
  and _identity_ready(s)
538
- and (s.task_type != "implementation-planning"
539
- or bool(_suggest_latest_final_report(s)))),
538
+ and _clarification_pick_required(s)),
540
539
  build=_build_clarification_pick, submit=_submit_clarification_pick,
541
540
  owns=("clarification_response_path", "clarification_pending_text")),
542
541
  Step(S_CLARIFICATION,
@@ -600,6 +600,16 @@ def _latest_run_inputs(
600
600
  phase_segment=phase_segment)
601
601
 
602
602
 
603
+ def _clarification_pick_required(state: WizardState) -> bool:
604
+ if state.task_type == "release-handoff":
605
+ report = _suggest_latest_final_report(state)
606
+ return bool(report and sidecar_answers(Path(state.project_root) / report))
607
+ return (
608
+ state.task_type != "implementation-planning"
609
+ or bool(_suggest_latest_final_report(state))
610
+ )
611
+
612
+
603
613
  def _carried_planning_report(state: WizardState) -> Optional[Path]:
604
614
  """이번 재실행이 이어받는 직전 implementation-planning 리포트 (없으면 None)."""
605
615
  if state.task_type != "implementation-planning":