okstra 0.64.1 → 0.65.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 (42) hide show
  1. package/bin/okstra +1 -0
  2. package/docs/kr/architecture.md +2 -0
  3. package/docs/kr/cli.md +11 -3
  4. package/docs/kr/performance-improvement-plan-v2.md +2 -1
  5. package/docs/project-structure-overview.md +1 -0
  6. package/docs/superpowers/plans/2026-06-10-p6-token-usage-incremental.md +1029 -0
  7. package/docs/superpowers/specs/2026-06-10-blocking-contract-posthoc-conformance-design.md +168 -0
  8. package/package.json +1 -1
  9. package/runtime/BUILD.json +2 -2
  10. package/runtime/agents/SKILL.md +3 -1
  11. package/runtime/agents/workers/claude-worker.md +1 -1
  12. package/runtime/agents/workers/codex-worker.md +1 -0
  13. package/runtime/agents/workers/gemini-worker.md +1 -0
  14. package/runtime/bin/lib/okstra/cli.sh +4 -0
  15. package/runtime/bin/lib/okstra/globals.sh +1 -0
  16. package/runtime/bin/lib/okstra/usage.sh +4 -1
  17. package/runtime/bin/okstra.sh +1 -0
  18. package/runtime/prompts/profiles/_implementation-executor.md +1 -0
  19. package/runtime/python/okstra_ctl/clarification_items.py +96 -37
  20. package/runtime/python/okstra_ctl/context_cost.py +86 -8
  21. package/runtime/python/okstra_ctl/locks.py +32 -0
  22. package/runtime/python/okstra_ctl/migrate.py +45 -6
  23. package/runtime/python/okstra_ctl/pr_template.py +2 -7
  24. package/runtime/python/okstra_ctl/run.py +58 -44
  25. package/runtime/python/okstra_ctl/run_context.py +3 -8
  26. package/runtime/python/okstra_ctl/seeding.py +25 -18
  27. package/runtime/python/okstra_ctl/wizard.py +8 -10
  28. package/runtime/python/okstra_ctl/worktree.py +13 -0
  29. package/runtime/python/okstra_project/dirs.py +10 -1
  30. package/runtime/python/okstra_token_usage/claude.py +226 -61
  31. package/runtime/python/okstra_token_usage/cli.py +10 -1
  32. package/runtime/python/okstra_token_usage/collect.py +34 -27
  33. package/runtime/python/okstra_token_usage/cursor.py +93 -0
  34. package/runtime/python/okstra_token_usage/paths.py +29 -2
  35. package/runtime/skills/okstra-coding-preflight/clean-code.md +15 -0
  36. package/runtime/skills/okstra-inspect/SKILL.md +16 -11
  37. package/runtime/skills/okstra-run/templates/pr-body.template.md +13 -16
  38. package/runtime/validators/lib/fixtures.sh +73 -10
  39. package/runtime/validators/lib/runners.sh +4 -0
  40. package/runtime/validators/validate-run.py +53 -0
  41. package/runtime/validators/validate_session_conformance.py +430 -0
  42. package/src/migrate.mjs +31 -0
@@ -23,6 +23,22 @@ INPUT_FILES = (
23
23
  "reference-expectations.md",
24
24
  "clarification-response.md",
25
25
  )
26
+ # Per-run hot-path instruction assets the lead/workers load OUTSIDE the task
27
+ # bundle: the lifecycle skills (Phase 1 / 2-5 / 5.5 / 6-7) plus the worker
28
+ # agent specs. These are the prompt-diet (perf plan v2 P2) targets; the
29
+ # bundle-local metrics below cannot see them, so they get their own metric.
30
+ HOT_PATH_SKILLS = (
31
+ "okstra-context-loader",
32
+ "okstra-team-contract",
33
+ "okstra-convergence",
34
+ "okstra-report-writer",
35
+ )
36
+ WORKER_AGENT_FILES = (
37
+ "claude-worker.md",
38
+ "codex-worker.md",
39
+ "gemini-worker.md",
40
+ "report-writer-worker.md",
41
+ )
26
42
  TIMESTAMPED_ARTIFACT_RE = re.compile(r"\d{4}-\d{2}-\d{2}[_T]\d{2}-\d{2}-\d{2}")
27
43
 
28
44
 
@@ -30,6 +46,21 @@ def _file_size(path: Path) -> int:
30
46
  return path.stat().st_size if path.is_file() else 0
31
47
 
32
48
 
