okstra 0.36.2 → 0.37.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 (64) hide show
  1. package/README.kr.md +6 -6
  2. package/README.md +6 -6
  3. package/bin/okstra +4 -2
  4. package/docs/kr/architecture.md +29 -29
  5. package/docs/kr/cli.md +7 -6
  6. package/docs/pr-template-usage.md +2 -2
  7. package/docs/project-structure-overview.md +4 -4
  8. package/docs/superpowers/plans/2026-05-25-okstra-project-root-rename.md +159 -0
  9. package/docs/superpowers/plans/2026-05-26-wizard-3-option-picker.md +860 -0
  10. package/docs/task-process/common-flow.md +2 -2
  11. package/package.json +1 -1
  12. package/runtime/BUILD.json +2 -2
  13. package/runtime/agents/SKILL.md +2 -2
  14. package/runtime/agents/workers/claude-worker.md +1 -1
  15. package/runtime/prompts/profiles/_common-contract.md +6 -6
  16. package/runtime/prompts/profiles/_implementation-executor.md +2 -2
  17. package/runtime/prompts/profiles/_implementation-verifier.md +1 -1
  18. package/runtime/prompts/profiles/final-verification.md +1 -1
  19. package/runtime/prompts/profiles/implementation-planning.md +5 -5
  20. package/runtime/prompts/profiles/release-handoff.md +1 -1
  21. package/runtime/prompts/profiles/requirements-discovery.md +3 -3
  22. package/runtime/prompts/wizard/prompts.ko.json +80 -6
  23. package/runtime/python/lib/okstra/interactive.sh +2 -2
  24. package/runtime/python/lib/okstra/project-resolver.sh +1 -1
  25. package/runtime/python/lib/okstra/usage.sh +5 -5
  26. package/runtime/python/lib/okstra-ctl/cmd-rerun.sh +1 -1
  27. package/runtime/python/okstra_ctl/backfill.py +5 -3
  28. package/runtime/python/okstra_ctl/migrate.py +408 -0
  29. package/runtime/python/okstra_ctl/paths.py +12 -3
  30. package/runtime/python/okstra_ctl/pr_template.py +4 -2
  31. package/runtime/python/okstra_ctl/render.py +8 -6
  32. package/runtime/python/okstra_ctl/run.py +5 -5
  33. package/runtime/python/okstra_ctl/seeding.py +12 -6
  34. package/runtime/python/okstra_ctl/sequence.py +3 -1
  35. package/runtime/python/okstra_ctl/wizard.py +412 -77
  36. package/runtime/python/okstra_ctl/worktree.py +8 -6
  37. package/runtime/python/okstra_project/__init__.py +35 -5
  38. package/runtime/python/okstra_project/dirs.py +67 -0
  39. package/runtime/python/okstra_project/resolver.py +8 -8
  40. package/runtime/python/okstra_project/state.py +11 -9
  41. package/runtime/python/okstra_token_usage/collect.py +3 -1
  42. package/runtime/skills/okstra-brief/SKILL.md +30 -30
  43. package/runtime/skills/okstra-context-loader/SKILL.md +7 -7
  44. package/runtime/skills/okstra-inspect/SKILL.md +25 -25
  45. package/runtime/skills/okstra-run/templates/pr-body.template.md +1 -1
  46. package/runtime/skills/okstra-schedule/SKILL.md +7 -7
  47. package/runtime/skills/okstra-setup/SKILL.md +8 -8
  48. package/runtime/templates/okstra.CLAUDE.md +4 -4
  49. package/runtime/templates/reports/brief.template.md +5 -5
  50. package/runtime/templates/reports/task-brief.template.md +1 -1
  51. package/runtime/validators/lib/fixtures.sh +2 -2
  52. package/runtime/validators/lib/paths.sh +9 -3
  53. package/runtime/validators/validate-brief.py +2 -2
  54. package/runtime/validators/validate-brief.sh +1 -1
  55. package/runtime/validators/validate-run.py +3 -1
  56. package/runtime/validators/validate-workflow.sh +2 -2
  57. package/src/check-project.mjs +3 -3
  58. package/src/config.mjs +6 -5
  59. package/src/install.mjs +5 -5
  60. package/src/migrate.mjs +163 -0
  61. package/src/okstra-dirs.mjs +37 -0
  62. package/src/paths.mjs +17 -0
  63. package/src/setup.mjs +8 -4
  64. package/src/uninstall.mjs +3 -3
