okstra 0.219.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 (86) 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/lib/runtime-payload.mjs +1 -2
  7. package/dist/lib/runtime-payload.mjs.map +1 -1
  8. package/docs/architecture/storage-model.md +1 -1
  9. package/docs/architecture.md +9 -7
  10. package/docs/cli.md +2 -3
  11. package/docs/project-structure-overview.md +3 -4
  12. package/package.json +1 -1
  13. package/runtime/BUILD.json +2 -2
  14. package/runtime/prompts/launch.template.md +1 -1
  15. package/runtime/prompts/lead/okstra-lead-contract.md +1 -3
  16. package/runtime/prompts/lead/report-writer.md +10 -17
  17. package/runtime/prompts/lead/team-contract.md +1 -2
  18. package/runtime/prompts/wizard/prompts.ko.json +0 -1
  19. package/runtime/python/okstra_ctl/adapters/hosts/codex/relay.md +1 -1
  20. package/runtime/python/okstra_ctl/adapters/providers/antigravity/adapter.py +1 -1
  21. package/runtime/python/okstra_ctl/adapters/providers/claude/adapter.py +0 -1
  22. package/runtime/python/okstra_ctl/adapters/providers/codex/adapter.py +1 -1
  23. package/runtime/python/okstra_ctl/adapters/providers/grok/adapter.py +0 -1
  24. package/runtime/python/okstra_ctl/adapters/providers/kimi/adapter.py +0 -1
  25. package/runtime/python/okstra_ctl/agent/invocation.py +1 -2
  26. package/runtime/python/okstra_ctl/agent/prompt_cli/cli.py +2 -2
  27. package/runtime/python/okstra_ctl/agent/prompt_cli/jobs.py +0 -2
  28. package/runtime/python/okstra_ctl/agent/prompt_cli/materialize.py +2 -27
  29. package/runtime/python/okstra_ctl/agent/prompt_cli/run_identity.py +0 -4
  30. package/runtime/python/okstra_ctl/dispatch_core.py +3 -349
  31. package/runtime/python/okstra_ctl/dispatch_state.py +0 -13
  32. package/runtime/python/okstra_ctl/domain/provider.py +1 -1
  33. package/runtime/python/okstra_ctl/domain/role.py +0 -6
  34. package/runtime/python/okstra_ctl/final_report_paths.py +0 -16
  35. package/runtime/python/okstra_ctl/i18n.py +2 -2
  36. package/runtime/python/okstra_ctl/path_hints.py +0 -4
  37. package/runtime/python/okstra_ctl/paths.py +0 -3
  38. package/runtime/python/okstra_ctl/phases/final_verification/profile.md +1 -0
  39. package/runtime/python/okstra_ctl/phases/final_verification/spec.md +2 -0
  40. package/runtime/python/okstra_ctl/phases/final_verification/target.py +17 -1
  41. package/runtime/python/okstra_ctl/phases/implementation/entry.py +12 -0
  42. package/runtime/python/okstra_ctl/phases/implementation/instructions/_implementation-deliverable.md +1 -0
  43. package/runtime/python/okstra_ctl/phases/implementation/instructions/_implementation-executor.md +1 -0
  44. package/runtime/python/okstra_ctl/phases/implementation/spec.md +4 -0
  45. package/runtime/python/okstra_ctl/phases/release_handoff/boundary.json +1 -1
  46. package/runtime/python/okstra_ctl/phases/release_handoff/operations.py +24 -3
  47. package/runtime/python/okstra_ctl/phases/release_handoff/profile.md +9 -9
  48. package/runtime/python/okstra_ctl/phases/release_handoff/spec.md +4 -2
  49. package/runtime/python/okstra_ctl/render.py +0 -10
  50. package/runtime/python/okstra_ctl/render_final_report.py +4 -4
  51. package/runtime/python/okstra_ctl/report_finalize.py +37 -76
  52. package/runtime/python/okstra_ctl/report_html/render.py +4 -44
  53. package/runtime/python/okstra_ctl/report_language.py +3 -3
  54. package/runtime/python/okstra_ctl/report_prose_keys.py +153 -0
  55. package/runtime/python/okstra_ctl/report_synthesis_packet.py +1 -1
  56. package/runtime/python/okstra_ctl/run.py +1 -21
  57. package/runtime/python/okstra_ctl/stage_integrate.py +37 -2
  58. package/runtime/python/okstra_ctl/stage_rebase.py +189 -0
  59. package/runtime/python/okstra_ctl/stage_targets.py +29 -62
  60. package/runtime/python/okstra_ctl/team.py +1 -6
  61. package/runtime/python/okstra_ctl/wizard/confirmation.py +0 -8
  62. package/runtime/python/okstra_ctl/wizard/registry.py +2 -3
  63. package/runtime/python/okstra_ctl/wizard/sources.py +10 -0
  64. package/runtime/python/okstra_ctl/worker_prompt_policy.py +3 -12
  65. package/runtime/python/okstra_ctl/worktree/__init__.py +1 -1
  66. package/runtime/python/okstra_ctl/worktree/decisions.py +3 -4
  67. package/runtime/python/okstra_ctl/worktree/provision.py +27 -0
  68. package/runtime/python/okstra_ctl/worktree/rebase.py +108 -0
  69. package/runtime/python/okstra_ctl/worktree_registry.py +14 -0
  70. package/runtime/skills/_fragments/wizard-render-bundle.md +1 -1
  71. package/runtime/skills/okstra-discussion/SKILL.md +1 -1
  72. package/runtime/skills/okstra-run/SKILL.md +1 -1
  73. package/runtime/skills/okstra-setup/SKILL.md +2 -2
  74. package/runtime/skills/okstra-setup/references/project-config.md +2 -2
  75. package/runtime/templates/report-writer-prompt-preamble.md +1 -1
  76. package/runtime/templates/reports/html/i18n/en.json +0 -1
  77. package/runtime/templates/reports/html/i18n/ko.json +0 -1
  78. package/runtime/templates/worker-prompt-preamble.md +1 -1
  79. package/runtime/validators/validate-run.py +1 -28
  80. package/runtime/agents/operations/report-translation.json +0 -6
  81. package/runtime/agents/roles/translator.json +0 -19
  82. package/runtime/bin/okstra-report-translate.py +0 -404
  83. package/runtime/prompts/duties/translator.json +0 -32
  84. package/runtime/python/okstra_ctl/report_translation.py +0 -509
  85. package/runtime/python/okstra_ctl/report_translation_dispatch.py +0 -356
  86. package/runtime/templates/translator-prompt-preamble.md +0 -36