49
+ def _estimate_tokens(paths: Iterable[Path]) -> int:
50
+ """Static token proxy: ~4 ASCII chars per token, non-ASCII (KR/CJK)
51
+ ~1 token per char. Heuristic — real billable cost is the token-usage
52
+ collector's job; this only ranks instruction surfaces against each other.
53
+ """
54
+ total = 0.0
55
+ for path in paths:
56
+ if not path.is_file():
57
+ continue
58
+ text = path.read_text(encoding="utf-8", errors="replace")
59
+ ascii_chars = sum(1 for ch in text if ord(ch) < 128)
60
+ total += ascii_chars / 4 + (len(text) - ascii_chars)
61
+ return round(total)
62
+
63
+
33
64
  def _count_files(paths: Iterable[Path]) -> tuple[int, int]:
34
65
  file_count = 0
35
66
  byte_count = 0
@@ -118,6 +149,14 @@ def _is_timestamped_legacy_artifact(path: Path) -> bool:
118
149
  return bool(TIMESTAMPED_ARTIFACT_RE.search(path.name))
119
150
 
120
151
 
152
+ def _installed_or_dev(installed: Path, dev_relative: str) -> Path:
153
+ """Prefer the user-machine install (what production runs read); fall back
154
+ to the repo dev tree when running from an uninstalled checkout."""
155
+ if installed.is_file():
156
+ return installed
157
+ return Path(__file__).resolve().parents[2] / dev_relative
158
+
159
+
121
160
  def _instruction_set_metric(task_root: Path, project_root: Path) -> dict:
122
161
  instruction_set = task_root / "instruction-set"
123
162
  files = _all_files(instruction_set)
@@ -128,11 +167,50 @@ def _instruction_set_metric(task_root: Path, project_root: Path) -> dict:
128
167
  "path": _project_rel(instruction_set, project_root),
129
168
  "fileCount": file_count,
130
169
  "bytes": byte_count,
170
+ "estimatedTokens": _estimate_tokens(files),
131
171
  "analysisPacketBytes": _file_size(packet),
132
172
  "legacyTaskPacketBytes": _file_size(legacy_packet),
133
173
  }
134
174
 
135
175
 
176
+ def _skill_assets_metric() -> dict:
177
+ """Per-run hot-path instruction assets outside the task bundle: lifecycle
178
+ skill bodies + worker agent specs. These dominate the fixed per-run
179
+ instruction footprint and are the prompt-diet ranking input."""
180
+ entries = []
181
+ claude_home = Path.home() / ".claude"
182
+ for name in HOT_PATH_SKILLS:
183
+ path = _installed_or_dev(
184
+ claude_home / "skills" / name / "SKILL.md",
185
+ f"skills/{name}/SKILL.md",
186
+ )
187
+ entries.append((f"skill:{name}", path))
188
+ for fname in WORKER_AGENT_FILES:
189
+ path = _installed_or_dev(
190
+ claude_home / "agents" / fname,
191
+ f"agents/workers/{fname}",
192
+ )
193
+ entries.append((f"agent:{fname}", path))
194
+
195
+ files = []
196
+ for label, path in entries:
197
+ if not path.is_file():
198
+ continue
199
+ files.append({
200
+ "name": label,
201
+ "path": str(path),
202
+ "bytes": _file_size(path),
203
+ "estimatedTokens": _estimate_tokens([path]),
204
+ })
205
+ files.sort(key=lambda row: row["bytes"], reverse=True)
206
+ return {
207
+ "fileCount": len(files),
208
+ "bytes": sum(row["bytes"] for row in files),
209
+ "estimatedTokens": sum(row["estimatedTokens"] for row in files),
210
+ "files": files,
211
+ }
212
+
213
+
136
214
  def _lead_phase1_metric(
137
215
  task_root: Path, run_dir: Path | None, manifest: dict, project_root: Path
138
216
  ) -> dict:
@@ -181,6 +259,7 @@ def _lead_phase1_metric(
181
259
  "mode": mode,
182
260
  "fileCount": file_count,
183
261
  "bytes": byte_count,
262
+ "estimatedTokens": _estimate_tokens(files),
184
263
  "files": [_project_rel(path, project_root) for path in files if path.is_file()],
185
264
  }
186
265
 
