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.
Files changed (74) hide show
  1. package/README.kr.md +4 -3
  2. package/README.md +4 -3
  3. package/docs/kr/architecture.md +2 -2
  4. package/docs/kr/cli.md +2 -0
  5. package/docs/kr/container.md +122 -0
  6. package/docs/project-structure-overview.md +12 -2
  7. package/docs/superpowers/plans/2026-06-21-okstra-container-local-user-test.md +714 -0
  8. package/docs/superpowers/specs/2026-06-21-okstra-container-local-user-test-design.md +125 -0
  9. package/package.json +1 -1
  10. package/runtime/BUILD.json +2 -2
  11. package/runtime/agents/workers/antigravity-worker.md +2 -18
  12. package/runtime/agents/workers/claude-worker.md +10 -33
  13. package/runtime/agents/workers/codex-worker.md +2 -18
  14. package/runtime/agents/workers/report-writer-worker.md +2 -10
  15. package/runtime/bin/lib/okstra-ctl/cmd-tail.sh +5 -2
  16. package/runtime/prompts/profiles/_clarification-recommendation.md +1 -0
  17. package/runtime/prompts/profiles/_coding-conventions-preflight.md +1 -1
  18. package/runtime/prompts/profiles/_common-contract.md +3 -3
  19. package/runtime/prompts/profiles/_coverage-critic.md +17 -0
  20. package/runtime/prompts/profiles/_implementation-deliverable.md +2 -2
  21. package/runtime/prompts/profiles/_implementation-executor.md +2 -2
  22. package/runtime/prompts/profiles/_implementation-self-check.md +2 -1
  23. package/runtime/prompts/profiles/_implementation-verifier.md +3 -3
  24. package/runtime/prompts/profiles/_stage-discipline.md +5 -8
  25. package/runtime/prompts/profiles/error-analysis.md +5 -5
  26. package/runtime/prompts/profiles/final-verification.md +4 -4
  27. package/runtime/prompts/profiles/implementation-planning.md +7 -7
  28. package/runtime/prompts/profiles/implementation.md +5 -5
  29. package/runtime/prompts/profiles/improvement-discovery.md +6 -6
  30. package/runtime/prompts/profiles/release-handoff.md +3 -3
  31. package/runtime/prompts/profiles/requirements-discovery.md +4 -4
  32. package/runtime/prompts/wizard/prompts.ko.json +0 -1
  33. package/runtime/python/okstra_ctl/__init__.py +4 -1
  34. package/runtime/python/okstra_ctl/container.py +970 -0
  35. package/runtime/python/okstra_ctl/container_registry.py +93 -0
  36. package/runtime/python/okstra_ctl/error_zip.py +4 -4
  37. package/runtime/python/okstra_ctl/handoff.py +7 -1
  38. package/runtime/python/okstra_ctl/ids.py +40 -1
  39. package/runtime/python/okstra_ctl/listing.py +3 -5
  40. package/runtime/python/okstra_ctl/log_report.py +102 -0
  41. package/runtime/python/okstra_ctl/pane_reclaim.py +5 -4
  42. package/runtime/python/okstra_ctl/paths.py +40 -0
  43. package/runtime/python/okstra_ctl/plan_run_root.py +78 -0
  44. package/runtime/python/okstra_ctl/reconcile.py +4 -2
  45. package/runtime/python/okstra_ctl/resolve_task_key.py +54 -0
  46. package/runtime/python/okstra_ctl/run.py +48 -30
  47. package/runtime/python/okstra_ctl/stage_integrate.py +8 -2
  48. package/runtime/python/okstra_ctl/stage_targets.py +79 -0
  49. package/runtime/python/okstra_ctl/time_report.py +200 -0
  50. package/runtime/python/okstra_ctl/tmux.py +67 -0
  51. package/runtime/python/okstra_ctl/wizard.py +35 -20
  52. package/runtime/python/okstra_project/__init__.py +2 -0
  53. package/runtime/python/okstra_project/state.py +50 -2
  54. package/runtime/skills/okstra-brief/SKILL.md +17 -7
  55. package/runtime/skills/okstra-container/SKILL.md +169 -0
  56. package/runtime/skills/okstra-inspect/SKILL.md +64 -178
  57. package/runtime/skills/okstra-memory/SKILL.md +8 -6
  58. package/runtime/skills/okstra-run/SKILL.md +4 -4
  59. package/runtime/skills/okstra-schedule/SKILL.md +9 -16
  60. package/runtime/skills/okstra-setup/SKILL.md +10 -9
  61. package/runtime/templates/reports/brief.template.md +1 -1
  62. package/runtime/templates/reports/final-report.template.md +1 -1
  63. package/runtime/templates/reports/settings.template.json +3 -1
  64. package/runtime/validators/validate-implementation-plan-stages.py +11 -3
  65. package/src/cli-registry.mjs +28 -0
  66. package/src/commands/inspect/container.mjs +27 -0
  67. package/src/commands/inspect/log-report.mjs +26 -0
  68. package/src/commands/inspect/resolve-task-key.mjs +26 -0
  69. package/src/commands/inspect/task-list.mjs +26 -15
  70. package/src/commands/inspect/time-report.mjs +26 -0
  71. package/src/commands/lifecycle/check-project.mjs +7 -1
  72. package/src/commands/lifecycle/doctor.mjs +16 -0
  73. package/src/lib/skill-catalog.mjs +1 -0
  74. 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
- out.append((project_root, Path(project_root) / run_dir_rel))
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
- project_root = row.get("projectRoot", "")
178
- rel = row.get("finalReportRel", "")
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 not rel or not root or row.get("status") not in NON_TERMINAL_RECENT_STATUSES:
37
+ if row.get("status") not in NON_TERMINAL_RECENT_STATUSES:
39
38
  continue
40
- dirs.append(Path(root) / rel)
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
- if not run_dir_rel:
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 = project_root / run_dir_rel / "manifests" / f"run-manifest{suffix}.json"
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:]))