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.
- package/bin/okstra +1 -0
- package/docs/kr/architecture.md +2 -0
- package/docs/kr/cli.md +11 -3
- package/docs/kr/performance-improvement-plan-v2.md +2 -1
- package/docs/project-structure-overview.md +1 -0
- package/docs/superpowers/plans/2026-06-10-p6-token-usage-incremental.md +1029 -0
- package/docs/superpowers/specs/2026-06-10-blocking-contract-posthoc-conformance-design.md +168 -0
- package/package.json +1 -1
- package/runtime/BUILD.json +2 -2
- package/runtime/agents/SKILL.md +3 -1
- package/runtime/agents/workers/claude-worker.md +1 -1
- package/runtime/agents/workers/codex-worker.md +1 -0
- package/runtime/agents/workers/gemini-worker.md +1 -0
- package/runtime/bin/lib/okstra/cli.sh +4 -0
- package/runtime/bin/lib/okstra/globals.sh +1 -0
- package/runtime/bin/lib/okstra/usage.sh +4 -1
- package/runtime/bin/okstra.sh +1 -0
- package/runtime/prompts/profiles/_implementation-executor.md +1 -0
- package/runtime/python/okstra_ctl/clarification_items.py +96 -37
- package/runtime/python/okstra_ctl/context_cost.py +86 -8
- package/runtime/python/okstra_ctl/locks.py +32 -0
- package/runtime/python/okstra_ctl/migrate.py +45 -6
- package/runtime/python/okstra_ctl/pr_template.py +2 -7
- package/runtime/python/okstra_ctl/run.py +58 -44
- package/runtime/python/okstra_ctl/run_context.py +3 -8
- package/runtime/python/okstra_ctl/seeding.py +25 -18
- package/runtime/python/okstra_ctl/wizard.py +8 -10
- package/runtime/python/okstra_ctl/worktree.py +13 -0
- package/runtime/python/okstra_project/dirs.py +10 -1
- package/runtime/python/okstra_token_usage/claude.py +226 -61
- package/runtime/python/okstra_token_usage/cli.py +10 -1
- package/runtime/python/okstra_token_usage/collect.py +34 -27
- package/runtime/python/okstra_token_usage/cursor.py +93 -0
- package/runtime/python/okstra_token_usage/paths.py +29 -2
- package/runtime/skills/okstra-coding-preflight/clean-code.md +15 -0
- package/runtime/skills/okstra-inspect/SKILL.md +16 -11
- package/runtime/skills/okstra-run/templates/pr-body.template.md +13 -16
- package/runtime/validators/lib/fixtures.sh +73 -10
- package/runtime/validators/lib/runners.sh +4 -0
- package/runtime/validators/validate-run.py +53 -0
- package/runtime/validators/validate_session_conformance.py +430 -0
- 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 =
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
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 =
|
|
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 .
|
|
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
|
-
|
|
348
|
-
if
|
|
350
|
+
scan = scan_approval_gate(body)
|
|
351
|
+
if scan.unreadable_reason:
|
|
349
352
|
raise PrepareError(
|
|
350
|
-
f"approved plan
|
|
351
|
-
f"
|
|
352
|
-
" the
|
|
353
|
-
"
|
|
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 =
|
|
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.
|
|
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
|
-
|
|
1749
|
-
|
|
1750
|
-
|
|
1751
|
-
|
|
1752
|
-
|
|
1753
|
-
|
|
1754
|
-
|
|
1755
|
-
|
|
1756
|
-
|
|
1757
|
-
|
|
1758
|
-
|
|
1759
|
-
|
|
1760
|
-
|
|
1761
|
-
|
|
1762
|
-
|
|
1763
|
-
|
|
1764
|
-
|
|
1765
|
-
|
|
1766
|
-
|
|
1767
|
-
|
|
1768
|
-
|
|
1769
|
-
|
|
1770
|
-
|
|
1771
|
-
|
|
1772
|
-
|
|
1773
|
-
|
|
1774
|
-
|
|
1775
|
-
|
|
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 =
|
|
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 `
|
|
37
|
+
The stamp lives at `okstra_home() / "version"`. `OKSTRA_HOME` overrides
|
|
36
38
|
the home directory for tests.
|
|
37
39
|
"""
|
|
38
|
-
version_file =
|
|
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
|
-
|
|
48
|
+
"""okstra install 이 채워야 하는 최소 자산 경로.
|
|
49
|
+
|
|
50
|
+
`installed_version()` 과 동일하게 `okstra_home()` 을 기준으로 한다 —
|
|
51
|
+
`OKSTRA_HOME` 으로 홈을 격리한 테스트는 conftest 가 같은 자산을 시드해
|
|
52
|
+
설치 검사를 통과시킨다(개발 머신의 실제 `~/.okstra` 설치 여부와 무관).
|
|
53
|
+
"""
|
|
54
|
+
home = okstra_home()
|
|
48
55
|
return [
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
489
|
-
if
|
|
485
|
+
scan = scan_approval_gate(body)
|
|
486
|
+
if scan.unreadable_reason:
|
|
490
487
|
raise WizardError(
|
|
491
|
-
f"approved plan
|
|
492
|
-
f"
|
|
493
|
-
" the
|
|
494
|
-
"
|
|
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 (
|
|
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
|