okstra 0.38.1 → 0.40.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 (80) hide show
  1. package/README.kr.md +1 -1
  2. package/README.md +1 -1
  3. package/docs/kr/architecture.md +18 -2
  4. package/docs/kr/cli.md +1 -1
  5. package/docs/project-structure-overview.md +2 -3
  6. package/docs/superpowers/plans/2026-06-02-final-verification-protocol-hardening.md +326 -0
  7. package/docs/superpowers/plans/2026-06-02-okstra-run-branch-confirm-step.md +337 -0
  8. package/docs/superpowers/plans/2026-06-02-okstra-run-phase-pane-cleanup.md +410 -0
  9. package/docs/superpowers/plans/2026-06-02-requirements-discovery-fanout.md +728 -0
  10. package/docs/superpowers/specs/2026-06-02-okstra-run-branch-confirm-step-design.md +113 -0
  11. package/docs/superpowers/specs/2026-06-02-okstra-run-phase-pane-cleanup-design.md +173 -0
  12. package/docs/superpowers/specs/2026-06-02-requirements-discovery-fanout-design.md +154 -0
  13. package/docs/task-process/requirements-discovery.md +1 -1
  14. package/package.json +3 -2
  15. package/runtime/BUILD.json +2 -2
  16. package/runtime/{python → bin}/lib/okstra/usage.sh +3 -2
  17. package/runtime/bin/okstra-codex-exec.sh +3 -3
  18. package/runtime/bin/okstra-trace-cleanup.sh +64 -26
  19. package/runtime/prompts/profiles/_common-contract.md +9 -5
  20. package/runtime/prompts/profiles/final-verification.md +18 -16
  21. package/runtime/prompts/profiles/implementation-planning.md +1 -0
  22. package/runtime/prompts/profiles/requirements-discovery.md +18 -1
  23. package/runtime/prompts/wizard/prompts.ko.json +11 -0
  24. package/runtime/python/okstra_ctl/consumers.py +1 -1
  25. package/runtime/python/okstra_ctl/fanout.py +35 -0
  26. package/runtime/python/okstra_ctl/migrate.py +21 -42
  27. package/runtime/python/okstra_ctl/reconcile.py +2 -2
  28. package/runtime/python/okstra_ctl/render_final_report.py +0 -1
  29. package/runtime/python/okstra_ctl/run.py +0 -29
  30. package/runtime/python/okstra_ctl/run_context.py +9 -12
  31. package/runtime/python/okstra_ctl/seeding.py +0 -192
  32. package/runtime/python/okstra_ctl/wizard.py +70 -5
  33. package/runtime/python/okstra_ctl/work_categories.py +21 -0
  34. package/runtime/python/okstra_ctl/worktree.py +74 -77
  35. package/runtime/python/okstra_project/__init__.py +0 -6
  36. package/runtime/python/okstra_project/dirs.py +0 -8
  37. package/runtime/schemas/final-report-v1.0.schema.json +34 -27
  38. package/runtime/skills/okstra-context-loader/SKILL.md +1 -1
  39. package/runtime/skills/okstra-convergence/SKILL.md +1 -1
  40. package/runtime/skills/okstra-inspect/SKILL.md +1 -1
  41. package/runtime/skills/okstra-run/SKILL.md +2 -0
  42. package/runtime/templates/prd/brief.template.md +1 -1
  43. package/runtime/templates/reports/fan-out-unit.template.md +25 -0
  44. package/runtime/templates/reports/final-report.template.md +24 -13
  45. package/runtime/templates/reports/final-verification-input.template.md +16 -5
  46. package/runtime/templates/reports/i18n/en.json +6 -3
  47. package/runtime/templates/reports/i18n/ko.json +6 -3
  48. package/runtime/templates/worker-prompt-preamble.md +7 -0
  49. package/runtime/validators/lib/fixtures.sh +2 -2
  50. package/runtime/validators/lib/validate-assets.sh +9 -0
  51. package/runtime/validators/validate-implementation-plan-stages.py +19 -11
  52. package/runtime/validators/validate-run.py +114 -0
  53. package/runtime/validators/validate-schedule.py +4 -4
  54. package/runtime/validators/validate_fanout.py +99 -0
  55. package/src/_proc.mjs +31 -0
  56. package/src/check-project.mjs +1 -25
  57. package/src/config.mjs +7 -31
  58. package/src/doctor.mjs +10 -29
  59. package/src/install.mjs +8 -36
  60. package/src/migrate.mjs +1 -18
  61. package/src/okstra-dirs.mjs +0 -11
  62. package/src/setup.mjs +1 -154
  63. package/src/uninstall.mjs +6 -13
  64. package/runtime/templates/okstra.CLAUDE.md +0 -104
  65. /package/runtime/{python → bin}/lib/okstra/cli.sh +0 -0
  66. /package/runtime/{python → bin}/lib/okstra/globals.sh +0 -0
  67. /package/runtime/{python → bin}/lib/okstra/interactive.sh +0 -0
  68. /package/runtime/{python → bin}/lib/okstra/project-resolver.sh +0 -0
  69. /package/runtime/{python → bin}/lib/okstra-ctl/cmd-batch.sh +0 -0
  70. /package/runtime/{python → bin}/lib/okstra-ctl/cmd-list.sh +0 -0
  71. /package/runtime/{python → bin}/lib/okstra-ctl/cmd-open.sh +0 -0
  72. /package/runtime/{python → bin}/lib/okstra-ctl/cmd-projects.sh +0 -0
  73. /package/runtime/{python → bin}/lib/okstra-ctl/cmd-reconcile.sh +0 -0
  74. /package/runtime/{python → bin}/lib/okstra-ctl/cmd-reindex.sh +0 -0
  75. /package/runtime/{python → bin}/lib/okstra-ctl/cmd-rerun.sh +0 -0
  76. /package/runtime/{python → bin}/lib/okstra-ctl/cmd-show.sh +0 -0
  77. /package/runtime/{python → bin}/lib/okstra-ctl/cmd-tail.sh +0 -0
  78. /package/runtime/{python → bin}/lib/okstra-ctl/main.sh +0 -0
  79. /package/runtime/{python → bin}/lib/okstra-ctl/prepare.sh +0 -0
  80. /package/runtime/{python → bin}/lib/okstra-ctl/usage.sh +0 -0