@@ -0,0 +1,408 @@
1
+ """`okstra migrate` 코어 — `<PROJECT_ROOT>/.project-docs/okstra/` 산출물을
2
+ `<PROJECT_ROOT>/.okstra/` 로 이전한다.
3
+
4
+ 설계 원칙
5
+ ---------
6
+ - `prepare_migration_plan(project_root)` 는 부수효과 없이 변경 항목만 수집.
7
+ - `apply_migration_plan(plan, *, dry_run=True)` 가 실제 실행. dry-run 이 default.
8
+ - 가드: `.okstra/` 이미 존재 / `.project-docs/okstra/` 없음 / git 미사용 fallback.
9
+ - 다음 다섯 가지 변경만 수행 (그 외 파일은 절대 건드리지 않음):
10
+ 1. `git mv .project-docs/okstra .okstra` (git 없으면 일반 `mv`).
11
+ 2. `.project-docs/` 가 비면 `rmdir .project-docs`.
12
+ 3. `<PROJECT_ROOT>/CLAUDE.md` 의 `@.project-docs/okstra/CLAUDE.md` import
13
+ 라인을 `@.okstra/CLAUDE.md` 로 교체.
14
+ 4. `.gitignore` 의 `.project-docs/okstra/` 항목을 `.okstra/` 로 교체.
15
+ 5. `~/.okstra/{recent,active}.jsonl` 와 `~/.okstra/worktrees/registry.json`
16
+ 의 해당 프로젝트 path 항목 갱신 (project_root 가 정확히 일치하는 row 만).
17
+ """
18
+ from __future__ import annotations
19
+
20
+ import json
21
+ import os
22
+ import shutil
23
+ import subprocess
24
+ from dataclasses import dataclass, field
25
+ from pathlib import Path
26
+ from typing import Optional
27
+
28
+ from okstra_project.dirs import (
29
+ CLAUDE_MD_IMPORT_LINE,
30
+ LEGACY_CLAUDE_MD_IMPORT_LINE,
31
+ LEGACY_OKSTRA_DIR_NAME,
32
+ LEGACY_OKSTRA_RELATIVE,
33
+ OKSTRA_DIR_NAME,
34
+ OKSTRA_RELATIVE,
35
+ )
36
+
37
+
38
+ class MigrationRefused(RuntimeError):
39
+ """Raised when the plan cannot be prepared safely (pre-condition fail)."""
40
+
41
+
42
+ @dataclass
43
+ class MigrationPlan:
44
+ """All work the migrator would perform, as a single inspectable record.
45
+
46
+ Render to JSON via `to_dict()` for dry-run output.
47
+ """
48
+
49
+ project_root: Path
50
+ source_dir: Path # <project>/.project-docs/okstra
51
+ target_dir: Path # <project>/.okstra
52
+ use_git: bool
53
+ remove_empty_parent: bool # True if .project-docs would be empty after move
54
+ claude_md_path: Optional[Path] # <project>/CLAUDE.md if import-line update needed
55
+ gitignore_path: Optional[Path] # <project>/.gitignore if entry update needed
56
+ registry_updates: list[dict] = field(default_factory=list)
57
+ # Each registry entry: {"file": "<abs path>", "row_count": N, "scope": "..."}
58
+
59
+ def to_dict(self) -> dict:
60
+ return {
61
+ "projectRoot": str(self.project_root),
62
+ "sourceDir": str(self.source_dir),
63
+ "targetDir": str(self.target_dir),
64
+ "useGit": self.use_git,
65
+ "removeEmptyParent": self.remove_empty_parent,
66
+ "claudeMdPath": str(self.claude_md_path) if self.claude_md_path else None,
67
+ "gitignorePath": str(self.gitignore_path) if self.gitignore_path else None,
68
+ "registryUpdates": self.registry_updates,
69
+ }
70
+
71
+
72
+ @dataclass
73
+ class MigrationResult:
74
+ """What the apply step actually did. `dry_run=True` returns an unchanged copy
75
+ of the plan with all `applied_*` flags set to False.
76
+ """
77
+
78
+ dry_run: bool
79
+ moved: bool
80
+ parent_removed: bool
81
+ claude_md_updated: bool
82
+ gitignore_updated: bool
83
+ registry_rows_updated: int
84
+
85
+ def to_dict(self) -> dict:
86
+ return {
87
+ "dryRun": self.dry_run,
88
+ "moved": self.moved,
89
+ "parentRemoved": self.parent_removed,
90
+ "claudeMdUpdated": self.claude_md_updated,
91
+ "gitignoreUpdated": self.gitignore_updated,
92
+ "registryRowsUpdated": self.registry_rows_updated,
93
+ }
94
+
95
+
96
+ def prepare_migration_plan(
97
+ project_root: Path,
98
+ *,
99
+ okstra_home: Optional[Path] = None,
100
+ ) -> MigrationPlan:
101
+ """Inspect `project_root` and report what the migration would do.
102
+
103
+ Raises `MigrationRefused` when the migration cannot proceed safely:
104
+ - `.okstra/` already exists (someone migrated, or a collision).
105
+ - `.project-docs/okstra/` is missing (nothing to migrate).
106
+ """
107
+ project_root = Path(project_root).resolve()
108
+ source = project_root / LEGACY_OKSTRA_RELATIVE
109
+ target = project_root / OKSTRA_RELATIVE
110
+
111
+ if not source.is_dir():
112
+ raise MigrationRefused(
113
+ f"no legacy {LEGACY_OKSTRA_DIR_NAME}/ under {project_root}. "
114
+ f"Either this project was never set up with okstra, or it has "
115
+ f"already been migrated. ({target}/ {'exists' if target.exists() else 'does not exist'}.)"
116
+ )
117
+ if target.exists():
118
+ raise MigrationRefused(
119
+ f"refusing to migrate: {target} already exists. "
120
+ f"Remove it manually if you intend to overwrite, then re-run."
121
+ )
122
+
123
+ use_git = _is_git_worktree(project_root)
124
+ parent = source.parent # <project>/.project-docs
125
+ remove_empty_parent = _would_be_empty_after_remove(parent, source)
126
+
127
+ claude_md = project_root / "CLAUDE.md"
128
+ claude_md_path: Optional[Path] = None
129
+ if claude_md.is_file():
130
+ try:
131
+ text = claude_md.read_text(encoding="utf-8")
132
+ except OSError:
133
+ text = ""
134
+ if LEGACY_CLAUDE_MD_IMPORT_LINE in text:
135
+ claude_md_path = claude_md
136
+
137
+ gitignore = project_root / ".gitignore"
138
+ gitignore_path: Optional[Path] = None
139
+ if gitignore.is_file():
140
+ try:
141
+ text = gitignore.read_text(encoding="utf-8")
142
+ except OSError:
143
+ text = ""
144
+ if LEGACY_OKSTRA_DIR_NAME in text or f"{LEGACY_OKSTRA_DIR_NAME}/" in text:
145
+ gitignore_path = gitignore
146
+
147
+ registry_updates = _scan_registries(
148
+ project_root, okstra_home=_resolve_okstra_home(okstra_home)
149
+ )
150
+
151
+ return MigrationPlan(
152
+ project_root=project_root,
153
+ source_dir=source,
154
+ target_dir=target,
155
+ use_git=use_git,
156
+ remove_empty_parent=remove_empty_parent,
157
+ claude_md_path=claude_md_path,
158
+ gitignore_path=gitignore_path,
159
+ registry_updates=registry_updates,
160
+ )
161
+
162
+
163
+ def apply_migration_plan(
164
+ plan: MigrationPlan,
165
+ *,
166
+ dry_run: bool = True,
167
+ okstra_home: Optional[Path] = None,
168
+ ) -> MigrationResult:
169
+ """Execute the migration. When `dry_run=True` (default), returns the
170
+ no-op result so callers can preview before opting in.
171
+
172
+ Failure during any step raises and leaves the project in whatever state
173
+ that step had reached. No automatic rollback — git mv is the only step
174
+ that touches the working tree, and a manual `git mv` reverse run is
175
+ trivial.
176
+ """
177
+ if dry_run:
178
+ return MigrationResult(
179
+ dry_run=True,
180
+ moved=False,
181
+ parent_removed=False,
182
+ claude_md_updated=False,
183
+ gitignore_updated=False,
184
+ registry_rows_updated=0,
185
+ )
186
+
187
+ _do_move(plan)
188
+ parent_removed = _maybe_remove_empty_parent(plan)
189
+ claude_md_updated = _update_claude_md(plan)
190
+ gitignore_updated = _update_gitignore(plan)
191
+ registry_rows_updated = _apply_registry_updates(
192
+ plan, okstra_home=_resolve_okstra_home(okstra_home)
193
+ )
194
+
195
+ return MigrationResult(
196
+ dry_run=False,
197
+ moved=True,
198
+ parent_removed=parent_removed,
199
+ claude_md_updated=claude_md_updated,
200
+ gitignore_updated=gitignore_updated,
201
+ registry_rows_updated=registry_rows_updated,
202
+ )
203
+
204
+
205
+ # --------------------------------------------------------------------------- #
206
+ # helpers
207
+ # --------------------------------------------------------------------------- #
208
+
209
+
210
+ def _resolve_okstra_home(override: Optional[Path]) -> Path:
211
+ if override is not None:
212
+ return Path(override)
213
+ env = os.environ.get("OKSTRA_HOME", "").strip()
214
+ if env:
215
+ return Path(env)
216
+ return Path.home() / ".okstra"
217
+
218
+
219
+ def _is_git_worktree(project_root: Path) -> bool:
220
+ try:
221
+ rc = subprocess.run(
222
+ ["git", "rev-parse", "--show-toplevel"],
223
+ cwd=str(project_root), capture_output=True, text=True, check=False,
224
+ )
225
+ except (OSError, FileNotFoundError):
226
+ return False
227
+ return rc.returncode == 0 and bool(rc.stdout.strip())
228
+
229
+
230
+ def _would_be_empty_after_remove(parent: Path, child: Path) -> bool:
231
+ """Return True if `parent` would be empty after `child` is moved out."""
232
+ if not parent.is_dir():
233
+ return False
234
+ try:
235
+ entries = list(parent.iterdir())
236
+ except OSError:
237
+ return False
238
+ return all(p == child for p in entries)
239
+
240
+
241
+ def _do_move(plan: MigrationPlan) -> None:
242
+ """`git mv` when possible, else regular `os.rename`."""
243
+ src = plan.source_dir
244
+ dst = plan.target_dir
245
+ if plan.use_git:
246
+ rc = subprocess.run(
247
+ ["git", "mv", str(src.relative_to(plan.project_root)), str(dst.relative_to(plan.project_root))],
248
+ cwd=str(plan.project_root), capture_output=True, text=True, check=False,
249
+ )
250
+ if rc.returncode != 0:
251
+ # Surface the actual git error so the user can decide. We do not
252
+ # fall back to plain mv when git mv fails — the user explicitly
253
+ # ran inside a git worktree and should see why the move was
254
+ # rejected (untracked files, conflicting moves, etc.).
255
+ raise MigrationRefused(
256
+ f"git mv failed: {rc.stderr.strip() or rc.stdout.strip()}"
257
+ )
258
+ return
259
+ shutil.move(str(src), str(dst))
260
+
261
+
262
+ def _maybe_remove_empty_parent(plan: MigrationPlan) -> bool:
263
+ if not plan.remove_empty_parent:
264
+ return False
265
+ parent = plan.source_dir.parent
266
+ try:
267
+ if parent.is_dir() and not any(parent.iterdir()):
268
+ parent.rmdir()
269
+ return True
270
+ except OSError:
271
+ return False
272
+ return False
273
+
274
+
275
+ def _update_claude_md(plan: MigrationPlan) -> bool:
276
+ if plan.claude_md_path is None:
277
+ return False
278
+ text = plan.claude_md_path.read_text(encoding="utf-8")
279
+ new_text = text.replace(LEGACY_CLAUDE_MD_IMPORT_LINE, CLAUDE_MD_IMPORT_LINE)
280
+ if new_text == text:
281
+ return False
282
+ plan.claude_md_path.write_text(new_text, encoding="utf-8")
283
+ return True
284
+
285
+
286
+ def _update_gitignore(plan: MigrationPlan) -> bool:
287
+ if plan.gitignore_path is None:
288
+ return False
289
+ text = plan.gitignore_path.read_text(encoding="utf-8")
290
+ # Match both forms: with and without trailing slash. Use word-boundary-ish
291
+ # replacement by anchoring on the legacy prefix; downstream comment lines
292
+ # that mention the old path in narrative are left alone because they
293
+ # never start at line-start with the bare path.
294
+ new_lines = []
295
+ changed = False
296
+ for line in text.splitlines(keepends=True):
297
+ stripped = line.strip()
298
+ if stripped == LEGACY_OKSTRA_DIR_NAME or stripped == f"{LEGACY_OKSTRA_DIR_NAME}/":
299
+ indent = line[: len(line) - len(line.lstrip())]
300
+ new_lines.append(f"{indent}{OKSTRA_DIR_NAME}/\n")
301
+ changed = True
302
+ else:
303
+ new_lines.append(line)
304
+ if not changed:
305
+ return False
306
+ plan.gitignore_path.write_text("".join(new_lines), encoding="utf-8")
307
+ return True
308
+
309
+
310
+ def _scan_registries(project_root: Path, *, okstra_home: Path) -> list[dict]:
311
+ """Find okstra-home registry files that reference this project's old path."""
312
+ project_str = str(project_root)
313
+ legacy_marker = str(project_root / LEGACY_OKSTRA_RELATIVE)
314
+ updates: list[dict] = []
315
+ for name in ("recent.jsonl", "active.jsonl"):
316
+ path = okstra_home / name
317
+ if not path.is_file():
318
+ continue
319
+ try:
320
+ lines = path.read_text(encoding="utf-8").splitlines()
321
+ except OSError:
322
+ continue
323
+ count = sum(
324
+ 1 for ln in lines if legacy_marker in ln or project_str in ln and ".project-docs/okstra" in ln
325
+ )
326
+ if count > 0:
327
+ updates.append({"file": str(path), "rowCount": count, "scope": name})
328
+ registry = okstra_home / "worktrees" / "registry.json"
329
+ if registry.is_file():
330
+ try:
331
+ text = registry.read_text(encoding="utf-8")
332
+ except OSError:
333
+ text = ""
334
+ if ".project-docs/okstra" in text and project_str in text:
335
+ updates.append({"file": str(registry), "rowCount": text.count(".project-docs/okstra"), "scope": "worktrees-registry"})
336
+ return updates
337
+
338
+
339
+ def _apply_registry_updates(plan: MigrationPlan, *, okstra_home: Path) -> int:
340
+ """Rewrite registry files in place. Replaces `.project-docs/okstra` with
341
+ `.okstra` only on lines that also mention this project's root, so we
342
+ never touch other projects' rows.
343
+ """
344
+ project_str = str(plan.project_root)
345
+ total = 0
346
+ for name in ("recent.jsonl", "active.jsonl"):
347
+ path = okstra_home / name
348
+ if not path.is_file():
349
+ continue
350
+ try:
351
+ text = path.read_text(encoding="utf-8")
352
+ except OSError:
353
+ continue
354
+ new_lines: list[str] = []
355
+ changed = 0
356
+ for line in text.splitlines(keepends=True):
357
+ if project_str in line and LEGACY_OKSTRA_DIR_NAME in line:
358
+ new_lines.append(line.replace(LEGACY_OKSTRA_DIR_NAME, OKSTRA_DIR_NAME))
359
+ changed += 1
360
+ else:
361
+ new_lines.append(line)
362
+ if changed:
363
+ path.write_text("".join(new_lines), encoding="utf-8")
364
+ total += changed
365
+ registry = okstra_home / "worktrees" / "registry.json"
366
+ if registry.is_file():
367
+ try:
368
+ text = registry.read_text(encoding="utf-8")
369
+ except OSError:
370
+ text = ""
371
+ # Only mutate when this project's root appears anywhere in the file —
372
+ # otherwise leave it alone (covers the case where registry.json
373
+ # contains an unrelated `.project-docs/okstra` literal in someone
374
+ # else's row).
375
+ if project_str in text and LEGACY_OKSTRA_DIR_NAME in text:
376
+ new_text = _replace_in_project_rows(text, project_str)
377
+ if new_text != text:
378
+ registry.write_text(new_text, encoding="utf-8")
379
+ total += text.count(LEGACY_OKSTRA_DIR_NAME) - new_text.count(
380
+ LEGACY_OKSTRA_DIR_NAME
381
+ )
382
+ return total
383
+
384
+
385
+ def _replace_in_project_rows(text: str, project_str: str) -> str:
386
+ """For registry.json: replace `.project-docs/okstra` with `.okstra` only
387
+ inside rows that mention `project_str`. registry.json is structured JSON
388
+ (top-level object with task-key → row), so we operate at the JSON level
389
+ to avoid clobbering unrelated rows.
390
+ """
391
+ try:
392
+ data = json.loads(text)
393
+ except json.JSONDecodeError:
394
+ return text
395
+ if not isinstance(data, dict):
396
+ return text
397
+ changed = False
398
+ for key, row in data.items():
399
+ row_text = json.dumps(row)
400
+ if project_str not in row_text or LEGACY_OKSTRA_DIR_NAME not in row_text:
401
+ continue
402
+ new_row_text = row_text.replace(LEGACY_OKSTRA_DIR_NAME, OKSTRA_DIR_NAME)
403
+ if new_row_text != row_text:
404
+ data[key] = json.loads(new_row_text)
405
+ changed = True
406
+ if not changed:
407
+ return text
408
+ return json.dumps(data, indent=2, ensure_ascii=False) + "\n"
@@ -18,11 +18,20 @@ import re
18
18
  from pathlib import Path
