okstra 0.136.0 → 0.138.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.
Files changed (50) hide show
  1. package/docs/architecture/storage-model.md +1 -1
  2. package/docs/architecture.md +3 -2
  3. package/docs/contributor-change-matrix.md +1 -1
  4. package/docs/project-structure-overview.md +32 -5
  5. package/package.json +2 -3
  6. package/runtime/BUILD.json +2 -2
  7. package/runtime/agents/workers/antigravity-worker.md +1 -1
  8. package/runtime/agents/workers/codex-worker.md +1 -1
  9. package/runtime/bin/lib/okstra-ctl/cmd-rerun.sh +1 -1
  10. package/runtime/bin/okstra-spawn-followups.py +4 -9
  11. package/runtime/prompts/lead/adapters/claude-code.md +1 -0
  12. package/runtime/prompts/lead/adapters/codex.md +1 -0
  13. package/runtime/prompts/lead/adapters/external.md +1 -0
  14. package/runtime/prompts/lead/plan-body-verification.md +2 -2
  15. package/runtime/prompts/lead/report-writer.md +11 -10
  16. package/runtime/prompts/lead/team-contract.md +4 -2
  17. package/runtime/prompts/profiles/_implementation-diff-review.md +1 -1
  18. package/runtime/prompts/profiles/_implementation-executor.md +2 -2
  19. package/runtime/prompts/profiles/_implementation-self-check.md +1 -1
  20. package/runtime/python/okstra_ctl/codex_dispatch.py +199 -681
  21. package/runtime/python/okstra_ctl/consumers.py +7 -5
  22. package/runtime/python/okstra_ctl/context_cost.py +4 -4
  23. package/runtime/python/okstra_ctl/dispatch_core.py +146 -287
  24. package/runtime/python/okstra_ctl/dispatch_state.py +231 -0
  25. package/runtime/python/okstra_ctl/error_report.py +2 -7
  26. package/runtime/python/okstra_ctl/implementation_outcome.py +12 -23
  27. package/runtime/python/okstra_ctl/implementation_stage.py +7 -2
  28. package/runtime/python/okstra_ctl/initial_prompt_materialization.py +1053 -0
  29. package/runtime/python/okstra_ctl/path_hints.py +3 -3
  30. package/runtime/python/okstra_ctl/paths.py +17 -2
  31. package/runtime/python/okstra_ctl/recap.py +5 -12
  32. package/runtime/python/okstra_ctl/render.py +18 -19
  33. package/runtime/python/okstra_ctl/report_finalize.py +8 -4
  34. package/runtime/python/okstra_ctl/report_language.py +83 -0
  35. package/runtime/python/okstra_ctl/run.py +262 -328
  36. package/runtime/python/okstra_ctl/set_work_status.py +2 -1
  37. package/runtime/python/okstra_ctl/stage_targets.py +330 -101
  38. package/runtime/python/okstra_ctl/time_report.py +4 -9
  39. package/runtime/python/okstra_ctl/wizard.py +47 -49
  40. package/runtime/python/okstra_ctl/work_categories.py +2 -2
  41. package/runtime/python/okstra_ctl/worker_prompt_body.py +82 -0
  42. package/runtime/python/okstra_ctl/worker_prompt_contract.py +73 -13
  43. package/runtime/python/okstra_ctl/worker_prompt_headers.py +7 -0
  44. package/runtime/python/okstra_ctl/worktree.py +37 -29
  45. package/runtime/python/okstra_project/__init__.py +4 -0
  46. package/runtime/python/okstra_project/dirs.py +7 -0
  47. package/runtime/python/okstra_project/state.py +78 -8
  48. package/runtime/validators/lib/fixtures.sh +2 -0
  49. package/runtime/validators/validate-run.py +3 -21
  50. package/src/commands/inspect/stage-map.mjs +12 -19