@@ -14,12 +14,6 @@ import time
14
14
  from pathlib import Path
15
15
  from typing import Optional
16
16
 
17
- from okstra_project.dirs import (
18
- CLAUDE_MD_IMPORT_LINE,
19
- CLAUDE_MD_SYMLINK_RELATIVE,
20
- OKSTRA_DIR_NAME,
21
- )
22
-
23
17
 
24
18
  class InstallationError(Exception):
25
19
  """okstra 가 깔아둔 런타임 자산이 누락됨."""
@@ -29,14 +23,6 @@ class SettingsLinkError(Exception):
29
23
  """`<project>/.claude/settings.local.json` symlink provisioning 실패."""
30
24
 
31
25
 
32
- class ClaudeMdLinkError(Exception):
33
- """okstra-relative `CLAUDE.md` symlink or import-block provisioning 실패."""
34
-
35
-
36
- class AgentsMdLinkError(Exception):
37
- """`<project>/AGENTS.md` symlink provisioning 실패."""
38
-
39
-
40
26
  def installed_version() -> str:
41
27
  """Read the version stamp written by `okstra install` to `~/.okstra/version`.
42
28
 
@@ -209,181 +195,3 @@ def _backup_and_replace(target: Path, template: Path) -> None:
209
195
  raise SettingsLinkError(
210
196
  f"failed to create symlink {target} -> {template} after backup: {exc}"
211
197
  ) from exc
212
-
213
-
214
- def installed_claude_md_template_path() -> Path:
215
- """okstra install 이 만들어 둔 okstra.CLAUDE.md template 의 절대경로."""
216
- return _okstra_home() / "templates" / "okstra.CLAUDE.md"
217
-
218
-
219
- _CLAUDE_MD_SYMLINK_REL = CLAUDE_MD_SYMLINK_RELATIVE
220
- _CLAUDE_MD_IMPORT_LINE = CLAUDE_MD_IMPORT_LINE
221
- _CLAUDE_MD_MARKER_BEGIN = (
222
- "<!-- okstra:claude-md:begin (managed by okstra setup — do not edit) -->"
223
- )
224
- _CLAUDE_MD_MARKER_END = "<!-- okstra:claude-md:end -->"
225
-
226
-
227
- def ensure_project_claude_md(*, project_root: Path) -> Optional[Path]:
228
- """okstra-relative `CLAUDE.md` 를 `~/.okstra/templates/okstra.CLAUDE.md`
229
- 로 가리키는 symlink 로 provisioning 하고, `<project_root>/CLAUDE.md` 에
230
- import block 을 멱등하게 주입한다.
231
-
232
- Claude Code 가 해당 프로젝트에서 host 세션으로 실행될 때
233
- `<project_root>/CLAUDE.md` 가 자동 로드되므로, okstra 가 관리하는 본문
234
- (slash command catalog, workflow guidance, ...) 도 같이 surface 된다.
235
-
236
- 반환값:
237
- - symlink Path: 신규 생성됐거나 이미 올바른 target 을 가리키고 있을 때.
238
- - None: install 이 아직 CLAUDE.md template 을 깔지 않았을 때 (구버전
239
- okstra install). 상위에서 경고로 흘려보낸다.
240
-
241
- 상위 호출자는 `ClaudeMdLinkError` 만 처리하면 된다.
242
- """
243
- project_root = Path(project_root)
244
- template = installed_claude_md_template_path()
245
- if not template.exists():
246
- return None
247
-
248
- target = project_root / _CLAUDE_MD_SYMLINK_REL
249
- target.parent.mkdir(parents=True, exist_ok=True)
250
-
251
- if target.is_symlink():
252
- try:
253
- current = os.readlink(target)
254
- except OSError as exc:
255
- raise ClaudeMdLinkError(
256
- f"failed to read existing symlink {target}: {exc}"
257
- ) from exc
258
- current_path = Path(current)
259
- if current_path == template or (target.parent / current_path).resolve() == template.resolve():
260
- _ensure_claude_md_import(project_root)
261
- return target
262
- _backup_and_replace_claude_md(target, template)
263
- _ensure_claude_md_import(project_root)
264
- return target
265
-
266
- if target.exists():
267
- _backup_and_replace_claude_md(target, template)
268
- _ensure_claude_md_import(project_root)
269
- return target
270
-
271
- try:
272
- target.symlink_to(template)
273
- except OSError as exc:
274
- raise ClaudeMdLinkError(
275
- f"failed to create symlink {target} -> {template}: {exc}"
276
- ) from exc
277
- _ensure_claude_md_import(project_root)
278
- return target
279
-
280
-
281
- def _ensure_claude_md_import(project_root: Path) -> bool:
282
- """`<project_root>/CLAUDE.md` 에 import block 이 없으면 append, 있으면 no-op.
283
-
284
- 반환: 새로 주입했을 때 True, 이미 있었을 때 False.
285
- """
286
- claude_md = project_root / "CLAUDE.md"
287
- block = f"{_CLAUDE_MD_MARKER_BEGIN}\n{_CLAUDE_MD_IMPORT_LINE}\n{_CLAUDE_MD_MARKER_END}\n"
288
-
289
- try:
290
- existing = claude_md.read_text(encoding="utf-8")
291
- except FileNotFoundError:
292
- try:
293
- claude_md.write_text(block, encoding="utf-8")
294
- except OSError as exc:
295
- raise ClaudeMdLinkError(
296
- f"failed to create {claude_md}: {exc}"
297
- ) from exc
298
- return True
299
- except OSError as exc:
300
- raise ClaudeMdLinkError(f"failed to read {claude_md}: {exc}") from exc
301
-
302
- if _CLAUDE_MD_MARKER_BEGIN in existing and _CLAUDE_MD_MARKER_END in existing:
303
- return False
304
-
305
- separator = _block_separator_for(existing)
306
- try:
307
- claude_md.write_text(existing + separator + block, encoding="utf-8")
308
- except OSError as exc:
309
- raise ClaudeMdLinkError(f"failed to update {claude_md}: {exc}") from exc
310
- return True
311
-
312
-
313
- def _block_separator_for(existing: str) -> str:
314
- """기존 파일 끝과 import block 사이의 공백 결정 — 가독성 보장 목적."""
315
- if not existing:
316
- return ""
317
- if existing.endswith("\n\n"):
318
- return ""
319
- if existing.endswith("\n"):
320
- return "\n"
321
- return "\n\n"
322
-
323
-
324
- def _backup_and_replace_claude_md(target: Path, template: Path) -> None:
325
- """기존 파일/심볼릭링크를 timestamped backup 으로 옮기고 새 symlink 생성."""
326
- stamp = time.strftime("%Y%m%d-%H%M%S")
327
- backup = target.with_name(f"{target.name}.bak.{stamp}")
328
- try:
329
- target.rename(backup)
330
- except OSError as exc:
331
- raise ClaudeMdLinkError(
332
- f"failed to back up existing {target} to {backup}: {exc}"
333
- ) from exc
334
- try:
335
- target.symlink_to(template)
336
- except OSError as exc:
337
- raise ClaudeMdLinkError(
338
- f"failed to create symlink {target} -> {template} after backup: {exc}"
339
- ) from exc
340
-
341
-
342
- def ensure_project_agents_md(*, project_root: Path) -> Optional[Path]:
343
- """`<project_root>/AGENTS.md` 가 없을 때만 `~/.okstra/templates/okstra.CLAUDE.md`
344
- 로의 심링크로 생성한다.
345
-
346
- AGENTS.md 는 codex / aider / 기타 agent 가 읽는 파일이고 @import 같은
347
- 부분 포함 메커니즘이 없어, 파일 전체 내용이 곧 agent 가 보는 콘텐츠가
348
- 된다. 그래서 CLAUDE.md (`@.okstra/CLAUDE.md` 마커 블록
349
- 주입) 와 달리 "AGENTS.md 가 비어 있을 때만 만들고, 존재하면 절대
350
- 건드리지 않는" 정책을 사용한다 — 사용자가 직접 작성한 AGENTS.md 를
351
- 덮어쓰지 않는다.
352
-
353
- 반환값:
354
- - target Path: 신규 심링크 생성, 또는 이미 우리 템플릿을 가리키고
355
- 있던 심링크가 idempotent 하게 확인된 경우.
356
- - None: install 이 아직 CLAUDE.md template 을 깔지 않았거나,
357
- AGENTS.md 가 이미 사용자 콘텐츠 (regular file) 거나 우리가
358
- 만들지 않은 다른 심링크로 존재하는 경우. 후자 두 케이스는
359
- 사용자의 의도로 간주하고 건드리지 않는다.
360
-
361
- 상위 호출자는 `AgentsMdLinkError` 만 처리하면 된다.
362
- """
363
- project_root = Path(project_root)
364
- template = installed_claude_md_template_path()
365
- if not template.exists():
366
- return None
367
-
368
- target = project_root / "AGENTS.md"
369
-
370
- if target.is_symlink():
371
- try:
372
- current = os.readlink(target)
373
- except OSError:
374
- return None
375
- current_path = Path(current)
376
- if current_path == template or (target.parent / current_path).resolve() == template.resolve():
377
- return target
378
- return None # foreign symlink — respect user
379
-
380
- if target.exists():
381
- return None # regular file — respect user content
382
-
383
- try:
384
- target.symlink_to(template)
385
- except OSError as exc:
386
- raise AgentsMdLinkError(
387
- f"failed to create symlink {target} -> {template}: {exc}"
388
- ) from exc
389
- return target
@@ -31,6 +31,7 @@ from okstra_ctl.models import (
31
31
  UnknownModelError,
32
32
  resolve_model_metadata,
33
33
  )
34
+ from okstra_ctl.clarification_items import unresolved_approval_blockers
34
35
  from okstra_ctl.pr_template import PrTemplateError, resolve_pr_template_path
35
36
  from okstra_ctl.run import (
36
37
  APPROVED_FRONTMATTER_PATTERN,
@@ -45,6 +46,7 @@ from okstra_ctl.workers import (
45
46
  validate_workers_against_profile,
46
47
  )
47
48
  from okstra_ctl import worktree_registry
49
+ from okstra_ctl.worktree import preview_worktree_decision
48
50
  from okstra_project.dirs import project_json_path, tasks_root
49
51
  from okstra_project.state import (
50
52
  StateError,
@@ -196,6 +198,7 @@ S_CLARIFICATION = "clarification"
196
198
  S_PR_TEMPLATE_PICK = "pr_template_pick"
197
199
  S_PR_TEMPLATE = "pr_template"
198
200
  S_PR_TEMPLATE_SCOPE = "pr_template_scope"
201
+ S_BRANCH_CONFIRM = "branch_confirm"
199
202
  S_CONFIRM = "confirm"
200
203
  S_EDIT_TARGET = "edit_target"
201
204
  S_DONE = "done"
@@ -267,6 +270,7 @@ class WizardState:
267
270
  last_pr_template_cached: str = ""
268
271
 
269
272
  # confirm / edit
273
+ branch_confirmed: Optional[bool] = None
270
274
  confirmed: Optional[bool] = None
271
275
  edit_target: str = ""
272
276
 
@@ -365,6 +369,18 @@ def _validate_approved_plan(path_str: str, project_root: Path) -> Path:
365
369
  " edit the report and change the line to `approved: true`, or re-run "
366
370
  "okstra with `--approve` to flip it from the CLI."
367
371
  )
372
+ # frontmatter approved == true 라도 §5 의 Blocks=approval 행이 미해결이면
373
+ # 승인이 무효 — prepare_task_bundle 의 _validate_approved_plan 과 동일 규약.
374
+ blockers = unresolved_approval_blockers(body)
375
+ if blockers:
376
+ lines = [
377
+ f"approved plan frontmatter has `approved: true` but §5 has {len(blockers)} "
378
+ f"unresolved `Blocks=approval` row(s); resolve them or mark them obsolete first:",
379
+ ]
380
+ for b in blockers:
381
+ lines.append(f" - {b.row_id} (Status={b.raw_status})")
382
+ lines.append(f" file: {p}")
383
+ raise WizardError("\n".join(lines))
368
384
  return p
369
385
 
370
386
 
@@ -1160,10 +1176,12 @@ def _build_stage_pick(state: WizardState) -> Prompt:
1160
1176
  / "validate-implementation-plan-stages.py"
1161
1177
  )
1162
1178
  spec = _ilu.spec_from_file_location("_ip_stage_v_wizard", str(validator_path))
1163
- mod = _ilu.module_from_spec(spec) # type: ignore[arg-type]
1179
+ if spec is None or spec.loader is None:
1180
+ raise WizardError(f"cannot load stage validator at {validator_path}")
1181
+ mod = _ilu.module_from_spec(spec)
1164
1182
  _sys.modules["_ip_stage_v_wizard"] = mod
1165
1183
  try:
1166
- spec.loader.exec_module(mod) # type: ignore[union-attr]
1184
+ spec.loader.exec_module(mod)
1167
1185
  stages, _errs = mod._parse_stage_map(plan_text)
1168
1186
  finally:
1169
1187
  _sys.modules.pop("_ip_stage_v_wizard", None)
@@ -1698,6 +1716,49 @@ def _submit_pr_template_scope(state: WizardState, value: str) -> Optional[str]:
1698
1716
  return f"pr-template-scope: {value}"
1699
1717
 
1700
1718
 
1719
+ def _build_branch_confirm(state: WizardState) -> Prompt:
1720
+ decision = preview_worktree_decision(
1721
+ project_root=Path(state.project_root),
1722
+ project_id=state.project_id,
1723
+ task_group_segment=state.task_group,
1724
+ task_id_segment=state.task_id,
1725
+ # okstra-run 경로는 --work-category 를 넘기지 않으므로 provision 도 ""(→ "task-" prefix)로
1726
+ # 브랜치를 만든다. 이 단계에서 미리보기 브랜치명은 실제 생성 브랜치와 정확히 일치한다.
1727
+ work_category="",
1728
+ base_ref=state.base_ref,
1729
+ )
1730
+ key = {
1731
+ "new": "new", "reused": "reuse",
1732
+ "skipped-in-worktree": "in_worktree", "skipped-not-git": "not_git",
1733
+ }[decision.status]
1734
+ # Fetch raw step data first so we can compute the label before calling _p.
1735
+ raw = _load_wizard_root(state.workspace_root)["steps"]["branch_confirm"]
1736
+ label = raw["labels"][key].format(
1737
+ branch=decision.branch or "(none)",
1738
+ base_ref=decision.base_ref or "(HEAD)",
1739
+ path=decision.path,
1740
+ )
1741
+ # Pass the computed label as `summary` so _p's placeholder interpolation works.
1742
+ t = _p(state.workspace_root, "branch_confirm", summary=label)
1743
+ opts = t["options"]
1744
+ options = [_opt("proceed", opts["proceed"])]
1745
+ if decision.status == "new":
1746
+ options.append(_opt("edit", opts["edit"]))
1747
+ return Prompt(step=S_BRANCH_CONFIRM, kind="pick", label=label,
1748
+ options=options, echo_template=t["echo_template"])
1749
+
1750
+
1751
+ def _submit_branch_confirm(state: WizardState, value: str) -> Optional[str]:
1752
+ if value == "edit":
1753
+ _reset_from(state, S_BASE_REF_PICK)
1754
+ state.branch_confirmed = None
1755
+ return "branch-confirm: edit"
1756
+ if value != "proceed":
1757
+ raise WizardError(f"expected 'proceed' or 'edit', got: {value!r}")
1758
+ state.branch_confirmed = True
1759
+ return "branch-confirm: proceed"
1760
+
1761
+
1701
1762
  def _build_confirm(state: WizardState) -> Prompt:
1702
1763
  t = _p(state.workspace_root, "confirm")
1703
1764
  return Prompt(
@@ -1720,7 +1781,7 @@ def _build_edit_target(state: WizardState) -> Prompt:
1720
1781
  # offer every step that has been answered.
1721
1782
  options: list[Option] = []
1722
1783
  for sid in state.answered:
1723
- if sid in (S_CONFIRM, S_EDIT_TARGET):
1784
+ if sid in (S_BRANCH_CONFIRM, S_CONFIRM, S_EDIT_TARGET):
1724
1785
  continue
1725
1786
  options.append(_opt(sid, sid))
1726
1787
  return Prompt(
@@ -1976,8 +2037,12 @@ STEPS: list[Step] = [
1976
2037
  and S_PR_TEMPLATE_SCOPE not in s.answered),
1977
2038
  build=_build_pr_template_scope, submit=_submit_pr_template_scope,
1978
2039
  owns=("pr_template_scope",)),
2040
+ Step(S_BRANCH_CONFIRM,
2041
+ applies=lambda s: _ready_for_confirm(s) and s.branch_confirmed is None,
2042
+ build=_build_branch_confirm, submit=_submit_branch_confirm,
2043
+ owns=("branch_confirmed",)),
1979
2044
  Step(S_CONFIRM,
1980
- applies=lambda s: _ready_for_confirm(s) and s.confirmed is None,
2045
+ applies=lambda s: _ready_for_confirm(s) and s.branch_confirmed is True and s.confirmed is None,
1981
2046
  build=_build_confirm, submit=_submit_confirm,
1982
2047
  owns=("confirmed", "edit_target")),
1983
2048
  Step(S_EDIT_TARGET,
@@ -2062,7 +2127,7 @@ _FIELD_DEFAULTS: dict[str, Any] = {
2062
2127
  "clarification_response_path": "", "clarification_pending_text": False,
2063
2128
  "pr_template_path": "", "pr_template_pending_text": False,
2064
2129
  "pr_template_scope": "",
2065
- "confirmed": None, "edit_target": "",
2130
+ "branch_confirmed": None, "confirmed": None, "edit_target": "",
2066
2131
  }
2067
2132
 
2068
2133
 
@@ -0,0 +1,21 @@
1
+ """requirements-discovery work-category(domain) SSOT.
2
+
3
+ profile / validators / wizard 는 이 모듈에서 enum 을 import 해야 한다. 다른 곳에서
4
+ 재정의하면 single-reference-point 위반이며 tests/test_work_categories.py 가 거부한다.
5
+
6
+ 다섯째 값은 `ops` 가 canonical 이다(`--work-category` CLI help 및 workflow.PHASE_RULES
7
+ 와 일치). 구버전 비표준 철자는 폐기된다.
8
+ """
9
+ from __future__ import annotations
10
+
11
+ WORK_CATEGORIES: tuple[str, ...] = (
12
+ "bugfix",
13
+ "feature",
14
+ "refactor",
15
+ "ops",
16
+ "improvement",
17
+ )
18
+
19
+
20
+ def is_valid_category(value: str) -> bool:
21
+ return value in WORK_CATEGORIES
@@ -46,11 +46,7 @@ from okstra_project.dirs import project_json_path
46
46
  from .ids import _safe_fs_segment
47
47
  from . import worktree_registry
48
48
  from .seeding import (
49
- AgentsMdLinkError,
50
- ClaudeMdLinkError,
51
49
  SettingsLinkError,
52
- ensure_project_agents_md,
53
- ensure_project_claude_md,
54
50
  ensure_project_settings_symlink,
55
51
  )
56
52
 
@@ -145,6 +141,60 @@ class WorktreeProvision:
145
141
  note: str = "" # human-readable explanation, surfaced in team-state / manifests
146
142
 
147
143
 
144
+ @dataclass
145
+ class WorktreeDecision:
146
+ """Side-effect-free preview of what `provision_task_worktree` would do.
147
+
148
+ status:
149
+ - "new": no active registry entry; a fresh worktree would be created
150
+ - "reused": registry already has this task-key; existing path/branch returned
151
+ - "skipped-in-worktree": project_root is itself a non-main worktree
152
+ - "skipped-not-git": project_root has no .git
153
+ """
154
+ status: str
155
+ path: str # worktree path (new: prospective; reuse: existing; skip: project_root)
156
+ branch: str = "" # new: prospective branch; reused: existing branch
157
+ base_ref: str = "" # new: requested base_ref; reused: existing base
158
+
159
+
160
+ def preview_worktree_decision(
161
+ *,
162
+ project_root,
163
+ project_id: str,
164
+ task_group_segment: str,
165
+ task_id_segment: str,
166
+ work_category: str,
167
+ base_ref: str = "",
168
+ ) -> "WorktreeDecision":
169
+ """Side-effect-free: what provision_task_worktree WOULD do, without touching disk.
170
+
171
+ Mirrors provision's decision branches exactly; reuses the same read-only
172
+ helpers so preview never diverges from the actual provisioning result.
173
+ """
174
+ project_root = Path(project_root)
175
+ if not _is_git_repo(project_root):
176
+ return WorktreeDecision(status="skipped-not-git", path=str(project_root))
177
+ if _is_inside_non_main_worktree(project_root):
178
+ return WorktreeDecision(status="skipped-in-worktree", path=str(project_root))
179
+ safe_project = _safe_segment(project_id)
180
+ safe_group = _safe_segment(task_group_segment)
181
+ safe_task = _safe_segment(task_id_segment)
182
+ existing = worktree_registry.lookup(safe_project, safe_group, safe_task)
183
+ if existing is not None and existing.status == "active":
184
+ return WorktreeDecision(
185
+ status="reused", path=existing.worktree_path,
186
+ branch=existing.branch, base_ref=existing.base_ref,
187
+ )
188
+ return WorktreeDecision(
189
+ status="new",
190
+ path=str(compute_worktree_path(
191
+ project_id=safe_project, task_group_segment=safe_group,
192
+ task_id_segment=safe_task)),
193
+ branch=compute_branch_name(work_category=work_category, task_id_segment=safe_task),
194
+ base_ref=base_ref,
195
+ )
196
+
197
+
148
198
  def _safe_segment(value: str) -> str:
149
199
  """Sanitise a single path/branch segment.