19
19
  from typing import Optional
20
20
 
21
+ from okstra_project.dirs import (
22
+ DISCOVERY_RELATIVE,
23
+ OKSTRA_RELATIVE,
24
+ TASKS_RELATIVE,
25
+ )
21
26
  from okstra_project.state import slugify
22
27
 
23
- OKSTRA_RELATIVE = Path(".project-docs/okstra")
24
- TASKS_RELATIVE = OKSTRA_RELATIVE / "tasks"
25
- DISCOVERY_RELATIVE = OKSTRA_RELATIVE / "discovery"
28
+ __all__ = [
29
+ "OKSTRA_RELATIVE",
30
+ "TASKS_RELATIVE",
31
+ "DISCOVERY_RELATIVE",
32
+ "compute_run_paths",
33
+ "next_run_seq",
34
+ ]
26
35
 
27
36
 
28
37
  def next_run_seq(run_seq_dir: Path, task_type_segment: str) -> int:
@@ -4,7 +4,7 @@ release-handoff 단계에서 lead 가 PR 본문을 작성할 때 사용하는
4
4
  템플릿의 경로를 결정한다. 우선순위:
5
5
 
6
6
  1. per-run override (okstra-run Step 6 에서 입력)
7
- 2. project: <project_root>/.project-docs/okstra/project.json 의 ``prTemplatePath``
7
+ 2. project: <project_root>/.okstra/project.json 의 ``prTemplatePath``
8
8
  3. global: ~/.okstra/config.json 의 ``prTemplatePath``