@@ -81,8 +81,8 @@ from okstra_ctl.worktree import (
81
81
  compute_worktree_path,
82
82
  is_git_work_tree,
83
83
  main_worktree_path,
84
- preview_stage_worktree_decision,
85
84
  preview_worktree_decision,
85
+ resolve_stage_worktree_decision,
86
86
  )
87
87
  from okstra_ctl.paths import RunRef, task_dir, task_runs_dir
88
88
  from okstra_ctl.work_categories import resolve_work_category
@@ -972,14 +972,7 @@ def _fix_cycle_confirm_required(state: WizardState) -> bool:
972
972
  return False
973
973
  task_root = task_dir(Path(state.project_root),
974
974
  state.task_group, state.task_id)
975
- manifest = task_root / "task-manifest.json"
976
- if not manifest.is_file():
977
- return False
978
- try:
979
- data = json.loads(manifest.read_text(encoding="utf-8"))
980
- except (OSError, json.JSONDecodeError):
981
- return False
982
- workflow = data.get("workflow") or {}
975
+ workflow = (read_task_manifest(task_root) or {}).get("workflow") or {}
983
976
  if workflow.get("lastCompletedPhase") != "release-handoff":
984
977
  return False
985
978
  return fix_cycles.open_cycle(fix_cycles.read_rows(task_root)) is None
@@ -1009,33 +1002,29 @@ def _parse_stage_objects(state: WizardState) -> list:
1009
1002
  return stages
1010
1003
 
1011
1004
 
1012
- def _done_stage_numbers(state: WizardState) -> set:
1013
- """approved plan 소비한 implementation run 들의 consumers.jsonl 에서
1014
- done 처리된 stage 번호 집합. 순수 읽기 — carry backfill 은 하지 않는다.
1005
+ def _stage_lifecycle_snapshot(
1006
+ state: WizardState, stages: list, *, reserved_stages: Optional[set] = None,
1007
+ ):
1008
+ """picker 가 보는 Stage Lifecycle Snapshot. 순수 읽기 — carry backfill 금지.
1015
1009
 
1016
1010
  backfill_done_from_carry 는 consumers.jsonl 에 done 행을 append 하고
1017
- 그 부수효과로 worktree-registry stage 점유를 해제한다. 이 함수는 stage
1011
+ 그 부수효과로 worktree-registry stage 점유를 해제한다. 이 스냅샷은 stage
1018
1012
  picker 표시와 progress 추정(_remaining_screens 가 매 step 반복 호출)에서만
1019
1013
  쓰이므로, 화면을 그리는 것만으로 동시 실행 중인 run 의 점유를 풀면 안 된다.
1020
- carry 기반 done 보정은 prepare 게이트(run.py)가 단일 진입점으로 수행한다."""
1021
- if not state.approved_plan_path:
1022
- return set()
1023
- from .consumers import read_consumers, latest_done_by_stage
1024
- plan_run_root = Path(state.approved_plan_path).resolve().parents[1]
1025
- rows = read_consumers(plan_run_root)
1026
- return set(latest_done_by_stage(rows).keys())
1014
+ carry 기반 done 보정은 prepare 게이트(run.py)가 단일 진입점으로 수행한다.
1027
1015
 
1028
-
1029
- def _started_stage_numbers(state: WizardState) -> set:
1030
- """approved plan 을 소비한 run 들의 consumers.jsonl 에서 status:started 인
1031
- stage 번호 집합. _done_stage_numbers 와 같은 순수 읽기 — carry backfill 금지."""
1032
- if not state.approved_plan_path:
1033
- return set()
1034
- from .consumers import read_consumers
1035
- plan_run_root = Path(state.approved_plan_path).resolve().parents[1]
1036
- rows = read_consumers(plan_run_root)
1037
- return {r["stage"] for r in rows
1038
- if r.get("status") == "started" and isinstance(r.get("stage"), int)}
1016
+ One snapshot per call site: the two ledger reads this replaced ran once per
1017
+ helper, and `_build_stage_pick` sits inside `_remaining_screens`' simulation.
1018
+ """
1019
+ from .stage_targets import read_stage_lifecycle_snapshot
1020
+ return read_stage_lifecycle_snapshot(
1021
+ [{"stage_number": s.stage_number,
1022
+ "depends_on": list(s.depends_on),
1023
+ "step_count": s.step_count} for s in stages],
1024
+ Path(state.approved_plan_path).resolve().parents[1],
1025
+ recover_from_carry=False,
1026
+ reserved_stages=reserved_stages,
1027
+ )
1039
1028
 