150
200
 
@@ -256,11 +306,6 @@ def _read_project_json_field(project_root: Path, field: str) -> Optional[tuple[s
256
306
  return cleaned
257
307
 
258
308
 
259
- def _read_project_json_sync_dirs(project_root: Path) -> Optional[tuple[str, ...]]:
260
- """Back-compat shim — preserves the old name used by external callers."""
261
- return _read_project_json_field(project_root, "worktreeSyncDirs")
262
-
263
-
264
309
  def _resolve_entries(
265
310
  *,
266
311
  env_var: str,
@@ -399,48 +444,6 @@ def _seed_worktree_settings_symlink(worktree_path: Path) -> None:
399
444
  )
400
445
 
401
446
 
402
- def _seed_worktree_claude_md(worktree_path: Path) -> None:
403
- """Seed `.okstra/CLAUDE.md` symlink + `<worktree>/CLAUDE.md`
404
- import block in the worker worktree so dispatched Claude sessions auto-load
405
- okstra's runtime guidance. Mirrors `_seed_worktree_settings_symlink` —
406
- needed because the worktree's `CLAUDE.md` comes from the git checkout (no
407
- marker block unless committed) and `.project-docs/` is only dir-symlinked,
408
- not its contents.
409
- """
410
- try:
411
- link = ensure_project_claude_md(project_root=worktree_path)
412
- except ClaudeMdLinkError as exc:
413
- print(
414
- f"okstra-claude-md: failed to seed worker worktree CLAUDE.md at "
415
- f"{worktree_path} — dispatched Claude sessions will not auto-load "
416
- f"okstra guidance. ({exc})",
417
- file=__import__("sys").stderr,
418
- )
419
- return
420
- if link is None:
421
- print(
422
- "okstra-claude-md: ~/.okstra/templates/okstra.CLAUDE.md missing — "
423
- "re-run 'npx okstra@latest install' to provision the symlink target.",
424
- file=__import__("sys").stderr,
425
- )
426
-
427
-
428
- def _seed_worktree_agents_md(worktree_path: Path) -> None:
429
- """Seed `<worktree>/AGENTS.md` symlink so codex / aider sessions dispatched
430
- into this worktree auto-load okstra's guidance. Only acts when AGENTS.md
431
- is absent — never overwrites user content.
432
- """
433
- try:
434
- ensure_project_agents_md(project_root=worktree_path)
435
- except AgentsMdLinkError as exc:
436
- print(
437
- f"okstra-agents-md: failed to seed worker worktree AGENTS.md at "
438
- f"{worktree_path} — dispatched codex / aider sessions will not "
439
- f"auto-load okstra guidance. ({exc})",
440
- file=__import__("sys").stderr,
441
- )
442
-
443
-
444
447
  def _copy_snapshot_files(source_root: Path, worktree_path: Path) -> list[str]:
445
448
  """Copy fixture files from MAIN → task worktree as read-only snapshots
446
449
  (FU-V3).
@@ -537,20 +540,26 @@ def provision_task_worktree(
537
540
  (`run.py`) catches and re-raises as PrepareError to keep a
538
541
  single error surface.
539
542
  """
540
- if not _is_git_repo(project_root):
543
+ decision = preview_worktree_decision(
544
+ project_root=project_root, project_id=project_id,
545
+ task_group_segment=task_group_segment, task_id_segment=task_id_segment,
546
+ work_category=work_category, base_ref=base_ref,
547
+ )
548
+
549
+ if decision.status == "skipped-not-git":
541
550
  return WorktreeProvision(
542
551
  status="skipped-not-git",
543
- path=str(project_root),
552
+ path=decision.path,
544
553
  note=(
545
554
  "worktree provisioning skipped: project_root is not inside a git "
546
555
  "repository; task will operate directly on project_root"
547
556
  ),
548
557
  )
549
558
 
550
- if _is_inside_non_main_worktree(project_root):
559
+ if decision.status == "skipped-in-worktree":
551
560
  return WorktreeProvision(
552
561
  status="skipped-in-worktree",
553
- path=str(project_root),
562
+ path=decision.path,
554
563
  note=(
555
564
  "worktree provisioning skipped: project_root is already inside a "
556
565
  "non-main git worktree; task reuses the caller's worktree"
@@ -561,33 +570,23 @@ def provision_task_worktree(
561
570
  safe_group = _safe_segment(task_group_segment)
562
571
  safe_task = _safe_segment(task_id_segment)
563
572
 
564
- # Registry lookup first — same task-key across phases must reuse.
565
- existing = worktree_registry.lookup(safe_project, safe_group, safe_task)
566
- if existing is not None and existing.status == "active":
573
+ if decision.status == "reused":
567
574
  worktree_registry.touch_phase(safe_project, safe_group, safe_task, task_type)
568
- _seed_worktree_settings_symlink(Path(existing.worktree_path))
569
- _seed_worktree_claude_md(Path(existing.worktree_path))
570
- _seed_worktree_agents_md(Path(existing.worktree_path))
575
+ _seed_worktree_settings_symlink(Path(decision.path))
571
576
  return WorktreeProvision(
572
577
  status="reused",
573
- path=existing.worktree_path,
574
- branch=existing.branch,
575
- base_ref=existing.base_ref,
578
+ path=decision.path,
579
+ branch=decision.branch,
580
+ base_ref=decision.base_ref,
576
581
  note=(
577
- f"task worktree reused at {existing.worktree_path} on branch "
578
- f"{existing.branch} (base {existing.base_ref[:12]}); phase {task_type}"
582
+ f"task worktree reused at {decision.path} on branch "
583
+ f"{decision.branch} (base {decision.base_ref[:12]}); phase {task_type}"
579
584
  ),
580
585
  )
581
586
 
582
- worktree_path = compute_worktree_path(
583
- project_id=safe_project,
584
- task_group_segment=safe_group,
585
- task_id_segment=safe_task,
586
- )
587
- branch = compute_branch_name(
588
- work_category=work_category,
589
- task_id_segment=safe_task,
590
- )
587
+ # decision.status == "new" — proceed with creation
588
+ worktree_path = Path(decision.path)
589
+ branch = decision.branch
591
590
 
592
591
  if worktree_path.exists():
593
592
  raise RuntimeError(
@@ -670,8 +669,6 @@ def provision_task_worktree(
670
669
  raise
671
670
 
672
671
  _seed_worktree_settings_symlink(worktree_path)
673
- _seed_worktree_claude_md(worktree_path)
674
- _seed_worktree_agents_md(worktree_path)
675
672
 
676
673
  base_label = (
677
674
  f"{base_origin} @ {resolved_base_ref[:12]}"
@@ -9,8 +9,6 @@ PROJECT_ROOT 를 해석하고, 실행 시점에 upsert 한다. 과거 모델
9
9
  from __future__ import annotations
10
10
 
11
11
  from .dirs import (
12
- CLAUDE_MD_IMPORT_LINE,
13
- CLAUDE_MD_SYMLINK_RELATIVE,
14
12
  DISCOVERY_RELATIVE,
15
13
  LATEST_TASK_RELATIVE,
16
14
  OKSTRA_DIR_NAME,
@@ -18,7 +16,6 @@ from .dirs import (
18
16
  PROJECT_JSON_RELATIVE,
19
17
  TASK_CATALOG_RELATIVE,
20
18
  TASKS_RELATIVE,
21
- claude_md_symlink_path,
22
19
  discovery_dir,
23
20
  okstra_root,
24
21
  project_json_path,
@@ -42,8 +39,6 @@ from .state import (
42
39
  )
43
40
 
44
41
  __all__ = [
45
- "CLAUDE_MD_IMPORT_LINE",
46
- "CLAUDE_MD_SYMLINK_RELATIVE",
47
42
  "DISCOVERY_RELATIVE",
48
43
  "LATEST_TASK_RELATIVE",
49
44
  "OKSTRA_DIR_NAME",
@@ -53,7 +48,6 @@ __all__ = [
53
48
  "StateError",
54
49
  "TASKS_RELATIVE",
55
50
  "TASK_CATALOG_RELATIVE",
56
- "claude_md_symlink_path",
57
51
  "discovery_dir",
58
52
  "find_task_root",
59
53
  "list_project_tasks",
@@ -37,9 +37,6 @@ TASKS_RELATIVE = OKSTRA_RELATIVE / "tasks"
37
37
  DISCOVERY_RELATIVE = OKSTRA_RELATIVE / "discovery"
38
38
  TASK_CATALOG_RELATIVE = DISCOVERY_RELATIVE / "task-catalog.json"
39
39
  LATEST_TASK_RELATIVE = DISCOVERY_RELATIVE / "latest-task.json"
40
- CLAUDE_MD_SYMLINK_RELATIVE = OKSTRA_RELATIVE / "CLAUDE.md"
41
- CLAUDE_MD_IMPORT_LINE = f"@{OKSTRA_DIR_NAME}/CLAUDE.md"
42
- LEGACY_CLAUDE_MD_IMPORT_LINE = f"@{LEGACY_OKSTRA_DIR_NAME}/CLAUDE.md"
43
40
 
44
41
 
45
42
  def okstra_root(project_root: Path) -> Path:
@@ -60,8 +57,3 @@ def tasks_root(project_root: Path) -> Path:
60
57
  def discovery_dir(project_root: Path) -> Path:
61
58
  """`<project_root>/.okstra/discovery` 절대 path."""
62
59
  return Path(project_root) / DISCOVERY_RELATIVE
63
-
64
-
65
- def claude_md_symlink_path(project_root: Path) -> Path:
66
- """`<project_root>/.okstra/CLAUDE.md` 절대 path."""
67
- return Path(project_root) / CLAUDE_MD_SYMLINK_RELATIVE