okstra 0.36.1 → 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.
- package/README.kr.md +6 -6
- package/README.md +6 -6
- package/bin/okstra +4 -2
- package/docs/kr/architecture.md +29 -29
- package/docs/kr/cli.md +7 -6
- package/docs/pr-template-usage.md +2 -2
- package/docs/project-structure-overview.md +4 -4
- package/docs/superpowers/plans/2026-05-25-okstra-project-root-rename.md +159 -0
- package/docs/superpowers/plans/2026-05-26-wizard-3-option-picker.md +860 -0
- package/docs/task-process/common-flow.md +2 -2
- package/package.json +1 -1
- package/runtime/BUILD.json +2 -2
- package/runtime/agents/SKILL.md +2 -2
- package/runtime/agents/workers/claude-worker.md +1 -1
- package/runtime/prompts/profiles/_common-contract.md +6 -6
- package/runtime/prompts/profiles/_implementation-executor.md +2 -2
- package/runtime/prompts/profiles/_implementation-verifier.md +1 -1
- package/runtime/prompts/profiles/final-verification.md +1 -1
- package/runtime/prompts/profiles/implementation-planning.md +5 -5
- package/runtime/prompts/profiles/release-handoff.md +1 -1
- package/runtime/prompts/profiles/requirements-discovery.md +3 -3
- package/runtime/prompts/wizard/prompts.ko.json +80 -6
- package/runtime/python/lib/okstra/interactive.sh +2 -2
- package/runtime/python/lib/okstra/project-resolver.sh +1 -1
- package/runtime/python/lib/okstra/usage.sh +5 -5
- package/runtime/python/lib/okstra-ctl/cmd-rerun.sh +1 -1
- package/runtime/python/okstra_ctl/backfill.py +5 -3
- package/runtime/python/okstra_ctl/migrate.py +408 -0
- package/runtime/python/okstra_ctl/paths.py +12 -3
- package/runtime/python/okstra_ctl/pr_template.py +4 -2
- package/runtime/python/okstra_ctl/render.py +8 -6
- package/runtime/python/okstra_ctl/render_final_report.py +4 -1
- package/runtime/python/okstra_ctl/run.py +5 -5
- package/runtime/python/okstra_ctl/seeding.py +12 -6
- package/runtime/python/okstra_ctl/sequence.py +3 -1
- package/runtime/python/okstra_ctl/wizard.py +412 -77
- package/runtime/python/okstra_ctl/worktree.py +8 -6
- package/runtime/python/okstra_project/__init__.py +35 -5
- package/runtime/python/okstra_project/dirs.py +67 -0
- package/runtime/python/okstra_project/resolver.py +8 -8
- package/runtime/python/okstra_project/state.py +11 -9
- package/runtime/python/okstra_token_usage/collect.py +3 -1
- package/runtime/python/okstra_token_usage/report.py +6 -2
- package/runtime/skills/okstra-brief/SKILL.md +30 -30
- package/runtime/skills/okstra-context-loader/SKILL.md +7 -7
- package/runtime/skills/okstra-inspect/SKILL.md +25 -25
- package/runtime/skills/okstra-run/templates/pr-body.template.md +1 -1
- package/runtime/skills/okstra-schedule/SKILL.md +7 -7
- package/runtime/skills/okstra-setup/SKILL.md +8 -8
- package/runtime/templates/okstra.CLAUDE.md +4 -4
- package/runtime/templates/reports/brief.template.md +5 -5
- package/runtime/templates/reports/task-brief.template.md +1 -1
- package/runtime/validators/lib/fixtures.sh +2 -2
- package/runtime/validators/lib/paths.sh +9 -3
- package/runtime/validators/validate-brief.py +2 -2
- package/runtime/validators/validate-brief.sh +1 -1
- package/runtime/validators/validate-run.py +3 -1
- package/runtime/validators/validate-workflow.sh +2 -2
- package/src/check-project.mjs +3 -3
- package/src/config.mjs +6 -5
- package/src/install.mjs +5 -5
- package/src/migrate.mjs +163 -0
- package/src/okstra-dirs.mjs +37 -0
- package/src/paths.mjs +17 -0
- package/src/setup.mjs +8 -4
- 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
|
-
|
|
24
|
-
|
|
25
|
-
|
|
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>/.
|
|
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 =
|
|
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
|
|
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
|
|
1334
|
-
|
|
1335
|
-
|
|
1336
|
-
|
|
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
|
|
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):
|
|
@@ -74,7 +74,10 @@ def _format_duration_ms(value: Any) -> str:
|
|
|
74
74
|
except (TypeError, ValueError):
|
|
75
75
|
return "--"
|
|
76
76
|
total_seconds = ms // 1000
|
|
77
|
-
|
|
77
|
+
hours, remainder = divmod(total_seconds, 3600)
|
|
78
|
+
minutes, seconds = divmod(remainder, 60)
|
|
79
|
+
if hours:
|
|
80
|
+
return f"{hours}h {minutes:02d}m {seconds:02d}s"
|
|
78
81
|
return f"{minutes}m {seconds:02d}s"
|
|
79
82
|
|
|
80
83
|
|
|
@@ -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
|
-
|
|
628
|
-
if
|
|
627
|
+
project_json = project_json_path(project_root)
|
|
628
|
+
if project_json.is_file():
|
|
629
629
|
try:
|
|
630
|
-
project_meta = json.loads(
|
|
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 {
|
|
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
|
-
"""
|
|
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 =
|
|
214
|
-
_CLAUDE_MD_IMPORT_LINE =
|
|
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
|
-
"""
|
|
228
|
+
"""okstra-relative `CLAUDE.md` 를 `~/.okstra/templates/okstra.CLAUDE.md`
|
|
223
229
|
로 가리키는 symlink 로 provisioning 하고, `<project_root>/CLAUDE.md` 에
|
|
224
|
-
|
|
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 (`@.
|
|
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
|
|
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
|