1040
1029
 
1041
1030
  def _reserved_stage_numbers(state: WizardState) -> set:
@@ -1068,7 +1057,7 @@ def _whole_task_allowed(
1068
1057
  if not stages:
1069
1058
  return False
1070
1059
  if done is None:
1071
- done = _done_stage_numbers(state)
1060
+ done = _stage_lifecycle_snapshot(state, stages).done_stages
1072
1061
  return all(s.stage_number in done for s in stages)
1073
1062
 
1074
1063
 
@@ -2192,9 +2181,11 @@ def _build_stage_pick(state: WizardState) -> Prompt:
2192
2181
  is_fv = state.task_type == "final-verification"
2193
2182
  is_impl = state.task_type == "implementation"
2194
2183
  label = (t["label_final_verification"] or t["label"]) if is_fv else t["label"]
2195
- done = _done_stage_numbers(state)
2196
- started = _started_stage_numbers(state) if is_impl else set()
2197
- reserved = _reserved_stage_numbers(state) if is_impl else set()
2184
+ snapshot = _stage_lifecycle_snapshot(
2185
+ state, stages,
2186
+ reserved_stages=_reserved_stage_numbers(state) if is_impl else None,
2187
+ )
2188
+ done = snapshot.done_stages
2198
2189
  options = []
2199
2190
  if _whole_task_allowed(state, stages=stages, done=done):
2200
2191
  options.append(_opt(WHOLE_TASK_STAGE, t["options"]["whole_task"]))
@@ -2208,7 +2199,8 @@ def _build_stage_pick(state: WizardState) -> Prompt:
2208
2199
  if s.stage_number in done
2209
2200
  else t["options"]["undone_mark"])
2210
2201
  elif is_impl:
2211
- suffix = " " + _impl_stage_marker(t, s, done, started, reserved)
2202
+ suffix = " " + _impl_stage_marker(
2203
+ t, snapshot.lifecycle_for(s.stage_number))
2212
2204
  options.append(_opt(
2213
2205
  str(s.stage_number),
2214
2206
  f"{s.stage_number}: {s.title} "
@@ -2222,15 +2214,19 @@ def _build_stage_pick(state: WizardState) -> Prompt:
2222
2214
  )
2223
2215
 
2224
2216
 
2225
- def _impl_stage_marker(t, stage, done, started, reserved) -> str:
2226
- n = stage.stage_number
2227
- if n in done:
2228
- return t["options"]["mark_done"]
2229
- if n in started or n in reserved:
2230
- return t["options"]["mark_active"]
2231
- if all(d in done for d in stage.depends_on):
2232
- return t["options"]["mark_ready"]
2233
- return t["options"]["mark_blocked"]
2217
+ # Presentation keys are mapped explicitly rather than interpolated from the
2218
+ # status, so renaming a Stage Lifecycle status is a compile-time-visible edit
2219
+ # here instead of a KeyError raised while drawing the picker.
2220
+ _STAGE_MARKER_KEYS = {
2221
+ "done": "mark_done",
2222
+ "active": "mark_active",
2223
+ "ready": "mark_ready",
2224
+ "blocked": "mark_blocked",
2225
+ }
2226
+
2227
+
2228
+ def _impl_stage_marker(t, lifecycle) -> str:
2229
+ return t["options"][_STAGE_MARKER_KEYS[lifecycle.status]]
2234
2230
 
2235
2231
 
2236
2232
  def _submit_stage_pick(state: WizardState, answer: str) -> Optional[str]:
@@ -2264,9 +2260,11 @@ def _submit_impl_stage_pick(state: WizardState, answer: str) -> Optional[str]:
2264
2260
  raise WizardError(t["errors"]["whole_task_impl"])
2265
2261
  stages = _parse_stage_objects(state)
2266
2262
  all_nums = {s.stage_number for s in stages}
2267
- done = _done_stage_numbers(state)
2268
- occupied = (done | _started_stage_numbers(state)
2269
- | _reserved_stage_numbers(state))
2263
+ snapshot = _stage_lifecycle_snapshot(
2264
+ state, stages, reserved_stages=_reserved_stage_numbers(state))
2265
+ done = snapshot.done_stages
2266
+ occupied = {lc.stage for lc in snapshot.lifecycles
2267
+ if lc.status in ("done", "active")}
2270
2268
  chosen = _impl_chosen_stages(t, picks, answer, all_nums, occupied)
2271
2269
  ordered = order_stage_closure(
2272
2270
  [(s.stage_number, s.depends_on) for s in stages], chosen, done)
@@ -3177,7 +3175,7 @@ def _worktree_preview_line_impl(state: WizardState) -> str:
3177
3175
  )