@@ -188,14 +267,10 @@ def _lead_phase1_metric(
188
267
  def _analysis_worker_metric(task_root: Path, project_root: Path) -> dict:
189
268
  instruction_set = task_root / "instruction-set"
190
269
  full_contract_files = [instruction_set / name for name in INPUT_FILES]
191
- preamble = Path.home() / ".okstra" / "templates" / "worker-prompt-preamble.md"
192
- if not preamble.is_file():
193
- repo_preamble = (
194
- Path(__file__).resolve().parents[2]
195
- / "templates"
196
- / "worker-prompt-preamble.md"
197
- )
198
- preamble = repo_preamble
270
+ preamble = _installed_or_dev(
271
+ Path.home() / ".okstra" / "templates" / "worker-prompt-preamble.md",
272
+ "templates/worker-prompt-preamble.md",
273
+ )
199
274
  full_contract_files.append(preamble)
200
275
  full_file_count, full_byte_count = _count_files(full_contract_files)
201
276
 
@@ -217,6 +292,7 @@ def _analysis_worker_metric(task_root: Path, project_root: Path) -> dict:
217
292
  "mode": mode,
218
293
  "fileCount": file_count,
219
294
  "bytesPerWorker": byte_count,
295
+ "estimatedTokensPerWorker": _estimate_tokens(current_files),
220
296
  "legacyFullContractBytesPerWorker": full_byte_count,
221
297
  "legacyFullContractFileCount": full_file_count,
222
298
  "estimatedPacketModeBytesPerWorker": packet_byte_count,
@@ -253,6 +329,7 @@ def _report_writer_metric(run_dir: Path | None, task_root: Path, project_root: P
253
329
  "mode": "raw-synthesis-inputs",
254
330
  "fileCount": file_count,
255
331
  "bytes": byte_count,
332
+ "estimatedTokens": _estimate_tokens(files),
256
333
  "files": [_project_rel(path, project_root) for path in files if path.is_file()],
257
334
  }
258
335
 
@@ -286,6 +363,7 @@ def analyze_task_bundle(task_root: Path, project_root: Path) -> dict:
286
363
  "leadPhase1": _lead_phase1_metric(task_root, run_dir, manifest, project_root),
287
364
  "analysisWorker": _analysis_worker_metric(task_root, project_root),
288
365
  "reportWriter": _report_writer_metric(run_dir, task_root, project_root),
366
+ "skillAssets": _skill_assets_metric(),
289
367
  }
290
368
 
291
369
 
@@ -27,6 +27,38 @@ def central_lock(home):
27
27
  f.close()
28
28
 
29
29
 
30
+ @contextlib.contextmanager
31
+ def worktree_provision_mutex(home, project_id: str, task_group: str,
32
+ task_id: str):
33
+ """task-key 단위 worktree 프로비저닝 임계구역
34
+ (`<home>/.locks/worktree-provision/<task-key>.lock` 위 fcntl LOCK_EX).
35
+
36
+ 사전검사(registry lookup / 경로·브랜치 존재)·stage 선택·`git worktree add`·
37
+ registry reserve 가 락 없이 인터리브되면: 동시 `--stage auto` run 둘이 같은
38
+ stage 를 선택해 후발이 "reused" 경로로 같은 worktree 에 들어가고, 같은
39
+ 경로·브랜치를 둔 git 경쟁이 비결정적으로 실패하며, 한쪽 rollback
40
+ (`git worktree remove --force`)이 다른 쪽 worktree 를 지울 수 있다.
41
+ stage worktree 도 같은 task-key 락을 공유한다 — 브랜치 네임스페이스와
42
+ common-anchor 계산이 task 단위로 묶여 있기 때문.
43
+
44
+ flock 은 재진입 불가: 이 락을 쥔 채 다시 acquire 하면 self-deadlock 이므로
45
+ provision 함수 내부가 아니라 호출자(run.py orchestration)가 1회 감싼다.
46
+ """
47
+ from pathlib import Path as _Path
48
+ locks_dir = _Path(home) / ".locks" / "worktree-provision"
49
+ locks_dir.mkdir(parents=True, exist_ok=True)
50
+ parts = [_escape_segment_for_join(_safe_fs_segment(s))
51
+ for s in (project_id, task_group, task_id)]
52
+ lockfile = locks_dir / ("-".join(parts) + ".lock")
53
+ lockfile.touch(exist_ok=True)
54
+ with lockfile.open("r+") as f:
55
+ fcntl.flock(f.fileno(), fcntl.LOCK_EX)
56
+ try:
57
+ yield
58
+ finally:
59
+ fcntl.flock(f.fileno(), fcntl.LOCK_UN)
60
+
61
+
30
62
  def task_lock_filename(project_id: str, task_group: str, task_id: str,
31
63
  task_type: str) -> str:
