okstra 0.97.1 → 0.98.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 +4 -3
- package/README.md +4 -3
- package/docs/kr/architecture.md +2 -2
- package/docs/kr/cli.md +2 -0
- package/docs/kr/container.md +122 -0
- package/docs/project-structure-overview.md +12 -2
- package/docs/superpowers/plans/2026-06-21-okstra-container-local-user-test.md +714 -0
- package/docs/superpowers/specs/2026-06-21-okstra-container-local-user-test-design.md +125 -0
- package/package.json +1 -1
- package/runtime/BUILD.json +2 -2
- package/runtime/agents/workers/antigravity-worker.md +2 -18
- package/runtime/agents/workers/claude-worker.md +10 -33
- package/runtime/agents/workers/codex-worker.md +2 -18
- package/runtime/agents/workers/report-writer-worker.md +2 -10
- package/runtime/bin/lib/okstra-ctl/cmd-tail.sh +5 -2
- package/runtime/prompts/profiles/_clarification-recommendation.md +1 -0
- package/runtime/prompts/profiles/_coding-conventions-preflight.md +1 -1
- package/runtime/prompts/profiles/_common-contract.md +3 -3
- package/runtime/prompts/profiles/_coverage-critic.md +17 -0
- package/runtime/prompts/profiles/_implementation-deliverable.md +2 -2
- package/runtime/prompts/profiles/_implementation-executor.md +2 -2
- package/runtime/prompts/profiles/_implementation-self-check.md +2 -1
- package/runtime/prompts/profiles/_implementation-verifier.md +3 -3
- package/runtime/prompts/profiles/_stage-discipline.md +5 -8
- package/runtime/prompts/profiles/error-analysis.md +5 -5
- package/runtime/prompts/profiles/final-verification.md +4 -4
- package/runtime/prompts/profiles/implementation-planning.md +7 -7
- package/runtime/prompts/profiles/implementation.md +5 -5
- package/runtime/prompts/profiles/improvement-discovery.md +6 -6
- package/runtime/prompts/profiles/release-handoff.md +3 -3
- package/runtime/prompts/profiles/requirements-discovery.md +4 -4
- package/runtime/prompts/wizard/prompts.ko.json +0 -1
- package/runtime/python/okstra_ctl/__init__.py +4 -1
- package/runtime/python/okstra_ctl/container.py +970 -0
- package/runtime/python/okstra_ctl/container_registry.py +93 -0
- package/runtime/python/okstra_ctl/error_zip.py +4 -4
- package/runtime/python/okstra_ctl/handoff.py +7 -1
- package/runtime/python/okstra_ctl/ids.py +40 -1
- package/runtime/python/okstra_ctl/listing.py +3 -5
- package/runtime/python/okstra_ctl/log_report.py +102 -0
- package/runtime/python/okstra_ctl/pane_reclaim.py +5 -4
- package/runtime/python/okstra_ctl/paths.py +40 -0
- package/runtime/python/okstra_ctl/plan_run_root.py +78 -0
- package/runtime/python/okstra_ctl/reconcile.py +4 -2
- package/runtime/python/okstra_ctl/resolve_task_key.py +54 -0
- package/runtime/python/okstra_ctl/run.py +48 -30
- package/runtime/python/okstra_ctl/stage_integrate.py +8 -2
- package/runtime/python/okstra_ctl/stage_targets.py +79 -0
- package/runtime/python/okstra_ctl/time_report.py +200 -0
- package/runtime/python/okstra_ctl/tmux.py +67 -0
- package/runtime/python/okstra_ctl/wizard.py +35 -20
- package/runtime/python/okstra_project/__init__.py +2 -0
- package/runtime/python/okstra_project/state.py +50 -2
- package/runtime/skills/okstra-brief/SKILL.md +17 -7
- package/runtime/skills/okstra-container/SKILL.md +169 -0
- package/runtime/skills/okstra-inspect/SKILL.md +64 -178
- package/runtime/skills/okstra-memory/SKILL.md +8 -6
- package/runtime/skills/okstra-run/SKILL.md +4 -4
- package/runtime/skills/okstra-schedule/SKILL.md +9 -16
- package/runtime/skills/okstra-setup/SKILL.md +10 -9
- package/runtime/templates/reports/brief.template.md +1 -1
- package/runtime/templates/reports/final-report.template.md +1 -1
- package/runtime/templates/reports/settings.template.json +3 -1
- package/runtime/validators/validate-implementation-plan-stages.py +11 -3
- package/src/cli-registry.mjs +28 -0
- package/src/commands/inspect/container.mjs +27 -0
- package/src/commands/inspect/log-report.mjs +26 -0
- package/src/commands/inspect/resolve-task-key.mjs +26 -0
- package/src/commands/inspect/task-list.mjs +26 -15
- package/src/commands/inspect/time-report.mjs +26 -0
- package/src/commands/lifecycle/check-project.mjs +7 -1
- package/src/commands/lifecycle/doctor.mjs +16 -0
- package/src/lib/skill-catalog.mjs +1 -0
- package/runtime/agents/TODO.md +0 -226
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
"""Per-task container registry — flock 보조 인덱스.
|
|
2
|
+
|
|
3
|
+
docker 라벨은 컨테이너 존재 여부의 기준값(SSOT)이지만 tmux session/pane id 나
|
|
4
|
+
findings 파일 경로 같은 운영 메타데이터는 추적하지 못한다. 이 모듈은 task root
|
|
5
|
+
하위 ``container/registry.json`` 에 그런 보조 정보를 가벼운 dict 로 보관한다.
|
|
6
|
+
|
|
7
|
+
flock + tmp+``os.replace`` 원자 쓰기 idiom 은 ``worktree_registry`` 의
|
|
8
|
+
``_registry_lock``/``_load``/``_save`` 를 그대로 복제했고, lock/registry 경로만
|
|
9
|
+
``paths.container_paths()`` 로 해소해 전역 ``~/.okstra/worktrees/registry.json``
|
|
10
|
+
과 분리한다. dict 구조는 ``{"services": {<svc>: {session_name, pane_id,
|
|
11
|
+
findings_path}}}`` 한 가지뿐 — worktree_registry 의 dataclass 는 복제하지 않는다.
|
|
12
|
+
"""
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
import contextlib
|
|
16
|
+
import fcntl
|
|
17
|
+
import json
|
|
18
|
+
import os
|
|
19
|
+
from pathlib import Path
|
|
20
|
+
|
|
21
|
+
from . import paths
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
@contextlib.contextmanager
|
|
25
|
+
def _registry_lock(lock_path: Path):
|
|
26
|
+
"""``container/registry.json.lock`` 에 대한 배타 flock. worktree_registry 의
|
|
27
|
+
``_registry_lock`` 과 동일한 패턴이되 경로를 인자로 받는다(전역 lock 과 분리)."""
|
|
28
|
+
lock_path.parent.mkdir(parents=True, exist_ok=True)
|
|
29
|
+
if not lock_path.exists():
|
|
30
|
+
lock_path.touch()
|
|
31
|
+
f = lock_path.open("r+")
|
|
32
|
+
try:
|
|
33
|
+
fcntl.flock(f.fileno(), fcntl.LOCK_EX)
|
|
34
|
+
yield
|
|
35
|
+
finally:
|
|
36
|
+
f.close()
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def _load(registry_path: Path) -> dict:
|
|
40
|
+
if not registry_path.exists():
|
|
41
|
+
return {"services": {}}
|
|
42
|
+
try:
|
|
43
|
+
data = json.loads(registry_path.read_text())
|
|
44
|
+
except (OSError, json.JSONDecodeError):
|
|
45
|
+
return {"services": {}}
|
|
46
|
+
data.setdefault("services", {})
|
|
47
|
+
return data
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def _save(registry_path: Path, data: dict) -> None:
|
|
51
|
+
registry_path.parent.mkdir(parents=True, exist_ok=True)
|
|
52
|
+
tmp = registry_path.with_suffix(".json.tmp")
|
|
53
|
+
tmp.write_text(json.dumps(data, indent=2, ensure_ascii=False) + "\n")
|
|
54
|
+
os.replace(tmp, registry_path)
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def reserve(
|
|
58
|
+
project_root: Path, group: str, task_id: str, service: str,
|
|
59
|
+
*, session_name: str, pane_id: str, findings_path: str,
|
|
60
|
+
) -> None:
|
|
61
|
+
"""``service`` 의 tmux session/pane/findings 메타데이터를 등록한다."""
|
|
62
|
+
cp = paths.container_paths(project_root, group, task_id)
|
|
63
|
+
with _registry_lock(cp["registry_lock"]):
|
|
64
|
+
data = _load(cp["registry"])
|
|
65
|
+
data["services"][service] = {
|
|
66
|
+
"session_name": session_name,
|
|
67
|
+
"pane_id": pane_id,
|
|
68
|
+
"findings_path": findings_path,
|
|
69
|
+
}
|
|
70
|
+
_save(cp["registry"], data)
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def lookup(project_root: Path, group: str, task_id: str) -> dict:
|
|
74
|
+
"""현재 registry dict 를 돌려준다. 파일 부재 시 ``{"services": {}}``."""
|
|
75
|
+
cp = paths.container_paths(project_root, group, task_id)
|
|
76
|
+
with _registry_lock(cp["registry_lock"]):
|
|
77
|
+
return _load(cp["registry"])
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def release(project_root: Path, group: str, task_id: str, service: str) -> None:
|
|
81
|
+
"""``service`` 항목 하나만 제거한다(없으면 no-op)."""
|
|
82
|
+
cp = paths.container_paths(project_root, group, task_id)
|
|
83
|
+
with _registry_lock(cp["registry_lock"]):
|
|
84
|
+
data = _load(cp["registry"])
|
|
85
|
+
data["services"].pop(service, None)
|
|
86
|
+
_save(cp["registry"], data)
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def clear(project_root: Path, group: str, task_id: str) -> None:
|
|
90
|
+
"""모든 service 항목을 비운다."""
|
|
91
|
+
cp = paths.container_paths(project_root, group, task_id)
|
|
92
|
+
with _registry_lock(cp["registry_lock"]):
|
|
93
|
+
_save(cp["registry"], {"services": {}})
|
|
@@ -16,7 +16,7 @@ from pathlib import Path
|
|
|
16
16
|
from okstra_ctl.error_log_core import aggregate, parse_records
|
|
17
17
|
from okstra_ctl.jsonl import read_jsonl
|
|
18
18
|
from okstra_ctl.locks import central_lock
|
|
19
|
-
from okstra_ctl.paths import okstra_home
|
|
19
|
+
from okstra_ctl.paths import okstra_home, resolve_under_root
|
|
20
20
|
|
|
21
21
|
|
|
22
22
|
def _run_dirs(home: Path) -> list[tuple[str, Path]]:
|
|
@@ -26,13 +26,13 @@ def _run_dirs(home: Path) -> list[tuple[str, Path]]:
|
|
|
26
26
|
for row in rows:
|
|
27
27
|
project_root = str(row.get("projectRoot", ""))
|
|
28
28
|
run_dir_rel = str(row.get("runDirRel", ""))
|
|
29
|
-
if not project_root or not run_dir_rel:
|
|
30
|
-
continue
|
|
31
29
|
key = (project_root, run_dir_rel)
|
|
32
30
|
if key in seen:
|
|
33
31
|
continue
|
|
34
32
|
seen.add(key)
|
|
35
|
-
|
|
33
|
+
run_dir = resolve_under_root(project_root, run_dir_rel)
|
|
34
|
+
if run_dir is not None:
|
|
35
|
+
out.append((project_root, run_dir))
|
|
36
36
|
return out
|
|
37
37
|
|
|
38
38
|
|
|
@@ -388,7 +388,7 @@ def _parse_stages_csv(raw: str) -> List[int]:
|
|
|
388
388
|
def main(argv: Optional[list] = None) -> int:
|
|
389
389
|
import argparse
|
|
390
390
|
|
|
391
|
-
from .run import _parse_stage_map_into_ctx
|
|
391
|
+
from .run import _parse_stage_map_into_ctx, PrepareError
|
|
392
392
|
|
|
393
393
|
p = argparse.ArgumentParser(prog="okstra handoff")
|
|
394
394
|
sub = p.add_subparsers(dest="cmd", required=True)
|
|
@@ -466,6 +466,12 @@ def main(argv: Optional[list] = None) -> int:
|
|
|
466
466
|
except HandoffError as exc:
|
|
467
467
|
print(json.dumps({"error": str(exc)}, ensure_ascii=False))
|
|
468
468
|
return 1
|
|
469
|
+
except PrepareError as exc:
|
|
470
|
+
# _parse_stage_map_into_ctx 가 빈/손상 Stage Map 을 거부하면 PrepareError 가
|
|
471
|
+
# 오른다(HandoffError 의 형제 — 위 except 로 안 잡힘). JSON 계약을 깨고
|
|
472
|
+
# traceback 으로 죽지 않도록 error envelope + exit 1 로 처리한다.
|
|
473
|
+
print(json.dumps({"error": str(exc)}, ensure_ascii=False))
|
|
474
|
+
return 1
|
|
469
475
|
print(json.dumps(out, ensure_ascii=False, indent=2))
|
|
470
476
|
return 0
|
|
471
477
|
|
|
@@ -1,9 +1,15 @@
|
|
|
1
|
-
"""runId 빌드/파싱/세그먼트 escape,
|
|
1
|
+
"""runId 빌드/파싱/세그먼트 escape, 세션명, container compose 라벨."""
|
|
2
2
|
from __future__ import annotations
|
|
3
3
|
|
|
4
|
+
import hashlib as _hashlib
|
|
4
5
|
import re as _re
|
|
5
6
|
from typing import Dict
|
|
6
7
|
|
|
8
|
+
from okstra_project.state import slugify as _slugify
|
|
9
|
+
|
|
10
|
+
# docker compose project name 길이 상한. compose 는 [a-z0-9][a-z0-9_-]* 만 허용한다.
|
|
11
|
+
CONTAINER_PROJECT_NAME_MAX = 60
|
|
12
|
+
|
|
7
13
|
|
|
8
14
|
# runId 형식: "<project-id>/<task-group>/<task-id>/<task-type>/r<run-seq>"
|
|
9
15
|
# task-type 이 포함되는 이유: okstra.sh 의 RUN_DIR 은 task-type 단위 하위 디렉터리이고
|
|
@@ -82,3 +88,36 @@ def run_id_to_session_name(run_id: str) -> str:
|
|
|
82
88
|
def slugify_task_segment(value: str) -> str:
|
|
83
89
|
"""Match okstra.sh slugify_value for task directory segments."""
|
|
84
90
|
return _re.sub(r"[^a-z0-9]+", "-", value.lower()).strip("-")
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def build_container_session_name(project_id: str, group: str, task_id: str) -> str:
|
|
94
|
+
"""container detached tmux 세션명을 합친다 → 'okstra-container-<slug>'.
|
|
95
|
+
|
|
96
|
+
세그먼트는 task_dir 가 쓰는 slugify_task_segment 와 동일 규칙으로 정규화해
|
|
97
|
+
세션명 slug 가 프로젝트 디스크 경로 slug 와 분기하지 않게 한다.
|
|
98
|
+
"""
|
|
99
|
+
seg = slugify_task_segment
|
|
100
|
+
return f"okstra-container-{seg(project_id)}-{seg(group)}-{seg(task_id)}"
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def compose_project_name(project_id: str, task_group: str, task_id: str) -> str:
|
|
104
|
+
"""docker compose project name 을 합친다(순수 계산).
|
|
105
|
+
|
|
106
|
+
세그먼트 정규화는 ``paths.task_dir`` 가 쓰는 slug 함수(okstra_project.state.
|
|
107
|
+
slugify)와 동일 함수를 써서 라벨(이 이름이 SSOT)↔디스크 경로의 slug 가
|
|
108
|
+
분기하지 않게 한다. 상한(``CONTAINER_PROJECT_NAME_MAX``) 초과 시 읽기 좋은
|
|
109
|
+
본문을 앞에서 자르고 끝에 전체 이름의 해시 8자리를 붙인다 — 단순 prefix-cut 은
|
|
110
|
+
task_group 만 다른 두 task-key 를 같은 이름으로 collapse 시켜(라벨 SSOT 충돌로
|
|
111
|
+
한 task 의 down 이 형제 task 컨테이너까지 회수) 위험하므로, 해시로 distinct 를
|
|
112
|
+
보장하고 상한도 항상 만족시킨다."""
|
|
113
|
+
def seg(v: str) -> str:
|
|
114
|
+
return _slugify(v) or "x" # 빈 슬러그 방지(compose 는 [a-z0-9] 시작 요구)
|
|
115
|
+
|
|
116
|
+
proj, grp, task = seg(project_id), seg(task_group), seg(task_id)
|
|
117
|
+
name = f"okstra-{proj}-{grp}-{task}"
|
|
118
|
+
if len(name) <= CONTAINER_PROJECT_NAME_MAX:
|
|
119
|
+
return name
|
|
120
|
+
digest = _hashlib.sha1(name.encode("utf-8")).hexdigest()[:8]
|
|
121
|
+
body_budget = CONTAINER_PROJECT_NAME_MAX - len("okstra-") - 1 - len(digest)
|
|
122
|
+
body = f"{proj}-{grp}-{task}"[:body_budget].rstrip("-")
|
|
123
|
+
return f"okstra-{body}-{digest}"
|
|
@@ -7,6 +7,7 @@ from datetime import datetime, timedelta, timezone
|
|
|
7
7
|
from pathlib import Path
|
|
8
8
|
from typing import List, Optional
|
|
9
9
|
|
|
10
|
+
from .paths import resolve_under_root
|
|
10
11
|
from .reconcile import _parse_iso
|
|
11
12
|
from .run_index_row import read_run_index
|
|
12
13
|
|
|
@@ -174,8 +175,5 @@ def format_show(row: dict) -> str:
|
|
|
174
175
|
|
|
175
176
|
|
|
176
177
|
def absolute_final_report_path(row: dict) -> Optional[Path]:
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
if not project_root or not rel:
|
|
180
|
-
return None
|
|
181
|
-
return (Path(project_root) / rel).resolve()
|
|
178
|
+
target = resolve_under_root(row.get("projectRoot", ""), row.get("finalReportRel", ""))
|
|
179
|
+
return target.resolve() if target is not None else None
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
"""Read-side inventory of wrapper sidecar logs.
|
|
2
|
+
|
|
3
|
+
`<project-root>/.okstra/tasks/**/runs/*/prompts/*.log` 를 스캔해 크기·mtime 을
|
|
4
|
+
수집하고 task-type/worker/seq 를 path 에서 파싱한 뒤, top-largest 목록과 per-task
|
|
5
|
+
합계를 만든다. skill markdown 이 raw `find`(OS별 분기) + 손-집계로 하던 일을 코드
|
|
6
|
+
SSOT 로 옮긴 것 — read-only(삭제하지 않는다). cleanup 명령 제시는 호출자(skill)에
|
|
7
|
+
남긴다.
|
|
8
|
+
"""
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import argparse
|
|
12
|
+
import json
|
|
13
|
+
import sys
|
|
14
|
+
from pathlib import Path
|
|
15
|
+
|
|
16
|
+
from okstra_project import resolve_project_root
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def _project_id(project_root: Path) -> str:
|
|
20
|
+
try:
|
|
21
|
+
return json.loads((project_root / ".okstra" / "project.json").read_text(encoding="utf-8")).get("projectId", "")
|
|
22
|
+
except (OSError, ValueError):
|
|
23
|
+
return ""
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def _parse(parts: tuple, stem: str) -> dict:
|
|
27
|
+
group = parts[0] if len(parts) > 0 else ""
|
|
28
|
+
task_id = parts[1] if len(parts) > 1 else ""
|
|
29
|
+
phase = ""
|
|
30
|
+
if "runs" in parts:
|
|
31
|
+
i = parts.index("runs")
|
|
32
|
+
if i + 1 < len(parts):
|
|
33
|
+
phase = parts[i + 1]
|
|
34
|
+
worker = stem.split("-worker-prompt-")[0] if "-worker-prompt-" in stem else ""
|
|
35
|
+
seq = stem.rsplit("-", 1)[-1] if "-" in stem else ""
|
|
36
|
+
return {"taskGroup": group, "taskId": task_id, "phase": phase, "worker": worker, "seq": seq}
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def _per_task(files: list[dict]) -> list[dict]:
|
|
40
|
+
acc: dict[str, dict] = {}
|
|
41
|
+
for f in files:
|
|
42
|
+
tk = f["taskKey"]
|
|
43
|
+
if not tk:
|
|
44
|
+
continue
|
|
45
|
+
a = acc.get(tk)
|
|
46
|
+
if a is None:
|
|
47
|
+
a = acc[tk] = {"taskKey": tk, "fileCount": 0, "totalBytes": 0,
|
|
48
|
+
"oldestEpoch": f["mtimeEpoch"], "newestEpoch": f["mtimeEpoch"]}
|
|
49
|
+
a["fileCount"] += 1
|
|
50
|
+
a["totalBytes"] += f["sizeBytes"]
|
|
51
|
+
a["oldestEpoch"] = min(a["oldestEpoch"], f["mtimeEpoch"])
|
|
52
|
+
a["newestEpoch"] = max(a["newestEpoch"], f["mtimeEpoch"])
|
|
53
|
+
return sorted(acc.values(), key=lambda a: a["totalBytes"], reverse=True)
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def scan_logs(logs_root: Path, project_root: Path, top: int = 20) -> dict:
|
|
57
|
+
project_id = _project_id(project_root)
|
|
58
|
+
files: list[dict] = []
|
|
59
|
+
if logs_root.is_dir():
|
|
60
|
+
for p in logs_root.rglob("*.log"):
|
|
61
|
+
parts = p.relative_to(logs_root).parts
|
|
62
|
+
if "runs" not in parts or "prompts" not in parts:
|
|
63
|
+
continue
|
|
64
|
+
try:
|
|
65
|
+
size = p.stat().st_size
|
|
66
|
+
mtime = int(p.stat().st_mtime)
|
|
67
|
+
except OSError:
|
|
68
|
+
continue
|
|
69
|
+
meta = _parse(parts, p.stem)
|
|
70
|
+
tk = f"{project_id}:{meta['taskGroup']}:{meta['taskId']}" if meta["taskGroup"] else ""
|
|
71
|
+
files.append({"path": str(p), "sizeBytes": size, "mtimeEpoch": mtime,
|
|
72
|
+
"taskKey": tk, **meta})
|
|
73
|
+
files.sort(key=lambda f: f["sizeBytes"], reverse=True)
|
|
74
|
+
return {
|
|
75
|
+
"logsRoot": str(logs_root),
|
|
76
|
+
"topLargest": files[:top],
|
|
77
|
+
"perTask": _per_task(files),
|
|
78
|
+
"totals": {"fileCount": len(files),
|
|
79
|
+
"totalBytes": sum(f["sizeBytes"] for f in files),
|
|
80
|
+
"taskCount": len({f["taskKey"] for f in files if f["taskKey"]})},
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def main(argv: list[str] | None = None) -> int:
|
|
85
|
+
parser = argparse.ArgumentParser(
|
|
86
|
+
prog="okstra log-report",
|
|
87
|
+
description="Inventory wrapper sidecar logs by size and task (read-only).")
|
|
88
|
+
parser.add_argument("--project-root", default="")
|
|
89
|
+
parser.add_argument("--cwd", default=".")
|
|
90
|
+
parser.add_argument("--top", type=int, default=20, help="topLargest entry count")
|
|
91
|
+
parser.add_argument("--json", action="store_true", help="emit JSON (always on)")
|
|
92
|
+
args = parser.parse_args(argv)
|
|
93
|
+
|
|
94
|
+
project_root = resolve_project_root(explicit_root=args.project_root, cwd=args.cwd)
|
|
95
|
+
result = {"ok": True, "projectRoot": str(project_root)}
|
|
96
|
+
result.update(scan_logs(project_root / ".okstra" / "tasks", project_root, top=args.top))
|
|
97
|
+
print(json.dumps(result, ensure_ascii=False, indent=2))
|
|
98
|
+
return 0
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
if __name__ == "__main__":
|
|
102
|
+
raise SystemExit(main(sys.argv[1:]))
|
|
@@ -6,6 +6,7 @@ import json
|
|
|
6
6
|
import sys
|
|
7
7
|
from pathlib import Path
|
|
8
8
|
|
|
9
|
+
from .paths import resolve_under_root
|
|
9
10
|
from .reconcile import NON_TERMINAL_RECENT_STATUSES
|
|
10
11
|
from .wrapper_status import read_wrapper_status
|
|
11
12
|
|
|
@@ -30,14 +31,14 @@ def active_run_dirs(home: Path) -> list[Path]:
|
|
|
30
31
|
row = json.loads(line)
|
|
31
32
|
except json.JSONDecodeError:
|
|
32
33
|
continue
|
|
33
|
-
rel = row.get("runDirRel")
|
|
34
|
-
root = row.get("projectRoot")
|
|
35
34
|
# 회수 대상(진행 중) 집합은 reconcile 의 NON_TERMINAL_RECENT_STATUSES 가
|
|
36
35
|
# SSOT. reserving 은 아직 run-dir 산출물이 없어(= 회수할 pane 이 없어) 이
|
|
37
36
|
# 집합에 들어있지 않으므로 자연히 제외된다(allowlist).
|
|
38
|
-
if
|
|
37
|
+
if row.get("status") not in NON_TERMINAL_RECENT_STATUSES:
|
|
39
38
|
continue
|
|
40
|
-
|
|
39
|
+
run_dir = resolve_under_root(row.get("projectRoot"), row.get("runDirRel"))
|
|
40
|
+
if run_dir is not None:
|
|
41
|
+
dirs.append(run_dir)
|
|
41
42
|
return dirs
|
|
42
43
|
|
|
43
44
|
|
|
@@ -31,8 +31,10 @@ __all__ = [
|
|
|
31
31
|
"DISCOVERY_RELATIVE",
|
|
32
32
|
"compute_run_paths",
|
|
33
33
|
"next_run_seq",
|
|
34
|
+
"resolve_under_root",
|
|
34
35
|
"task_dir",
|
|
35
36
|
"task_runs_dir",
|
|
37
|
+
"container_paths",
|
|
36
38
|
"okstra_home",
|
|
37
39
|
]
|
|
38
40
|
|
|
@@ -56,6 +58,23 @@ def task_runs_dir(project_root: Path, task_group: str, task_id: str) -> Path:
|
|
|
56
58
|
return task_dir(project_root, task_group, task_id) / "runs"
|
|
57
59
|
|
|
58
60
|
|
|
61
|
+
def container_paths(project_root: Path, task_group: str, task_id: str) -> dict:
|
|
62
|
+
"""container 산출물 경로 묶음(순수 계산, mkdir 금지).
|
|
63
|
+
|
|
64
|
+
task root 하위 ``container/`` 디렉터리에 deploy 산출물을 모은다. base 가
|
|
65
|
+
``task_dir`` 에서 파생되므로 라벨(compose project name)↔디스크 경로의 slug
|
|
66
|
+
규칙이 자동으로 일치한다."""
|
|
67
|
+
base = task_dir(project_root, task_group, task_id) / "container"
|
|
68
|
+
return {
|
|
69
|
+
"container_dir": base,
|
|
70
|
+
"env_override": base / "env.override",
|
|
71
|
+
"registry": base / "registry.json",
|
|
72
|
+
"registry_lock": base / "registry.json.lock",
|
|
73
|
+
"deploy_state": base / "deploy-state.json",
|
|
74
|
+
"watchers_dir": base / "watchers",
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
|
|
59
78
|
def next_run_seq(run_seq_dir: Path, task_type_segment: str) -> int:
|
|
60
79
|
"""run_seq_dir 안에서 `*-<task-type>-NNN.<ext>` 파일을 스캔해 다음 seq 번호를
|
|
61
80
|
돌려준다. 디렉터리 부재 시 1.
|
|
@@ -84,6 +103,27 @@ def _rel(project_root: Path, target: Path) -> str:
|
|
|
84
103
|
return str(target)
|
|
85
104
|
|
|
86
105
|
|
|
106
|
+
def resolve_under_root(
|
|
107
|
+
project_root: str | Path | None, rel: str | None
|
|
108
|
+
) -> Path | None:
|
|
109
|
+
"""글로벌 인덱스의 신뢰 불가 상대경로 필드(runDirRel/finalReportRel/
|
|
110
|
+
teamStatePath 등)를 project_root 기준 절대경로로 푼다(_rel 의 역).
|
|
111
|
+
|
|
112
|
+
이 필드들은 신뢰할 수 없어 절대경로나 '..' 가 들어오면 결합 결과가
|
|
113
|
+
project_root 밖을 가리킬 수 있다(`Path('/p') / '/etc'` → `/etc`). 루트 하위로
|
|
114
|
+
떨어지지 않거나 입력이 비면 None 을 돌려준다. 결합 결과 자체는 resolve 하지
|
|
115
|
+
않고 그대로 돌려준다 — pane 태그 매칭이 compute_run_paths 의 un-resolved
|
|
116
|
+
RUN_DIR 문자열에 의존하므로 containment 검사에만 resolve 를 쓴다(절대경로가
|
|
117
|
+
필요한 호출자는 반환값에 직접 .resolve() 한다)."""
|
|
118
|
+
if not project_root or not rel:
|
|
119
|
+
return None
|
|
120
|
+
root = Path(project_root)
|
|
121
|
+
target = root / rel
|
|
122
|
+
if not target.resolve().is_relative_to(root.resolve()):
|
|
123
|
+
return None
|
|
124
|
+
return target
|
|
125
|
+
|
|
126
|
+
|
|
87
127
|
def compute_run_paths(
|
|
88
128
|
*,
|
|
89
129
|
project_root: Path,
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
"""implementation-planning run-root 해소 규칙의 단일 참조점.
|
|
2
|
+
|
|
3
|
+
`plan_run_root` 는 한 planning run 의 산출물 루트다. consumers.jsonl(stage 완료
|
|
4
|
+
원장) 과 approved-plan(final-report) 이 모두 이 루트 기준으로 놓인다.
|
|
5
|
+
|
|
6
|
+
두 진입점이 같은 layout 규칙을 공유한다:
|
|
7
|
+
- final-verification 은 사용자가 넘긴 approved-plan 파일 경로에서 역산한다
|
|
8
|
+
(``<plan_run_root>/reports/final-report-...md`` → ``parents[1]``).
|
|
9
|
+
- container 는 approved-plan 입력이 없으므로 task-key 로 planning run 디렉터리를
|
|
10
|
+
스캔해 latest done run 을 고른다.
|
|
11
|
+
"""
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
import re
|
|
15
|
+
from pathlib import Path
|
|
16
|
+
from typing import NamedTuple
|
|
17
|
+
|
|
18
|
+
from .stage_targets import PrepareError
|
|
19
|
+
|
|
20
|
+
_FINAL_REPORT_RE = re.compile(r"final-report-implementation-planning-(\d+)\.md$")
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class PlanRun(NamedTuple):
|
|
24
|
+
"""task-key 로 역추적한 planning run: 산출물 루트 + approved-plan(final-report) 경로."""
|
|
25
|
+
run_root: Path
|
|
26
|
+
approved_plan_path: str
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def plan_run_root_from_approved_plan(approved_plan_path: str | Path) -> Path:
|
|
30
|
+
"""approved-plan(final-report) 경로에서 plan_run_root 를 역산한다.
|
|
31
|
+
|
|
32
|
+
final-report 는 ``<plan_run_root>/reports/final-report-...md`` 에 놓이므로
|
|
33
|
+
``parents[1]`` 이 run-root 다. run.py(final-verification) 와 container 가
|
|
34
|
+
이 한 규칙을 공유한다."""
|
|
35
|
+
return Path(approved_plan_path).resolve().parents[1]
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def resolve_plan_run_root_by_task_key(
|
|
39
|
+
*, project_root: Path, task_group: str, task_id: str,
|
|
40
|
+
) -> PlanRun:
|
|
41
|
+
"""task-key 로 implementation-planning run 을 역추적한다.
|
|
42
|
+
|
|
43
|
+
container 는 approved-plan 입력이 없어 디스크에서 planning run 을 찾아야 한다.
|
|
44
|
+
``<task_dir>/runs/implementation-planning/reports/`` 의 final-report 들을
|
|
45
|
+
스캔해 done consumers 를 가진 run 중 seq 가 가장 큰(latest) run 의 run-root 와
|
|
46
|
+
그 final-report 경로를 돌려준다. done run 이 없거나 모호하면 PrepareError.
|
|
47
|
+
"""
|
|
48
|
+
from . import paths
|
|
49
|
+
from .consumers import read_consumers
|
|
50
|
+
|
|
51
|
+
reports_dir = (
|
|
52
|
+
paths.task_dir(project_root, task_group, task_id)
|
|
53
|
+
/ "runs" / "implementation-planning" / "reports"
|
|
54
|
+
)
|
|
55
|
+
if not reports_dir.is_dir():
|
|
56
|
+
raise PrepareError(
|
|
57
|
+
"container up: implementation-planning run 을 찾을 수 없습니다 "
|
|
58
|
+
f"({reports_dir} 없음). 먼저 implementation-planning 을 완료하거나 "
|
|
59
|
+
"approved-plan 경로를 직접 지정하세요."
|
|
60
|
+
)
|
|
61
|
+
candidates: list[tuple[int, Path, Path]] = []
|
|
62
|
+
for report in reports_dir.glob("final-report-implementation-planning-*.md"):
|
|
63
|
+
m = _FINAL_REPORT_RE.search(report.name)
|
|
64
|
+
if not m:
|
|
65
|
+
continue
|
|
66
|
+
run_root = plan_run_root_from_approved_plan(report)
|
|
67
|
+
done = [r for r in read_consumers(run_root) if r.get("status") == "done"]
|
|
68
|
+
if done:
|
|
69
|
+
candidates.append((int(m.group(1)), run_root, report))
|
|
70
|
+
if not candidates:
|
|
71
|
+
raise PrepareError(
|
|
72
|
+
"container up: done 상태의 implementation-planning run 이 없습니다. "
|
|
73
|
+
"stage 를 완료(implementation done)한 뒤 다시 시도하거나 approved-plan "
|
|
74
|
+
"경로를 직접 지정하세요."
|
|
75
|
+
)
|
|
76
|
+
candidates.sort(key=lambda c: c[0])
|
|
77
|
+
_, run_root, report = candidates[-1]
|
|
78
|
+
return PlanRun(run_root=run_root, approved_plan_path=str(report))
|
|
@@ -9,6 +9,7 @@ from typing import Optional
|
|
|
9
9
|
|
|
10
10
|
from .jsonl import append_jsonl, rotate_recent_if_needed
|
|
11
11
|
from .locks import central_lock
|
|
12
|
+
from .paths import resolve_under_root
|
|
12
13
|
from .project_meta import upsert_project_meta
|
|
13
14
|
from .run_index_row import read_run_index, slim_run_row
|
|
14
15
|
|
|
@@ -61,10 +62,11 @@ def _read_run_manifest_validation(project_root: Path, run_dir_rel: str,
|
|
|
61
62
|
완료/실패 여부를 정확히 반영하기 위함 — 단순히 final-report 존재만으로
|
|
62
63
|
'passed' 로 판정하면 contract-violation/failed 를 잘못 기록한다.
|
|
63
64
|
"""
|
|
64
|
-
|
|
65
|
+
run_dir = resolve_under_root(project_root, run_dir_rel)
|
|
66
|
+
if run_dir is None:
|
|
65
67
|
return "not-run"
|
|
66
68
|
suffix = f"-{task_type}-{run_seq:03d}"
|
|
67
|
-
manifest =
|
|
69
|
+
manifest = run_dir / "manifests" / f"run-manifest{suffix}.json"
|
|
68
70
|
if not manifest.is_file():
|
|
69
71
|
return "not-run"
|
|
70
72
|
try:
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
"""bare task-id → task-catalog.json 후보 entry 목록(skill 공용 해석기).
|
|
2
|
+
|
|
3
|
+
skill markdown 이 복제하던 "catalog 를 읽어 taskId 를 case-insensitive 매칭 →
|
|
4
|
+
0/1/N 분기" 를 단일 CLI 진입점으로 수렴시킨다. okstra_project.resolve_task_id
|
|
5
|
+
가 SSOT 이고, 이 모듈은 project-root 해석 + JSON 정형화만 한다.
|
|
6
|
+
"""
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import argparse
|
|
10
|
+
import json
|
|
11
|
+
import sys
|
|
12
|
+
|
|
13
|
+
from okstra_project import (
|
|
14
|
+
ResolverError,
|
|
15
|
+
StateError,
|
|
16
|
+
resolve_project_root,
|
|
17
|
+
resolve_task_id,
|
|
18
|
+
)
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def main(argv: list[str] | None = None) -> int:
|
|
22
|
+
parser = argparse.ArgumentParser(
|
|
23
|
+
description="Resolve a bare task-id to candidate task-keys from the catalog."
|
|
24
|
+
)
|
|
25
|
+
parser.add_argument("task_id", help="bare task-id (case-insensitive)")
|
|
26
|
+
parser.add_argument("--task-group", default="", help="scope match to this task-group")
|
|
27
|
+
parser.add_argument("--project-root", default="", help="project root for catalog lookup")
|
|
28
|
+
parser.add_argument("--cwd", default=".", help="cwd for project root resolution")
|
|
29
|
+
parser.add_argument("--json", action="store_true", help="emit JSON (always on)")
|
|
30
|
+
args = parser.parse_args(argv)
|
|
31
|
+
|
|
32
|
+
try:
|
|
33
|
+
project_root = resolve_project_root(explicit_root=args.project_root, cwd=args.cwd)
|
|
34
|
+
except ResolverError as exc:
|
|
35
|
+
print(json.dumps({"ok": False, "stage": "resolve", "reason": str(exc)}))
|
|
36
|
+
return 2
|
|
37
|
+
|
|
38
|
+
try:
|
|
39
|
+
matches = resolve_task_id(
|
|
40
|
+
project_root, args.task_id, task_group=args.task_group or None
|
|
41
|
+
)
|
|
42
|
+
except StateError as exc:
|
|
43
|
+
print(json.dumps({"ok": False, "stage": "catalog", "reason": str(exc)}))
|
|
44
|
+
return 2
|
|
45
|
+
|
|
46
|
+
print(json.dumps(
|
|
47
|
+
{"ok": True, "projectRoot": str(project_root), "matches": matches},
|
|
48
|
+
ensure_ascii=False, indent=2,
|
|
49
|
+
))
|
|
50
|
+
return 0
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
if __name__ == "__main__":
|
|
54
|
+
raise SystemExit(main(sys.argv[1:]))
|