3178
3176
  return _msg(state.workspace_root, "confirmation", "worktree_impl_auto",
3179
3177
  path=str(parent_dir))
3180
- decision = preview_stage_worktree_decision(
3178
+ decision = resolve_stage_worktree_decision(
3181
3179
  project_id=state.project_id, task_group_segment=state.task_group,
3182
3180
  task_id_segment=state.task_id,
3183
3181
  work_category=_preview_work_category(state),
@@ -11,7 +11,7 @@ from __future__ import annotations
11
11
  import json
12
12
  from pathlib import Path
13
13
 
14
- from .paths import task_dir
14
+ from .paths import task_dir, task_manifest_file
15
15
 
16
16
  WORK_CATEGORIES: tuple[str, ...] = (
17
17
  "bugfix",
@@ -30,7 +30,7 @@ def is_valid_category(value: str) -> bool:
30
30
 
31
31
  def recorded_work_category(project_root: Path, task_group: str, task_id: str) -> str:
32
32
  """task-manifest.json 에 이미 기록된 work-category. 없거나 enum 밖이면 ""."""
33
- manifest = task_dir(project_root, task_group, task_id) / "task-manifest.json"
33
+ manifest = task_manifest_file(task_dir(project_root, task_group, task_id))
34
34
  try:
35
35
  data = json.loads(manifest.read_text(encoding="utf-8"))
36
36
  except (OSError, json.JSONDecodeError):
@@ -71,6 +71,88 @@ def analysis_input_lines(
71
71
  return existing_input_lines(inputs)
72
72
 
73
73
 
74
+ REPORT_WRITER_WORKER_ID = "report-writer"
75
+
76
+
77
+ def report_writer_prompt_body(
78
+ manifest: Mapping[str, Any],
79
+ active_context: Mapping[str, Any],
80
+ team_state: Mapping[str, Any],
81
+ model: str,
82
+ report_language: str,
83
+ ) -> list[str]:
84
+ """Render the provider-neutral body for the report-writer audience."""
85
+ return [
86
+ f"**Model:** Report writer worker, {model}",
87
+ f"**Report Language:** {report_language}",
88
+ "",
89
+ "# Report Writer Worker Dispatch",
90
+ "",
91
+ "## Role",
92
+ (
93
+ "You are the Report writer worker. Author the final-report data.json "
94
+ "and the worker-results audit file."
95
+ ),
96
+ "",
97
+ "## Task",
98
+ f"- Task key: `{_require_string(manifest, 'taskKey')}`",
99
+ f"- Task type: `{_require_string(manifest, 'taskType')}`",
100
+ "",
101
+ "## Inputs",
102
+ *report_writer_input_lines(manifest, active_context, team_state),
103
+ "",
104
+ mcp_pointer_line(),
105
+ "",
106
+ "## Output Contract",
107
+ "You are the author of TWO files:",
108
+ "- The final-report data.json at Result Path.",
109
+ "- The worker-results audit file at Audit sidecar path.",
110
+ (
111
+ 'After writing the data.json, invoke "okstra render-final-report '
112
+ '<Result Path>" so the markdown sibling is rendered before you return.'
113
+ ),
114
+ "Do not return the report inline.",
115
+ "Copy Report Language verbatim into data.json.meta.reportLanguage.",
116
+ ]
117
+
118
+
119
+ def report_writer_input_lines(
120
+ manifest: Mapping[str, Any],
121
+ active_context: Mapping[str, Any],
122
+ team_state: Mapping[str, Any],
123
+ ) -> list[str]:
124
+ inputs = [
125
+ ("Task brief", instruction_path(manifest, active_context, "taskBriefPath")),
126
+ ("Analysis profile", instruction_path(manifest, active_context, "analysisProfilePath")),
127
+ ("Analysis material", instruction_path(manifest, active_context, "analysisMaterialPath")),
128
+ ("Reference expectations", instruction_path(manifest, active_context, "referenceExpectationsPath")),
129
+ ("Clarification response", instruction_path(manifest, active_context, "clarificationResponsePath")),
130
+ ("Final report template", instruction_path(manifest, active_context, "finalReportTemplatePath")),
131
+ ("Final report schema", instruction_path(manifest, active_context, "finalReportSchemaPath")),
132
+ ("Worker results directory", _string_value(manifest.get("workerResultsDirectoryPath"))),
133
+ ]
134
+ lines = existing_input_lines(inputs)
135
+ lines.extend(analysis_worker_result_lines(team_state))
136
+ return lines or ["- No report-writer inputs were recorded in active-run-context."]
137
+
138
+
139
+ def analysis_worker_result_lines(team_state: Mapping[str, Any]) -> list[str]:
140
+ lines = []
141
+ workers = team_state.get("workers")
142
+ if not isinstance(workers, list):
143
+ return lines
144
+ for worker in workers:
145
+ if not isinstance(worker, Mapping):
146
+ continue
147
+ worker_id = worker.get("workerId")
148
+ if worker_id == REPORT_WRITER_WORKER_ID:
149
+ continue
150
+ result_path = _string_value(worker.get("resultPath"))
151
+ if result_path:
152
+ lines.append(f"- Analysis result ({worker_id}): `{result_path}`")
153
+ return lines
154
+
155
+
74
156
  def existing_input_lines(inputs: Sequence[tuple[str, str]]) -> list[str]:
75
157
  lines = [f"- {label}: `{path}`" for label, path in inputs if path]
76
158
  return lines or ["- No input paths were recorded in active-run-context."]
@@ -16,6 +16,10 @@ from .worker_prompt_policy import (
16
16
  MAX_FINAL_VERIFICATION_DIRECTIVE_LINES = 40
17
17
  MAX_FINAL_VERIFICATION_BODY_LINES = 96
18
18
 
19
+ PROMPT_DELIVERY_MODE_HEADER = "**Prompt Delivery Mode:**"
20
+ PROMPT_DELIVERY_MODES = frozenset({"eager-include", "lazy-path-reference"})
21
+ MODEL_HEADER = "**Model:**"
22
+
19
23
  _DIRECTIVE_HEADING = "## Run-specific directive"
20
24
  _WORKER_ERROR_CONTRACT_HEADER = "**Worker Error Contract Path:**"
21
25
  _PRIMARY_PACKET_RE = re.compile(
@@ -48,6 +52,7 @@ _NON_BODY_PREFIXES = (
48
52
  "**Verification head ref:**",
49
53
  "**Verification target path:**",
50
54
  "**Verification target digest:**",
55
+ PROMPT_DELIVERY_MODE_HEADER,
51
56
  )
52
57
  _REQUIRED_TARGET_PREFIXES = (
53
58
  "**Worktree:**",
@@ -81,6 +86,8 @@ class PromptRecord:
81
86
  worker_id: str
82
87
  dispatch_kind: str
83
88
  path: Path
89
+ expected_model: str | None = None
90
+ expected_delivery_mode: str | None = None
84
91
 
85
92
 
86
93
  def validate_final_verification_initial_prompt(text: str) -> list[str]:
@@ -170,19 +177,6 @@ def validate_analysis_prompt_set(prompts: Mapping[str, str]) -> list[str]:
170
177
  return [f"normalized initial analysis prompts differ across workers: {workers}"]
171
178
 
172
179
 
173
- def validate_final_verification_prompt_paths(
174
- prompt_paths: Mapping[str, Path],
175
- ) -> list[str]:
176
- """Validate persisted initial prompts and their normalized equality."""
177
- records = [
178
- PromptRecord(worker_id, "initial", path)
179
- for worker_id, path in sorted(prompt_paths.items())
180
- ]
181
- return validate_initial_prompt_records(
182
- manifest={"taskType": "final-verification"},
183
- records=records,
184
- )
185
-
186
180
 
187
181
  def validate_initial_prompt_records(
188
182
  *,
@@ -207,6 +201,10 @@ def validate_initial_prompt_records(
207
201
  f"{record.worker_id}: {error}"
208
202
  for error in _validate_prompt_for_plan(text, plan, manifest)
209
203
  )
204
+ errors.extend(
205
+ f"{record.worker_id}: {error}"
206
+ for error in _validate_record_metadata(text, record)
207
+ )
210
208
  if plan.equality_group:
211
209
  group = equality_groups.setdefault(plan.equality_group, {})
212
210
  group[record.worker_id] = text
@@ -215,6 +213,68 @@ def validate_initial_prompt_records(
215
213
  return errors
216
214
 
217
215
 
216
+ def _validate_record_metadata(text: str, record: PromptRecord) -> list[str]:
217
+ errors = _validate_delivery_mode(
218
+ _header_values(text, PROMPT_DELIVERY_MODE_HEADER),
219
+ record.expected_delivery_mode,
220
+ )
221
+ if record.expected_model is None:
222
+ return errors
223
+ model = _model_value(_header_values(text, MODEL_HEADER))
224
+ if model is None:
225
+ errors.append(
226
+ "exactly one non-empty **Model:** <label>, <model> header is required"
227
+ )
228
+ elif model != record.expected_model:
229
+ errors.append(
230
+ f"prompt model does not match requested model: {record.expected_model}"
231
+ )
232
+ return errors
233
+
234
+
235
+ def _validate_delivery_mode(
236
+ values: list[str],
237
+ expected: str | None,
238
+ ) -> list[str]:
239
+ errors = []
240
+ if len(values) != 1 or not values[0]:
241
+ errors.append(
242
+ "exactly one non-empty **Prompt Delivery Mode:** header is required"
243
+ )
244
+ errors.extend(
245
+ f"unsupported Prompt Delivery Mode: {value}"
246
+ for value in values
247
+ if value and value not in PROMPT_DELIVERY_MODES
248
+ )
249
+ if (
250
+ expected is not None
251
+ and len(values) == 1
252
+ and values[0]
253
+ and values[0] != expected
254
+ ):
255
+ errors.append(
256
+ f"Prompt Delivery Mode does not match requested mode: {expected}"
257
+ )
258
+ return errors
259
+
260
+
261
+ def _header_values(text: str, prefix: str) -> list[str]:
262
+ return [
263
+ line.strip()[len(prefix):].strip()
264
+ for line in text.splitlines()
265
+ if line.strip().startswith(prefix)
266
+ ]
267
+
268
+
269
+ def _model_value(values: list[str]) -> str | None:
270
+ if len(values) != 1:
271
+ return None
272
+ parts = [part.strip() for part in values[0].rsplit(",", 1)]
273
+ if len(parts) != 2 or not all(parts):
274
+ return None
275
+ return parts[1]
276
+
277
+
218
278
  def _resolve_record_plan(
219
279
  manifest: Mapping[str, Any],
220
280
  record: PromptRecord,
@@ -85,6 +85,13 @@ def worker_prompt_headers(
85
85
  f"**Project Root:** {project_root}",
86
86
  f"**Prompt History Path:** {prompt_rel}",
87
87
  f"**Result Path:** {result_rel}",
88
+ ]
89
+ if audit_source_rel and audit_source_rel != result_rel:
90
+ # The audit path is derived from this file, not from Result Path, so the
91
+ # worker has to be told which one it is (report-writer, whose Result Path
92
+ # is the report data.json rather than its own worker result).
93
+ headers.append(f"**Worker Result Path:** {audit_source_rel}")
94
+ headers += [
88
95
  f"**Audit sidecar path:** {audit_sidecar_path}",
89
96
  f"Assigned worker prompt history path: {prompt_path}",
90
97
  f"**Worker Preamble Path:** {_worker_preamble_path(plan, active_context)}",
@@ -194,31 +194,38 @@ def preview_worktree_decision(
194
194
  )
195
195
 
196
196
 
197
- def preview_stage_worktree_decision(
197
+ @dataclass
198
+ class StageWorktreeDecision:
199
+ """Side-effect-free decision for one concrete implementation stage."""
200
+
201
+ status: str
202
+ path: str
203
+ branch: str = ""
204
+ base_ref: str = ""
205
+
206
+
207
+ def resolve_stage_worktree_decision(
198
208
  *,
199
209
  project_id: str,
200
210
  task_group_segment: str,
201
211
  task_id_segment: str,
202
212
  work_category: str,
203
213
  stage_number: int,
204
- ) -> "WorktreeDecision":
205
- """Side-effect-free: what provision_stage_worktree WOULD do (reuse vs new).
206
-
207
- Mirrors provision_stage_worktree's registry-reuse branch exactly. A "new"
208
- decision carries no base_ref — the stage base commit is resolved later by
209
- the prepare flow from the stage's dependency anchors.
210
- """
214
+ ) -> StageWorktreeDecision:
215
+ """Resolve whether one concrete stage worktree is new or reusable."""
211
216
  safe_project = _safe_segment(project_id)
212
217
  safe_group = _safe_segment(task_group_segment)
213
218
  safe_task = _safe_segment(task_id_segment)
214
219
  existing = worktree_registry.lookup(
215
220
  safe_project, safe_group, safe_task, stage_number=stage_number)
216
221
  if existing is not None and existing.status == "active":
217
- return WorktreeDecision(
218
- status="reused", path=existing.worktree_path,
219
- branch=existing.branch, base_ref=existing.base_ref,
222
+ return StageWorktreeDecision(
223
+ status="reused",
224
+ path=existing.worktree_path,
225
+ branch=existing.branch,
226
+ base_ref=existing.base_ref,
220
227
  )
221
- return WorktreeDecision(
228
+ return StageWorktreeDecision(
222
229
  status="new",
223
230
  path=str(compute_worktree_path(
224
231
  project_id=safe_project, task_group_segment=safe_group,
@@ -923,31 +930,32 @@ def provision_stage_worktree(
923
930
  if not base_commit:
924
931
  raise RuntimeError("provision_stage_worktree requires a base_commit")
925
932
 
926
- safe_project = _safe_segment(project_id)
927
- safe_group = _safe_segment(task_group_segment)
928
- safe_task = _safe_segment(task_id_segment)
929
- worktree_path = compute_worktree_path(
930
- project_id=safe_project, task_group_segment=safe_group,
931
- task_id_segment=safe_task, stage_number=stage_number,
932
- )
933
- branch = compute_branch_name(
934
- work_category=work_category, task_id_segment=safe_task,
933
+ decision = resolve_stage_worktree_decision(
934
+ project_id=project_id,
935
+ task_group_segment=task_group_segment,
936
+ task_id_segment=task_id_segment,
937
+ work_category=work_category,
935
938
  stage_number=stage_number,
936
939
  )
937
-
938
- existing = worktree_registry.lookup(
939
- safe_project, safe_group, safe_task, stage_number=stage_number)
940
- if existing is not None and existing.status == "active":
940
+ if decision.status == "reused":
941
941
  return WorktreeProvision(
942
- status="reused", path=existing.worktree_path,
943
- branch=existing.branch, base_ref=existing.base_ref,
942
+ status="reused",
943
+ path=decision.path,
944
+ branch=decision.branch,
945
+ base_ref=decision.base_ref,
944
946
  note=(
945
947
  f"stage {stage_number} worktree reused at "
946
- f"{existing.worktree_path} on branch {existing.branch} "
947
- f"(base {existing.base_ref[:12]})"
948
+ f"{decision.path} on branch {decision.branch} "
949
+ f"(base {decision.base_ref[:12]})"
948
950
  ),
949
951
  )
950
952
 
953
+ safe_project = _safe_segment(project_id)
954
+ safe_group = _safe_segment(task_group_segment)
955
+ safe_task = _safe_segment(task_id_segment)
956
+ worktree_path = Path(decision.path)
957
+ branch = decision.branch
958
+
951
959
  if worktree_path.exists():
952
960
  raise RuntimeError(
953
961
  f"stage worktree path already exists but is not in the registry: "
@@ -33,11 +33,13 @@ from .state import (
33
33
  parse_task_key,
34
34
  read_latest_task,
35
35
  read_task_catalog,
36
+ read_task_key,
36
37
  read_task_manifest,
37
38
  resolve_task_id,
38
39
  resolve_task_identity,
39
40
  resolve_task_reference,
40
41
  slugify,
42
+ stage_map_read_side_snapshot,
41
43
  task_read_side_snapshot,
42
44
  )
43
45
 
@@ -59,12 +61,14 @@ __all__ = [
59
61
  "project_json_path",
60
62
  "read_latest_task",
61
63
  "read_task_catalog",
64
+ "read_task_key",
62
65
  "read_task_manifest",
63
66
  "resolve_project_root",
64
67
  "resolve_task_id",
65
68
  "resolve_task_identity",
66
69
  "resolve_task_reference",
67
70
  "slugify",
71
+ "stage_map_read_side_snapshot",
68
72
  "task_read_side_snapshot",
69
73
  "tasks_root",
70
74
  "upsert_project_json",
@@ -37,6 +37,13 @@ OKSTRA_RELATIVE = Path(OKSTRA_DIR_NAME)
37
37
  LEGACY_OKSTRA_RELATIVE = Path(LEGACY_OKSTRA_DIR_NAME)
38
38
  PROJECT_JSON_RELATIVE = OKSTRA_RELATIVE / "project.json"
39
39
  TASKS_RELATIVE = OKSTRA_RELATIVE / "tasks"
40
+
41
+ TASK_MANIFEST_FILENAME = "task-manifest.json"
42
+ """task root 안의 lifecycle manifest 파일명.
43
+
44
+ `okstra_ctl.paths.task_manifest_path` 와 `okstra_project.state.read_task_manifest`
45
+ 양쪽이 이 상수를 쓴다 — paths 가 state 를 import 하므로 역방향 import 는 순환이고,
46
+ 두 계층이 공유할 수 있는 자리는 여기뿐이다."""
40
47
  DISCOVERY_RELATIVE = OKSTRA_RELATIVE / "discovery"
41
48
  TASK_CATALOG_RELATIVE = DISCOVERY_RELATIVE / "task-catalog.json"
42
49
  LATEST_TASK_RELATIVE = DISCOVERY_RELATIVE / "latest-task.json"