32
64
  """task-key + task-type 단위 mutex 파일명. ~/.okstra/.locks/ 아래에 위치한다.
@@ -24,6 +24,9 @@ from pathlib import Path
24
24
  from typing import Optional
25
25
 
26
26
  from okstra_ctl.worktree import is_git_work_tree
27
+ # 모듈 import 인 이유: 이 파일의 공개 함수들이 `okstra_home` 이라는 파라미터
28
+ # 이름을 쓰고 있어, 같은 이름의 함수를 직접 import 하면 가려진다.
29
+ from okstra_project import dirs as project_dirs
27
30
  from okstra_project.dirs import (
28
31
  LEGACY_OKSTRA_DIR_NAME,
29
32
  LEGACY_OKSTRA_RELATIVE,
@@ -187,12 +190,7 @@ def apply_migration_plan(
187
190
 
188
191
 
189
192
  def _resolve_okstra_home(override: Optional[Path]) -> Path:
190
- if override is not None:
191
- return Path(override)
192
- env = os.environ.get("OKSTRA_HOME", "").strip()
193
- if env:
194
- return Path(env)
195
- return Path.home() / ".okstra"
193
+ return Path(override) if override is not None else project_dirs.okstra_home()
196
194
 
197
195
 
198
196
  def _would_be_empty_after_remove(parent: Path, child: Path) -> bool:
@@ -375,3 +373,44 @@ def _replace_in_project_rows(text: str, project_str: str) -> str:
375
373
  if not changed:
376
374
  return text
377
375
  return json.dumps(data, indent=2, ensure_ascii=False) + "\n"
376
+
377
+
378
+ def main(argv: Optional[list[str]] = None) -> int:
379
+ """`python3 -m okstra_ctl.migrate [--apply] [--cwd <dir>] [--quiet]`.
380
+
381
+ 기본은 dry-run 미리보기(plan JSON 출력 후 종료). 거부 가드에 걸리면
382
+ stderr 메시지와 함께 exit 1 — `bin/okstra` 의 `migrate` 서브커맨드가
383
+ 그대로 forward 한다.
384
+ """
385
+ import argparse
386
+ import sys
387
+
388
+ p = argparse.ArgumentParser(prog="okstra migrate")
389
+ p.add_argument("--apply", action="store_true",
390
+ help="실제 실행 (기본: dry-run 미리보기)")
391
+ p.add_argument("--cwd", default=".",
392
+ help="프로젝트 루트 (기본: 현재 디렉토리)")
393
+ p.add_argument("--quiet", action="store_true", help="한 줄 JSON 만 출력")
394
+ args = p.parse_args(argv)
395
+
396
+ try:
397
+ plan = prepare_migration_plan(Path(args.cwd))
398
+ except MigrationRefused as exc:
399
+ print(f"migrate refused: {exc}", file=sys.stderr)
400
+ return 1
401
+ result = apply_migration_plan(plan, dry_run=not args.apply)
402
+ payload = {"plan": plan.to_dict(), "result": result.to_dict()}
403
+ if args.quiet:
404
+ print(json.dumps(payload, ensure_ascii=False))
405
+ else:
406
+ print(json.dumps(payload, indent=2, ensure_ascii=False))
407
+ if result.dry_run:
408
+ print("dry-run only — re-run with --apply to execute.",
409
+ file=sys.stderr)
410
+ return 0
411
+
412
+
413
+ if __name__ == "__main__":
414
+ import sys
415
+
416
+ sys.exit(main())
@@ -19,7 +19,7 @@ import os
19
19
  from dataclasses import dataclass
20
20
  from pathlib import Path
21
21
 
22
- from okstra_project.dirs import project_json_path
22
+ from okstra_project.dirs import okstra_home, project_json_path
23
23
 
24
24
  _DEFAULT_FILENAME = "pr-body.template.md"
25
25
 
@@ -34,11 +34,6 @@ class ResolvedPrTemplate:
34
34
  source: str # "override" | "project" | "global" | "default"
35
35
 
36
36
 
37
- def _okstra_home() -> Path:
38
- override = os.environ.get("OKSTRA_HOME", "").strip()
39
- return Path(override) if override else Path.home() / ".okstra"
40
-
41
-
42
37
  def _default_template_candidates() -> list[Path]:
43
38
  """디폴트 템플릿 후보 경로들 (우선순위 순)."""
44
39
  out: list[Path] = []
@@ -100,7 +95,7 @@ def resolve_pr_template_path(
100
95
  return ResolvedPrTemplate(path=p, source="project")
101
96
 
102
97
  # 3) global config
103
- gc_path = _okstra_home() / "config.json"
98
+ gc_path = okstra_home() / "config.json"
104
99
  gv = _read_json_field(gc_path, "prTemplatePath")
105
100
  if gv:
106
101
  p = Path(gv).expanduser()
@@ -29,10 +29,7 @@ from pathlib import Path
29
29
  from okstra_project import project_json_path, upsert_project_json
30
30
  from okstra_project.state import slugify
31
31
  from .analysis_packet import build_analysis_packet
32
- from .clarification_items import (
33
- section_1_present_but_unparsed,
34
- unresolved_approval_blockers,
35
- )
32
+ from .clarification_items import scan_approval_gate
36
33
  from .qa_commands import format_errors as _format_qa_errors, validate_qa_commands
37
34
  from .material import (
38
35
  build_analysis_material,
@@ -58,7 +55,12 @@ from .render import (
58
55
  render_template_with_ctx,
59
56
  render_timeline,
60
57
  )
61
- from .run_context import compute_and_write_run_context, write_run_inputs
58
+ from okstra_project.dirs import okstra_home
59
+
60
+ from .run_context import (
61
+ compute_and_write_run_context,
62
+ write_run_inputs,
63
+ )
62
64
  from .seeding import (
63
65
  SettingsLinkError,
64
66
  cleanup_obsolete_generated_docs,
@@ -78,6 +80,7 @@ from .workers import (
78
80
  validate_workers_against_profile,
79
81
  )
80
82
  from .workflow import compute_workflow_state
83
+ from .locks import worktree_provision_mutex
81
84
  from .worktree import provision_task_worktree
82
85
 
83
86
  # Frontmatter approval-flag matcher.
@@ -344,15 +347,15 @@ def _validate_approved_plan(path: str) -> None:
344
347
  _validate_data_json_approval_consistency(p, markdown_approved=True)
345
348
  # frontmatter approved == true 상태. §1 Clarification Items 의
346
349
  # Blocks=approval 행이 아직 open/answered 면 승인을 무효화한다.
347
- blockers = unresolved_approval_blockers(body)
348
- if blockers is None and section_1_present_but_unparsed(body):
350
+ scan = scan_approval_gate(body)
351
+ if scan.unreadable_reason:
349
352
  raise PrepareError(
350
- f"approved plan has a `## 1. Clarification Items` heading but its table "
351
- f"could not be parsed (heading/anchor/format drift): {path}\n"
352
- " the approval gate cannot confirm there are no unresolved "
353
- "`Blocks=approval` rows, so it refuses to soft-pass. Re-render the report "
354
- "with scripts/okstra-render-final-report.py so §1 matches the schema, then retry."
353
+ f"approved plan §1 approval gate could not be read: {path}\n"
354
+ f" {scan.unreadable_reason}.\n"
355
+ " the gate refuses to soft-pass re-render the report with "
356
+ "scripts/okstra-render-final-report.py so §1 matches the schema, then retry."
355
357
  )
358
+ blockers = scan.blockers
356
359
  if blockers:
357
360
  lines = [
358
361
  f"approved plan frontmatter has `approved: true` but §1 has {len(blockers)} "
@@ -843,9 +846,8 @@ def _record_start(
843
846
  import json as _json
844
847
  from datetime import datetime, timezone
845
848
  from okstra_ctl import record_start
846
- from okstra_ctl.run_context import _okstra_home # type: ignore
847
849
 
848
- home = _okstra_home()
850
+ home = okstra_home()
849
851
  home.mkdir(mode=0o700, parents=True, exist_ok=True)
850
852
  os.chmod(home, 0o700)
851
853
  # bootstrap (okstra_central_bootstrap 와 동등) — 디렉터리·jsonl 파일 보장.
@@ -1741,38 +1743,50 @@ def prepare_task_bundle(inp: PrepareInputs) -> PrepareOutputs:
1741
1743
  # ---- task worktree provisioning (every phase, every task-type) ----
1742
1744
  # One worktree per task-key: requirements-discovery, error-analysis,
1743
1745
  # implementation-planning and implementation phases of the same task
1744
- # all share this directory and branch. The global registry handles
1745
- # reservation across concurrent runs. Runs BEFORE run-path compute: its
1746
+ # all share this directory and branch. Runs BEFORE run-path compute: its
1746
1747
  # degrade status (skipped-*) feeds implementation stage selection, and
1747
1748
  # the resolved stage namespaces the run path.
1748
- try:
1749
- worktree = provision_task_worktree(
1750
- task_type=inp.task_type,
1751
- project_root=project_root,
1752
- project_id=inp.project_id,
1753
- task_group_segment=task_group_segment,
1754
- task_id_segment=task_id_segment,
1755
- work_category=inp.work_category,
1756
- base_ref=inp.base_ref,
1757
- require_base_ref=True,
1758
- )
1759
- except RuntimeError as exc:
1760
- raise PrepareError(f"task worktree provisioning failed: {exc}") from exc
1761
-
1762
- # ---- implementation stage selection (path-independent, reserves once) ----
1763
- # Resolve + provision the stage BEFORE run-path compute so RUN_DIR lands
1764
- # in runs/implementation/stage-<N>. The registry stage-key is reserved
1765
- # exactly once here (inside provision_stage_worktree). Non-implementation
1766
- # task-types skip this entirely → stage_arg stays None → identical paths.
1767
- if inp.task_type == "implementation":
1768
- impl_stage_selection = _select_and_provision_implementation_stage(
1769
- inp, ctx_stage_map, task_group_segment, task_id_segment,
1770
- task_key, worktree.status,
1771
- )
1772
- stage_arg = impl_stage_selection.stage
1773
- else:
1774
- impl_stage_selection = None
1775
- stage_arg = None
1749
+ #
1750
+ # The whole check→select→`git worktree add`→reserve sequence (task
1751
+ # worktree AND implementation stage) is one critical section per
1752
+ # task-key: the registry lock alone only covers the reserve row, so
1753
+ # without this mutex two concurrent runs can pick the same stage or
1754
+ # race the same path/branch at the git level (TOCTOU).
1755
+ with worktree_provision_mutex(
1756
+ okstra_home(), inp.project_id, task_group_segment, task_id_segment,
1757
+ ):
1758
+ try:
1759
+ worktree = provision_task_worktree(
1760
+ task_type=inp.task_type,
1761
+ project_root=project_root,
1762
+ project_id=inp.project_id,
1763
+ task_group_segment=task_group_segment,
1764
+ task_id_segment=task_id_segment,
1765
+ work_category=inp.work_category,
1766
+ base_ref=inp.base_ref,
1767
+ require_base_ref=True,
1768
+ )
1769
+ except RuntimeError as exc:
1770
+ raise PrepareError(
1771
+ f"task worktree provisioning failed: {exc}"
1772
+ ) from exc
1773
+
1774
+ # ---- implementation stage selection (path-independent) ----
1775
+ # Resolve + provision the stage BEFORE run-path compute so RUN_DIR
1776
+ # lands in runs/implementation/stage-<N>. The registry stage-key is
1777
+ # reserved exactly once here (inside provision_stage_worktree), and
1778
+ # the surrounding mutex makes the registry read in stage selection
1779
+ # and that reserve atomic. Non-implementation task-types skip this
1780
+ # entirely → stage_arg stays None → identical paths.
1781
+ if inp.task_type == "implementation":
1782
+ impl_stage_selection = _select_and_provision_implementation_stage(
1783
+ inp, ctx_stage_map, task_group_segment, task_id_segment,
1784
+ task_key, worktree.status,
1785
+ )
1786
+ stage_arg = impl_stage_selection.stage
1787
+ else:
1788
+ impl_stage_selection = None
1789
+ stage_arg = None
1776
1790
 
1777
1791
  ctx = compute_and_write_run_context(
1778
1792
  workspace_root=workspace_root, project_root=project_root,
@@ -23,6 +23,8 @@ from datetime import datetime, timezone
23
23
  from pathlib import Path
24
24
  from typing import Iterator, Optional
25
25
 
26
+ from okstra_project.dirs import okstra_home
27
+
26
28
  from .paths import compute_run_paths, task_runs_dir
27
29
 
28
30
 
@@ -78,16 +80,9 @@ def _now_task_date() -> str:
78
80
  return datetime.now(timezone.utc).strftime("%Y-%m-%d %H:%M:%S")
79
81
 
80
82
 
81
- def _okstra_home() -> Path:
82
- home = os.environ.get("OKSTRA_HOME")
83
- if home:
84
- return Path(home)
85
- return Path.home() / ".okstra"
86
-
87
-
88
83
  def _task_lock_path(task_key: str) -> Path:
89
84
  """task-key 별 mutex 파일. central index 와는 별개로 task 단위 직렬화."""
90
- home = _okstra_home()
85
+ home = okstra_home()
91
86
  locks = home / ".locks"
92
87
  locks.mkdir(parents=True, exist_ok=True)
93
88
  safe = task_key.replace("/", "_").replace(":", "_")
@@ -14,6 +14,8 @@ import time
14
14
  from pathlib import Path
15
15
  from typing import Optional
16
16
 
17
+ from okstra_project.dirs import okstra_home
18
+
17
19
 
18
20
  class InstallationError(Exception):
19
21
  """okstra 가 깔아둔 런타임 자산이 누락됨."""
@@ -32,10 +34,10 @@ def installed_version() -> str:
32
34
  so that report readers can distinguish behaviour drift across upgrades
33
35
  without having to dig through git history.
34
36
 
35
- The stamp lives at `_okstra_home() / "version"`. `OKSTRA_HOME` overrides
37
+ The stamp lives at `okstra_home() / "version"`. `OKSTRA_HOME` overrides
36
38
  the home directory for tests.
37
39
  """