@@ -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":
@@ -13,7 +13,6 @@ PromptAudience = Literal[
13
13
  "implementation-executor",
14
14
  "implementation-verifier",
15
15
  "report-writer",
16
- "translator",
17
16
  "reverify",
18
17
  "lead-only",
19
18
  ]
@@ -84,7 +83,6 @@ WORKER_PREAMBLE_FILENAME_BY_AUDIENCE = {
84
83
  "implementation-executor": "implementation-worker-preamble.md",
85
84
  "implementation-verifier": "implementation-worker-preamble.md",
86
85
  "report-writer": "report-writer-prompt-preamble.md",
87
- "translator": "translator-prompt-preamble.md",
88
86
  }
89
87
  WORKER_ERROR_CONTRACT_FILENAME = "worker-error-contract.md"
90
88
  # coverage-critic gap 검증 라운드의 dispatch kind. 번호 라운드(`reverify-r<N>`)와
@@ -199,7 +197,7 @@ def resolve_prompt_plan(
199
197
  """Resolve prompt policy from the worker's role and the dispatch kind.
200
198
 
201
199
  Neither the worker id nor the provider or model identity takes part. The id
202
- used to: `report-writer`, `translator`, and "the executor's worker id" were
200
+ used to: `report-writer` and "the executor's worker id" were
203
201
  compared by spelling, so a verifier that happened to share the executor's
204
202
  id received the executor's prompt.
205
203
  """
@@ -210,12 +208,6 @@ def resolve_prompt_plan(
210
208
  # 걸리지 않아 분석 계획으로 떨어졌고, 리드 프롬프트가 분석 워커 직무를
211
209
  # 달고 나갔다.
212
210
  return _plan("lead-only")
213
- if dispatch_kind == "translator":
214
- if role not in (None, "translator"):
215
- raise ValueError(f"translator dispatch cannot run role {role}")
216
- return _plan("translator", duty_audience="translator")
217
- if role == "translator":
218
- raise ValueError("translator role requires the translator dispatch kind")
219
211
  if is_verification_dispatch_kind(dispatch_kind):
220
212
  return _plan("reverify")
221
213
  # A critic pass keeps the full analysis contract — worker anchor headers, the
@@ -307,8 +299,8 @@ def resolve_prompt_plan_for_manifest(
307
299
  """Resolve policy from the manifest's task type and the worker's roster role.
308
300
 
309
301
  *worker_id* is the lookup key into `workerAssignments`; its spelling decides
310
- nothing. A worker with no roster row — the translator, or a verification
311
- pass whose kind alone decides — resolves with no role.
302
+ nothing. A worker with no roster row — a verification pass whose kind
303
+ alone decides — resolves with no role.
312
304
  """
313
305
  task_type = _required_string(manifest, "taskType")
314
306
  if not worker_id.strip():
@@ -383,7 +375,6 @@ def _duty_audience(audience: PromptAudience) -> AgentAudience:
383
375
  "implementation-executor": "implementation-executor",
384
376
  "implementation-verifier": "implementation-verifier",
385
377
  "report-writer": "report-writer",
386
- "translator": "translator",
387
378
  "reverify": "reverification-worker",
388
379
  "lead-only": "lead",
389
380
  }[audience]
@@ -34,7 +34,7 @@ Side effects:
34
34
  `sync_config`(무엇을 옮길지), `git_ops`(git 사실) — 은 서로를 모른다. 그 위에
35
35
  `cleanliness`(무엇이 더러운가) · `linking`(실제로 링크 걸기) ·
36
36
  `decisions`(무엇이 일어날지 미리 답하기)가 있고, 부수 효과를 내는 것은
37
- `provision` 하나뿐이다. 이 파일은 밖으로 나가는 이름만 모아 둔다.
37
+ `provision` 과 단계 브랜치를 재배치하는 `rebase` 뿐이다. 이 파일은 밖으로 나가는 이름만 모아 둔다.
38
38
  """
39
39
  from __future__ import annotations
40
40
 
@@ -118,10 +118,9 @@ def _stage_entry_is_reusable(entry: worktree_registry.WorktreeEntry) -> bool:
118
118
  the fix-run case: a stage whose verifier returned FAIL records a `failed`
119
119
  consumers row, which frees the occupancy but deliberately keeps the worktree
120
120
  and its branch as the reviewable stack. Re-entry MUST reuse that tree —
121
- provisioning anew refuses on the existing path and branch. After whole-task
122
- final-verification removes the directory the entry stops being reusable, so
123
- the stage provisions from scratch.
121
+ provisioning anew refuses on the existing branch. A removed directory is
122
+ restored from that branch on the next implementation or verification entry.
124
123
  """
125
124
  if entry.status == "active":
126
125
  return True
127
- return entry.status == "released" and Path(entry.worktree_path).is_dir()
126
+ return entry.status == "released"
@@ -13,6 +13,7 @@ from pathlib import Path
13
13
 
14
14
  from .. import worktree_registry
15
15
  from .decisions import preview_worktree_decision, resolve_stage_worktree_decision
16
+ from .rebase import rebase_stage_branch
16
17
  from .git_ops import (
17
18
  _branch_exists,
18
19
  _branch_exists_message,
@@ -259,6 +260,21 @@ def provision_task_worktree(
259
260
  )
260
261
 
261
262
 
263
+ def restore_stage_worktree(project_root: Path, path: str, branch: str) -> None:
264
+ target = Path(path)
265
+ if target.exists():
266
+ return
267
+ root = main_worktree_path(project_root)
268
+ target.parent.mkdir(parents=True, exist_ok=True)
269
+ result = _git(root, "worktree", "add", str(target), branch)
270
+ if result.returncode:
271
+ raise RuntimeError(f"cannot restore stage worktree {target}: {result.stderr.strip()}")
272
+ _link_sync_dirs(root, target)
273
+ _link_sync_files(root, target)
274
+ _copy_snapshot_files(root, target)
275
+ _seed_worktree_settings_symlink(target)
276
+
277
+
262
278
  def provision_stage_worktree(
263
279
  *,
264
280
  project_root: Path,
@@ -298,6 +314,17 @@ def provision_stage_worktree(
298
314
  stage_number=stage_number,
299
315
  )
300
316
  if decision.status == "reused":
317
+ restore_stage_worktree(project_root, decision.path, decision.branch)
318
+ resolved_base = _resolve_commit_sha(project_root, base_commit)
319
+ if not resolved_base:
320
+ raise RuntimeError(f"could not resolve base_commit `{base_commit}`")
321
+ if decision.base_ref != resolved_base:
322
+ rebase_stage_branch(project_root, decision.branch, decision.base_ref, resolved_base)
323
+ worktree_registry.update_stage_base(
324
+ project_id, task_group_segment, task_id_segment, stage_number,
325
+ expected_base=decision.base_ref, base_ref=resolved_base,
326
+ )
327
+ decision.base_ref = resolved_base
301
328
  drift = _sync_key_drift_note(
302
329
  main_worktree_path(project_root),
303
330
  worktree_registry.lookup(
@@ -0,0 +1,108 @@
1
+ """단계 브랜치 하나를 새 기준 위로 재배치하는 git 작업.
2
+
3
+ `provision` 의 재진입 경로와 `stage_rebase` 의 의존 순서 재배치가 함께 쓴다.
4
+ """
5
+ from __future__ import annotations
6
+
7
+ import tempfile
8
+ from collections.abc import Iterator
9
+ from contextlib import contextmanager
10
+ from pathlib import Path
11
+
12
+ from .cleanliness import is_dirty_excluding_okstra
13
+ from .git_ops import _git, is_ancestor
14
+
15
+
16
+ def _checked_git(root: Path, *args: str) -> str:
17
+ result = _git(root, *args)
18
+ if result.returncode:
19
+ raise RuntimeError(f"git {' '.join(args)} failed: {result.stderr.strip()}")
20
+ return result.stdout.strip()
21
+
22
+
23
+ @contextmanager
24
+ def _stage_worktree(project_root: Path, branch: str) -> Iterator[Path]:
25
+ worktrees = _checked_git(project_root, "worktree", "list", "--porcelain")
26
+ holder = None
27
+ current = None
28
+ for line in worktrees.splitlines():
29
+ if line.startswith("worktree "):
30
+ current = Path(line.removeprefix("worktree "))
31
+ elif line == f"branch refs/heads/{branch}":
32
+ holder = current
33
+ if holder is not None and is_dirty_excluding_okstra(holder):
34
+ raise RuntimeError(
35
+ f"cannot rebase {branch}: worktree {holder} has uncommitted changes"
36
+ )
37
+ temporary = holder is None
38
+ if temporary:
39
+ holder = Path(tempfile.mkdtemp(prefix="okstra-rebase-"))
40
+ holder.rmdir()
41
+ _checked_git(project_root, "worktree", "add", str(holder), branch)
42
+ try:
43
+ yield holder
44
+ finally:
45
+ if temporary:
46
+ # 복구에 실패한 작업 트리는 삭제하지 않는다.
47
+ states = ["rebase-merge", "rebase-apply"]
48
+ if not any(
49
+ Path(_checked_git(holder, "rev-parse", "--git-path", state)).exists()
50
+ for state in states
51
+ ):
52
+ _checked_git(project_root, "worktree", "remove", str(holder))
53
+
54
+
55
+ def rebase_stage_branch(
56
+ project_root: Path,
57
+ branch: str,
58
+ old_base: str,
59
+ new_base: str,
60
+ ) -> str:
61
+ old = _checked_git(project_root, "rev-parse", f"{old_base}^{{commit}}")
62
+ new = _checked_git(project_root, "rev-parse", f"{new_base}^{{commit}}")
63
+ head = _checked_git(project_root, "rev-parse", f"refs/heads/{branch}")
64
+ if old == new:
65
+ return head
66
+ if not is_ancestor(project_root, old, head):
67
+ raise RuntimeError(
68
+ f"cannot rebase {branch}: recorded base {old} is not an ancestor"
69
+ )
70
+ with _stage_worktree(project_root, branch) as holder:
71
+ for state in ["rebase-merge", "rebase-apply", "MERGE_HEAD", "CHERRY_PICK_HEAD"]:
72
+ path = Path(_checked_git(holder, "rev-parse", "--git-path", state))
73
+ if path.exists():
74
+ raise RuntimeError(
75
+ f"cannot rebase {branch}: unfinished git operation at {path}"
76
+ )
77
+ _checked_git(
78
+ project_root,
79
+ "update-ref",
80
+ f"refs/okstra/rebase-backups/{branch}/{head}",
81
+ head,
82
+ )
83
+ return _apply_rebase(holder, branch, old, new)
84
+
85
+
86
+ def _apply_rebase(holder: Path, branch: str, old: str, new: str) -> str:
87
+ result = _git(
88
+ holder,
89
+ "rebase",
90
+ "--no-autostash",
91
+ "--no-autosquash",
92
+ "--no-update-refs",
93
+ "--no-rebase-merges",
94
+ "--onto",
95
+ new,
96
+ old,
97
+ )
98
+ if result.returncode:
99
+ abort = _git(holder, "rebase", "--abort")
100
+ detail = (result.stderr or result.stdout).strip()
101
+ if abort.returncode:
102
+ raise RuntimeError(
103
+ f"rebase failed for {branch}; recovery worktree: {holder}; {detail}; abort: {abort.stderr}"
104
+ )
105
+ raise RuntimeError(
106
+ f"rebase failed for {branch}; original branch restored: {detail}"
107
+ )
108
+ return _checked_git(holder, "rev-parse", "HEAD")
@@ -310,6 +310,20 @@ def get_stage_row(
310
310
  return data["tasks"].get(key)
311
311
 
312
312
 
313
+ def update_stage_base(
314
+ project_id: str, task_group: str, task_id: str, stage: int, *,
315
+ expected_base: str, base_ref: str,
316
+ ) -> None:
317
+ key = task_key(project_id, task_group, task_id, stage)
318
+ with _registry_lock():
319
+ data = _load()
320
+ row = data["tasks"].get(key)
321
+ if row is None or row.get("base_ref") != expected_base:
322
+ raise RuntimeError(f"stage base changed concurrently: {key}")
323
+ row["base_ref"] = base_ref
324
+ _save(data)
325
+
326
+
313
327
  def list_active_stage_numbers(
314
328
  project_id: str, task_group: str, task_id: str,
315
329
  ) -> set:
@@ -10,7 +10,7 @@ Output: `{ok: true, outcome: {renderArgv: ["--lead-runtime", "...", ...], render
10
10
 
11
11
  `renderArgv` is the canonical ordered `okstra render-bundle` argument list. Pass its tokens verbatim and in order. Repeated `--role-count` and `--role-model` flags are intentional. `renderArgs` is a compatibility and inspection view only; do not reconstruct an invocation from it. `orchestration` holds signals this skill acts on itself — never pass its entries to `render-bundle`.
12
12
 
13
- `userAuthorization`, when present, preserves the confirmation prompt actually emitted by this wizard and the relayed `proceed` response. Its `--user-authorization-json` token carries that record into the run manifest; preserve it unchanged. Do not synthesize a receipt for older confirmed states. Include the translator's disclosed provider/model and report material when citing this record in host execution requests, including `report-finalize`. The record documents task scope; it does not grant host execution privileges.
13
+ `userAuthorization`, when present, preserves the confirmation prompt actually emitted by this wizard and the relayed `proceed` response. Its `--user-authorization-json` token carries that record into the run manifest; preserve it unchanged. Do not synthesize a receipt for older confirmed states. The record documents task scope; it does not grant host execution privileges.
14
14
 
15
15
  Before retrying a worker in an existing run, inspect its last invocation attempt: await running work; append an attempt only after `failed-no-mutation` within the retry budget. Preserve every other terminal attempt. `okstra team dispatch` routes the legacy declaration-only implementer failure to a separate same-model evidence-recovery invocation; collect its result before independent verification. Other terminal failures need `okstra agent-prompt materialize` with a new `--invocation-id`, distinct `--prompt`/`--result` paths, and unchanged assignment/model for a bounded correction. Do not restart completed implementation or request renewed approval for unchanged scope. Enforcement: `dispatch_core._next_attempt` and `execution_manifest._validate_next_attempt`.
16
16
 
@@ -223,7 +223,7 @@ Output: `{ok: true, outcome: {renderArgv: ["--lead-runtime", "...", ...], render
223
223
 
224
224
  `renderArgv` is the canonical ordered `okstra render-bundle` argument list. Pass its tokens verbatim and in order. Repeated `--role-count` and `--role-model` flags are intentional. `renderArgs` is a compatibility and inspection view only; do not reconstruct an invocation from it. `orchestration` holds signals this skill acts on itself — never pass its entries to `render-bundle`.
225
225
 
226
- `userAuthorization`, when present, preserves the confirmation prompt actually emitted by this wizard and the relayed `proceed` response. Its `--user-authorization-json` token carries that record into the run manifest; preserve it unchanged. Do not synthesize a receipt for older confirmed states. Include the translator's disclosed provider/model and report material when citing this record in host execution requests, including `report-finalize`. The record documents task scope; it does not grant host execution privileges.
226
+ `userAuthorization`, when present, preserves the confirmation prompt actually emitted by this wizard and the relayed `proceed` response. Its `--user-authorization-json` token carries that record into the run manifest; preserve it unchanged. Do not synthesize a receipt for older confirmed states. The record documents task scope; it does not grant host execution privileges.
227
227
 
228
228
  Before retrying a worker in an existing run, inspect its last invocation attempt: await running work; append an attempt only after `failed-no-mutation` within the retry budget. Preserve every other terminal attempt. `okstra team dispatch` routes the legacy declaration-only implementer failure to a separate same-model evidence-recovery invocation; collect its result before independent verification. Other terminal failures need `okstra agent-prompt materialize` with a new `--invocation-id`, distinct `--prompt`/`--result` paths, and unchanged assignment/model for a bounded correction. Do not restart completed implementation or request renewed approval for unchanged scope. Enforcement: `dispatch_core._next_attempt` and `execution_manifest._validate_next_attempt`.
229
229
 
@@ -256,7 +256,7 @@ Output: `{ok: true, outcome: {renderArgv: ["--lead-runtime", "...", ...], render
256
256
 
257
257
  `renderArgv` is the canonical ordered `okstra render-bundle` argument list. Pass its tokens verbatim and in order. Repeated `--role-count` and `--role-model` flags are intentional. `renderArgs` is a compatibility and inspection view only; do not reconstruct an invocation from it. `orchestration` holds signals this skill acts on itself — never pass its entries to `render-bundle`.
258
258
 
259
- `userAuthorization`, when present, preserves the confirmation prompt actually emitted by this wizard and the relayed `proceed` response. Its `--user-authorization-json` token carries that record into the run manifest; preserve it unchanged. Do not synthesize a receipt for older confirmed states. Include the translator's disclosed provider/model and report material when citing this record in host execution requests, including `report-finalize`. The record documents task scope; it does not grant host execution privileges.
259
+ `userAuthorization`, when present, preserves the confirmation prompt actually emitted by this wizard and the relayed `proceed` response. Its `--user-authorization-json` token carries that record into the run manifest; preserve it unchanged. Do not synthesize a receipt for older confirmed states. The record documents task scope; it does not grant host execution privileges.
260
260
 
261
261
  Before retrying a worker in an existing run, inspect its last invocation attempt: await running work; append an attempt only after `failed-no-mutation` within the retry budget. Preserve every other terminal attempt. `okstra team dispatch` routes the legacy declaration-only implementer failure to a separate same-model evidence-recovery invocation; collect its result before independent verification. Other terminal failures need `okstra agent-prompt materialize` with a new `--invocation-id`, distinct `--prompt`/`--result` paths, and unchanged assignment/model for a bounded correction. Do not restart completed implementation or request renewed approval for unchanged scope. Enforcement: `dispatch_core._next_attempt` and `execution_manifest._validate_next_attempt`.
262
262