9
9
  4. default: 스킬 설치 디렉터리의 ``okstra-run/templates/pr-body.template.md``
10
10
 
@@ -19,6 +19,8 @@ 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
23
+
22
24
  _DEFAULT_FILENAME = "pr-body.template.md"
23
25
 
24
26
 
@@ -86,7 +88,7 @@ def resolve_pr_template_path(
86
88
  return ResolvedPrTemplate(path=p, source="override")
87
89
 
88
90
  # 2) project.json
89
- pj_path = Path(project_root) / ".project-docs" / "okstra" / "project.json"
91
+ pj_path = project_json_path(project_root)
90
92
  pj_val = _read_json_field(pj_path, "prTemplatePath")
91
93
  if pj_val:
92
94
  p = _resolve_project_relative(pj_val, project_root)
@@ -22,6 +22,8 @@ import re
22
22
  import sys
23
23
  from pathlib import Path
24
24
 
25
+ from okstra_project.dirs import OKSTRA_DIR_NAME, project_json_path
26
+
25
27
 
26
28
  class TokenRenderError(Exception):
27
29
  """Raised when a template references a `{{TOKEN}}` not present in ctx.
@@ -1319,7 +1321,7 @@ def render_task_index(template_path: str, output_path: str, ctx: dict) -> None:
1319
1321
 
1320
1322
 
1321
1323
  _NO_MCP_SERVERS_LINE = (
1322
- "- No MCP servers are declared in `.project-docs/okstra/project.json`'s "
1324
+ f"- No MCP servers are declared in `{OKSTRA_DIR_NAME}/project.json`'s "
1323
1325
  "`mcpServers` array. Treat MCP tools as unavailable for this run. To enable "
1324
1326
  "them, add entries shaped `{name, description, tools, notes?}` to that array "
1325
1327
  "and re-render the bundle."
@@ -1330,12 +1332,12 @@ def build_available_mcp_servers_block(project_root: Path) -> str:
1330
1332
  """Render the `## Available MCP Servers` first bullet from project.json.
1331
1333
 
1332
1334
  The MCP server list used to be hardcoded for one specific environment.
1333
- It now comes from the project's `.project-docs/okstra/project.json`
1334
- (`mcpServers` array), so each user/project declares the MCP surface
1335
- available to their lead+workers. Missing file or empty array yields a
1336
- generic "none declared" fallback.
1335
+ It now comes from the project's okstra project.json (`mcpServers` array),
1336
+ so each user/project declares the MCP surface available to their
1337
+ lead+workers. Missing file or empty array yields a generic "none declared"
1338
+ fallback.
1337
1339
  """
1338
- config_path = project_root / ".project-docs" / "okstra" / "project.json"
1340
+ config_path = project_json_path(project_root)
1339
1341
  try:
1340
1342
  raw = json.loads(config_path.read_text(encoding="utf-8"))
1341
1343
  except (FileNotFoundError, json.JSONDecodeError):
@@ -26,7 +26,7 @@ from dataclasses import dataclass, field
26
26
  from datetime import datetime, timezone
27
27
  from pathlib import Path
28
28
 
29
- from okstra_project import upsert_project_json
29
+ from okstra_project import project_json_path, upsert_project_json
30
30
  from .clarification_items import unresolved_approval_blockers
31
31
  from .qa_commands import format_errors as _format_qa_errors, validate_qa_commands
32
32
  from .material import (
@@ -624,13 +624,13 @@ def prepare_task_bundle(inp: PrepareInputs) -> PrepareOutputs:
624
624
  # 쓰이므로 검증도 implementation 진입 시에만 수행한다. 다른 task-type 에서는
625
625
  # 잘못된 선언이 있어도 동작에 영향이 없어 fail-fast 할 이유가 없다.
626
626
  if inp.task_type == "implementation":
627
- project_json_path = Path(project_root) / ".project-docs" / "okstra" / "project.json"
628
- if project_json_path.is_file():
627
+ project_json = project_json_path(project_root)
628
+ if project_json.is_file():
629
629
  try:
630
- project_meta = json.loads(project_json_path.read_text())
630
+ project_meta = json.loads(project_json.read_text())
631
631
  except (OSError, json.JSONDecodeError) as exc:
632
632
  raise PrepareError(
633
- f"project.json read failed at {project_json_path}: {exc}"
633
+ f"project.json read failed at {project_json}: {exc}"
634
634
  ) from exc
635
635
  qa_errors = validate_qa_commands(project_meta.get("qaCommands"))
636
636
  if qa_errors:
@@ -14,6 +14,12 @@ 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
+
17
23
 
18
24
  class InstallationError(Exception):
19
25
  """okstra 가 깔아둔 런타임 자산이 누락됨."""
@@ -24,7 +30,7 @@ class SettingsLinkError(Exception):
24
30
 
25
31
 
26
32
  class ClaudeMdLinkError(Exception):
27
- """`<project>/.project-docs/okstra/CLAUDE.md` symlink or import-block provisioning 실패."""
33
+ """okstra-relative `CLAUDE.md` symlink or import-block provisioning 실패."""
28
34
 
29
35
 
30
36
  class AgentsMdLinkError(Exception):
@@ -210,8 +216,8 @@ def installed_claude_md_template_path() -> Path:
210
216
  return _okstra_home() / "templates" / "okstra.CLAUDE.md"
211
217
 
212
218
 
213
- _CLAUDE_MD_SYMLINK_REL = Path(".project-docs") / "okstra" / "CLAUDE.md"
214
- _CLAUDE_MD_IMPORT_LINE = "@.project-docs/okstra/CLAUDE.md"
219
+ _CLAUDE_MD_SYMLINK_REL = CLAUDE_MD_SYMLINK_RELATIVE
220
+ _CLAUDE_MD_IMPORT_LINE = CLAUDE_MD_IMPORT_LINE
215
221
  _CLAUDE_MD_MARKER_BEGIN = (
216
222
  "<!-- okstra:claude-md:begin (managed by okstra setup — do not edit) -->"
217
223
  )
@@ -219,9 +225,9 @@ _CLAUDE_MD_MARKER_END = "<!-- okstra:claude-md:end -->"
219
225
 
220
226
 
221
227
  def ensure_project_claude_md(*, project_root: Path) -> Optional[Path]:
222
- """`<project_root>/.project-docs/okstra/CLAUDE.md` 를 `~/.okstra/templates/okstra.CLAUDE.md`
228
+ """okstra-relative `CLAUDE.md` 를 `~/.okstra/templates/okstra.CLAUDE.md`
223
229
  로 가리키는 symlink 로 provisioning 하고, `<project_root>/CLAUDE.md` 에
224
- `@.project-docs/okstra/CLAUDE.md` import block 을 멱등하게 주입한다.
230
+ import block 을 멱등하게 주입한다.
225
231
 
226
232
  Claude Code 가 해당 프로젝트에서 host 세션으로 실행될 때
227
233
  `<project_root>/CLAUDE.md` 가 자동 로드되므로, okstra 가 관리하는 본문
@@ -339,7 +345,7 @@ def ensure_project_agents_md(*, project_root: Path) -> Optional[Path]:
339
345
 
340
346
  AGENTS.md 는 codex / aider / 기타 agent 가 읽는 파일이고 @import 같은
341
347
  부분 포함 메커니즘이 없어, 파일 전체 내용이 곧 agent 가 보는 콘텐츠가
342
- 된다. 그래서 CLAUDE.md (`@.project-docs/okstra/CLAUDE.md` 마커 블록
348
+ 된다. 그래서 CLAUDE.md (`@.okstra/CLAUDE.md` 마커 블록
343
349
  주입) 와 달리 "AGENTS.md 가 비어 있을 때만 만들고, 존재하면 절대
344
350
  건드리지 않는" 정책을 사용한다 — 사용자가 직접 작성한 AGENTS.md 를
345
351
  덮어쓰지 않는다.
@@ -5,6 +5,8 @@ import re as _re
5
5
  from pathlib import Path
6
6
  from typing import Optional
7
7
 
8
+ from okstra_project.dirs import tasks_root
9
+
8
10
  from .ids import slugify_task_segment
9
11
  from .jsonl import read_jsonl
10
12
 
@@ -24,7 +26,7 @@ def predict_next_run_seq(project_root: Path, task_group: str, task_id: str,
24
26
  이 셋의 max + 1 이 안전한 다음 seq.
25
27
  """
26
28
  task_type_segment = slugify_task_segment(task_type)
27
- base = (project_root / ".project-docs" / "okstra" / "tasks"
29
+ base = (tasks_root(project_root)
28
30
  / slugify_task_segment(task_group) / slugify_task_segment(task_id)
29
31
  / "runs" / task_type_segment)
30
32
  max_seq = 0