38
- version_file = _okstra_home() / "version"
40
+ version_file = okstra_home() / "version"
39
41
  try:
40
42
  return version_file.read_text(encoding="utf-8").strip()
41
43
  except OSError:
@@ -43,13 +45,18 @@ def installed_version() -> str:
43
45
 
44
46
 
45
47
  def required_install_paths() -> list[Path]:
46
- """okstra install 이 채워야 하는 최소 자산 경로."""
47
- okstra_home = Path.home() / ".okstra"
48
+ """okstra install 이 채워야 하는 최소 자산 경로.
49
+
50
+ `installed_version()` 과 동일하게 `okstra_home()` 을 기준으로 한다 —
51
+ `OKSTRA_HOME` 으로 홈을 격리한 테스트는 conftest 가 같은 자산을 시드해
52
+ 설치 검사를 통과시킨다(개발 머신의 실제 `~/.okstra` 설치 여부와 무관).
53
+ """
54
+ home = okstra_home()
48
55
  return [
49
- okstra_home / "lib" / "python" / "okstra_project",
50
- okstra_home / "lib" / "python" / "okstra_ctl",
51
- okstra_home / "bin" / "okstra.sh",
52
- okstra_home / "version",
56
+ home / "lib" / "python" / "okstra_project",
57
+ home / "lib" / "python" / "okstra_ctl",
58
+ home / "bin" / "okstra.sh",
59
+ home / "version",
53
60
  ]
54
61
 
55
62
 
@@ -60,8 +67,16 @@ def verify_installation(workspace_root: Path) -> None:
60
67
  workspace_root 는 prompts/, templates/, validators/, agents/ 를 담은
61
68
  디렉터리(`<okstra-package>/runtime` 또는 dev-link 모드의 repo 루트)다.
62
69
  이 검사는 ~/.okstra 자산만을 본다; workspace_root 의 존재는 별도 검증.
70
+
71
+ `OKSTRA_SKIP_INSTALL_CHECK=1` 이면 설치 자산 검사를 건너뛴다 — 격리
72
+ OKSTRA_HOME 으로 subprocess 를 띄우는 테스트용 게이트(conftest 가 설정,
73
+ OKSTRA_CTL_SKIP_RECONCILE/BACKFILL 과 같은 패턴). workspace_root 검증은
74
+ 게이트와 무관하게 항상 수행한다.
63
75
  """
64
- missing = [p for p in required_install_paths() if not p.exists()]
76
+ if os.environ.get("OKSTRA_SKIP_INSTALL_CHECK", "").strip():
77
+ missing = []
78
+ else:
79
+ missing = [p for p in required_install_paths() if not p.exists()]
65
80
  if missing:
66
81
  msg_lines = [f"okstra runtime missing: {p}" for p in missing]
67
82
  msg_lines.append("")
@@ -108,17 +123,9 @@ def cleanup_obsolete_generated_docs(
108
123
  pass
109
124
 
110
125
 
111
- def _okstra_home() -> Path:
112
- """`~/.okstra` 의 절대경로. 테스트에서 `OKSTRA_HOME` 으로 override 가능."""
113
- override = os.environ.get("OKSTRA_HOME", "").strip()
114
- if override:
115
- return Path(override)
116
- return Path.home() / ".okstra"
117
-
118
-
119
126
  def installed_settings_template_path() -> Path:
120
127
  """okstra install 이 만들어 둔 settings.local.json template 의 절대경로."""
121
- return _okstra_home() / "templates" / "settings.local.json"
128
+ return okstra_home() / "templates" / "settings.local.json"
122
129
 
123
130
 
124
131
  def ensure_project_settings_symlink(*, project_root: Path) -> Optional[Path]:
@@ -31,10 +31,7 @@ from okstra_ctl.models import (
31
31
  UnknownModelError,
32
32
  resolve_model_metadata,
33
33
  )
34
- from okstra_ctl.clarification_items import (
35
- section_1_present_but_unparsed,
36
- unresolved_approval_blockers,
37
- )
34
+ from okstra_ctl.clarification_items import scan_approval_gate
38
35
  from okstra_ctl.pr_template import PrTemplateError, resolve_pr_template_path
39
36
  from okstra_ctl.run import (
40
37
  APPROVED_FRONTMATTER_PATTERN,
@@ -485,14 +482,15 @@ def _classify_approved_plan(path_str: str, project_root: Path) -> tuple[Path, bo
485
482
  # A blocking gate or an open Blocks=approval row makes the plan UN-approvable
486
483
  # — these raise regardless of the current flag value.
487
484
  _reject_blocking_plan_body_gate(p, body, action="approved plan validation")
488
- blockers = unresolved_approval_blockers(body)
489
- if blockers is None and section_1_present_but_unparsed(body):
485
+ scan = scan_approval_gate(body)
486
+ if scan.unreadable_reason:
490
487
  raise WizardError(
491
- f"approved plan has a `## 1. Clarification Items` heading but its table "
492
- f"could not be parsed (heading/anchor/format drift): {p}\n"
493
- " the approval gate cannot confirm there are no unresolved "
494
- "`Blocks=approval` rows — re-render the report so §1 matches the schema."
488
+ f"approved plan §1 approval gate could not be read: {p}\n"
489
+ f" {scan.unreadable_reason}.\n"
490
+ " the gate refuses to soft-pass re-render the report so §1 "
491
+ "matches the schema."
495
492
  )
493
+ blockers = scan.blockers
496
494
  if blockers:
497
495
  lines = [
498
496
  f"approved plan §1 has {len(blockers)} unresolved `Blocks=approval` "
@@ -548,6 +548,12 @@ def provision_task_worktree(
548
548
  AskUserQuestion menu in the okstra-run skill). Subsequent phases
549
549
  ignore ``base_ref`` — the registered entry's base is reused.
550
550
 
551
+ Concurrency: callers must hold ``locks.worktree_provision_mutex`` for
552
+ this task-key (run.py's prepare flow does). The exists/branch pre-checks
553
+ and ``git worktree add`` here are not internally locked — only the
554
+ registry reserve row is — so unlocked concurrent calls race (TOCTOU).
555
+ flock is non-reentrant, hence the lock lives at the caller.
556
+
551
557
  Raises:
552
558
  RuntimeError when worktree creation fails (path clash on disk
553
559
  that the registry does not know about, branch clash with a
@@ -721,6 +727,13 @@ def provision_stage_worktree(
721
727
  `worktree_registry`; re-entry of the same stage-key returns the
722
728
  existing entry. Branch / on-disk conflicts roll back the worktree
723
729
  before re-raising so a retry is not blocked.
730
+
731
+ Concurrency: callers must hold ``locks.worktree_provision_mutex`` for
732
+ the task-key, acquired BEFORE stage selection reads the registry
733
+ (run.py does) — otherwise two `--stage auto` runs can select the same
734
+ stage and the loser silently enters the winner's worktree via the
735
+ "reused" path. flock is non-reentrant, hence the lock lives at the
736
+ caller.
724
737
  """
725
738
  if not base_commit:
726
739
  raise RuntimeError("provision_stage_worktree requires a base_commit")
@@ -9,11 +9,12 @@ DRY 위반의 비용: 이전에는 동일 path 문자열이 50+ Python·Shell·m
9
9
  중복으로 박혀 있었고, 디렉토리 이름을 바꾸려면 60+ 파일을 동시에 수정해야 했다.
10
10
  이 모듈은 그 비용을 한 줄 수정으로 줄인다.
11
11
 
12
- 의존성 0 (Path only). `paths.py` 와 `state.py` 양쪽에서 import 되므로 순환 위험을
12
+ 의존성 0 (stdlib only). `paths.py` 와 `state.py` 양쪽에서 import 되므로 순환 위험을
13
13
  피하기 위해 다른 okstra 모듈을 import 하지 않는다.
14
14
  """
15
15
  from __future__ import annotations
16
16
 
17
+ import os
17
18
  from pathlib import Path
18
19
 
19
20
  OKSTRA_DIR_NAME = ".okstra"
@@ -39,6 +40,14 @@ TASK_CATALOG_RELATIVE = DISCOVERY_RELATIVE / "task-catalog.json"
39
40
  LATEST_TASK_RELATIVE = DISCOVERY_RELATIVE / "latest-task.json"
40
41
 
41
42
 
43
+ def okstra_home() -> Path:
44
+ """`~/.okstra` 절대 path. 테스트/설치 환경에서 `OKSTRA_HOME` env 로 override."""
45
+ override = os.environ.get("OKSTRA_HOME", "").strip()
46
+ if override:
47
+ return Path(override)
48
+ return Path.home() / ".okstra"
49
+
50
+
42
51
  def okstra_root(project_root: Path) -> Path:
43
52
  """`<project_root>/.okstra` 절대 path."""
44
53
  return Path(project_root) / OKSTRA_RELATIVE