okstra 0.135.0 → 0.136.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "okstra",
3
- "version": "0.135.0",
3
+ "version": "0.136.0",
4
4
  "description": "Multi-agent cross-verification orchestrator runtime + Claude Code skills.",
5
5
  "license": "MIT",
6
6
  "author": "devonshin",
@@ -1,5 +1,5 @@
1
1
  {
2
- "package": "0.135.0",
3
- "builtAt": "2026-07-25T13:20:54.916Z",
2
+ "package": "0.136.0",
3
+ "builtAt": "2026-07-25T16:28:08.933Z",
4
4
  "repoRoot": "/home/runner/work/okstra/okstra"
5
5
  }
@@ -11,19 +11,6 @@ is_interactive_session() {
11
11
  [[ -t 0 && -t 1 ]]
12
12
  }
13
13
 
14
- file_mtime() {
15
- # Epoch mtime; portable across GNU (stat -c) and macOS/BSD (stat -f).
16
- # Validate that the output is a bare integer before accepting it: GNU
17
- # `stat -f %m` (a BSD-ism) does NOT fail cleanly — it succeeds with
18
- # non-numeric filesystem output, so a plain `a || b` would return that
19
- # garbage and later break `-gt`/`-eq` arithmetic under `set -u`.
20
- local m=""
21
- m="$(stat -c %Y "$1" 2>/dev/null)"
22
- [[ "$m" =~ ^[0-9]+$ ]] || m="$(stat -f %m "$1" 2>/dev/null)"
23
- [[ "$m" =~ ^[0-9]+$ ]] || m="0"
24
- printf '%s' "$m"
25
- }
26
-
27
14
  split_task_key() {
28
15
  local raw_key=""
29
16
  raw_key="$(trim_whitespace "${TASK_KEY_INPUT-}")"
@@ -293,74 +280,44 @@ find_latest_final_report() {
293
280
  # Args: project_root task_group task_id task_type_filter(optional)
294
281
  # Echoes: <abs path to latest final-report-*.md>\t<task-type>
295
282
  # Returns: 1 if not found.
283
+ #
284
+ # 선택 규칙(mtime 최신, 동률이면 basename)과 산출물 위치 지식은
285
+ # okstra_ctl.paths.RunRef 가 정본이다. 과거 이 함수는 그 규칙을 bash 로
286
+ # 재구현해 python 쪽과 손으로 동기화해야 했다.
296
287
  local project_root="$1"
297
288
  local task_group="$2"
298
289
  local task_id="$3"
299
290
  local task_type_filter="$4"
300
291
 
292
+ # task-key 단축키 해소와 오타 진단(실제 task-key 목록 출력)은 이 resolver 가
293
+ # 소유한다. 최신 보고서 선택만 python 정본에 위임한다.
301
294
  local task_root=""
302
295
  task_root="$(resolve_task_root_for_shortcut "$project_root" "$PROJECT_ID" "$task_group" "$task_id")" || {
303
296
  printf 'resume-clarification: unable to resolve task root for the given task-key. See diagnostics above.\n' >&2
304
297
  return 1
305
298
  }
306
299
 
307
- local runs_root="$task_root/runs"
308
- if [[ ! -d "$runs_root" ]]; then
309
- printf 'resume-clarification: no runs/ directory under %s\n' "$task_root" >&2
310
- return 1
311
- fi
312
-
313
- local search_dirs=()
314
- if [[ -n "$task_type_filter" ]]; then
315
- search_dirs+=("$runs_root/$task_type_filter/reports")
316
- else
317
- [[ -d "$runs_root/error-analysis/reports" ]] && search_dirs+=("$runs_root/error-analysis/reports")
318
- [[ -d "$runs_root/requirements-discovery/reports" ]] && search_dirs+=("$runs_root/requirements-discovery/reports")
319
- fi
320
-
321
- if (( ${#search_dirs[@]} == 0 )); then
322
- printf 'resume-clarification: no requirements-discovery or error-analysis reports/ directory under %s/runs/. Run that phase first before invoking --resume-clarification.\n' "$task_root" >&2
323
- return 1
324
- fi
325
-
326
- # Pick the chronologically newest report (mtime first), matching the Python
327
- # resume path (okstra_ctl.wizard's mtime-max). A pure basename sort conflates
328
- # phases: across error-analysis/ and requirements-discovery/ dirs the filename
329
- # embeds the task-type, so a lexicographic max always favors one phase
330
- # regardless of which run is actually newer. Ties (same-second mtime within a
331
- # phase dir) fall back to the greater basename so the highest seq/timestamp
332
- # wins deterministically.
333
- local best_path=""
334
- local best_mtime=-1
335
- local best_basename=""
336
- local candidate=""
337
- local candidate_mtime=""
338
- local candidate_base=""
339
- local d=""
340
- for d in "${search_dirs[@]}"; do
341
- [[ -d "$d" ]] || continue
342
- while IFS= read -r candidate; do
343
- candidate_mtime="$(file_mtime "$candidate")"
344
- candidate_base="$(basename "$candidate")"
345
- if [[ -z "$best_path" \
346
- || "$candidate_mtime" -gt "$best_mtime" \
347
- || ( "$candidate_mtime" -eq "$best_mtime" && "$candidate_base" > "$best_basename" ) ]]; then
348
- best_path="$candidate"
349
- best_mtime="$candidate_mtime"
350
- best_basename="$candidate_base"
351
- fi
352
- done < <(find "$d" -maxdepth 1 -type f -name 'final-report-*.md' 2>/dev/null)
353
- done
300
+ local found=""
301
+ found="$(PYTHONPATH="$WORKSPACE_ROOT/scripts:${PYTHONPATH-}" python3 - \
302
+ "$task_root" "$task_type_filter" <<'PY'
303
+ import sys
304
+ from okstra_ctl.paths import RunRef
305
+
306
+ task_root, task_type_filter = sys.argv[1:3]
307
+ types = ((task_type_filter,) if task_type_filter
308
+ else ("error-analysis", "requirements-discovery"))
309
+ ref = RunRef.latest_under(task_root, types)
310
+ if ref is not None:
311
+ sys.stdout.write(f"{ref.report}\t{ref.task_type}")
312
+ PY
313
+ )" || return 1
354
314
 
355
- if [[ -z "$best_path" ]]; then
356
- printf 'resume-clarification: no final-report-*.md found for task %s:%s under %s\n' \
357
- "$task_group" "$task_id" "$runs_root" >&2
315
+ if [[ -z "$found" ]]; then
316
+ printf 'resume-clarification: no final-report-*.md found for task %s:%s under %s/runs\n' \
317
+ "$task_group" "$task_id" "$task_root" >&2
358
318
  return 1
359
319
  fi
360
-
361
- local resolved_type=""
362
- resolved_type="$(basename "$(dirname "$(dirname "$best_path")")")"
363
- printf '%s\t%s\n' "$best_path" "$resolved_type"
320
+ printf '%s\n' "$found"
364
321
  }
365
322
 
366
323
  run_resume_clarification() {
@@ -290,15 +290,17 @@ if (( idle_timeout_secs > 0 )); then
290
290
  while kill -0 "$agy_pid" 2>/dev/null; do
291
291
  sleep "$poll_interval"
292
292
  kill -0 "$agy_pid" 2>/dev/null || exit 0
293
- # GNU `stat -c` 우선 + 숫자 검증. BSD-ism `stat -f %m` 은 Linux 에서 깨끗이
294
- # 실패하지 않고 비숫자 파일시스템 출력으로 성공해 아래 산술을 set -u 에서
295
- # 깨뜨린다file_mtime(lib/okstra/interactive.sh) 동일 가드.
293
+ # Portable mtime probe: GNU (stat -c %Y) first, macOS/BSD (stat -f %m) as
294
+ # the fallback, 0 as the floor. Validate each result as a bare integer
295
+ # before accepting it on Linux the BSD-ism `stat -f %m` does NOT fail
296
+ # cleanly; it succeeds and prints non-numeric filesystem output, which
297
+ # would break the arithmetic below under `set -u`.
296
298
  #
297
- # `|| true` 필수다. macOS 에는 GNU `stat -c` 없어 exit 1 이고,
298
- # `2>/dev/null` stderr 막지 종료코드는 막지 못한다. 워치독은 명시적
299
- # `( ) &` 서브셸이라 위쪽 `set -e` 상속하므로, 실패한 할당 하나가 첫 폴링에서
300
- # 워치독을 통째로 죽인다 조용히, 로그 없이. file_mtime 같은 코드로도
301
- # 멀쩡한 것은 항상 `$( )` 안에서 호출돼 errexit 상속하지 않기 때문이다.
299
+ # `|| true` is mandatory. macOS has no GNU `stat -c`, so that call exits 1,
300
+ # and `2>/dev/null` suppresses stderr but not the exit code. This watchdog
301
+ # is an explicit `( ) &` subshell, so it inherits whatever errexit setting
302
+ # is in force; under `set -e` a single failed assignment kills the whole
303
+ # watchdog on the first poll silently, without a log line.
302
304
  last_mtime="$(stat -c %Y "$log_path" 2>/dev/null || true)"
303
305
  [[ "$last_mtime" =~ ^[0-9]+$ ]] || last_mtime="$(stat -f %m "$log_path" 2>/dev/null || true)"
304
306
  [[ "$last_mtime" =~ ^[0-9]+$ ]] || last_mtime=0
@@ -132,15 +132,17 @@ if (( idle_timeout_secs > 0 )); then
132
132
  while kill -0 "$claude_pid" 2>/dev/null; do
133
133
  sleep "$poll_interval"
134
134
  kill -0 "$claude_pid" 2>/dev/null || exit 0
135
- # GNU `stat -c` 우선 + 숫자 검증. BSD-ism `stat -f %m` 은 Linux 에서 깨끗이
136
- # 실패하지 않고 비숫자 파일시스템 출력으로 성공해 아래 산술을 set -u 에서
137
- # 깨뜨린다file_mtime(lib/okstra/interactive.sh) 동일 가드.
135
+ # Portable mtime probe: GNU (stat -c %Y) first, macOS/BSD (stat -f %m) as
136
+ # the fallback, 0 as the floor. Validate each result as a bare integer
137
+ # before accepting it on Linux the BSD-ism `stat -f %m` does NOT fail
138
+ # cleanly; it succeeds and prints non-numeric filesystem output, which
139
+ # would break the arithmetic below under `set -u`.
138
140
  #
139
- # `|| true` 필수다. macOS 에는 GNU `stat -c` 없어 exit 1 이고,
140
- # `2>/dev/null` stderr 막지 종료코드는 막지 못한다. 워치독은 명시적
141
- # `( ) &` 서브셸이라 위쪽 `set -e` 상속하므로, 실패한 할당 하나가 첫 폴링에서
142
- # 워치독을 통째로 죽인다 조용히, 로그 없이. file_mtime 같은 코드로도
143
- # 멀쩡한 것은 항상 `$( )` 안에서 호출돼 errexit 상속하지 않기 때문이다.
141
+ # `|| true` is mandatory. macOS has no GNU `stat -c`, so that call exits 1,
142
+ # and `2>/dev/null` suppresses stderr but not the exit code. This watchdog
143
+ # is an explicit `( ) &` subshell, so it inherits whatever errexit setting
144
+ # is in force; under `set -e` a single failed assignment kills the whole
145
+ # watchdog on the first poll silently, without a log line.
144
146
  last_mtime="$(stat -c %Y "$log_path" 2>/dev/null || true)"
145
147
  [[ "$last_mtime" =~ ^[0-9]+$ ]] || last_mtime="$(stat -f %m "$log_path" 2>/dev/null || true)"
146
148
  [[ "$last_mtime" =~ ^[0-9]+$ ]] || last_mtime=0
@@ -421,15 +421,17 @@ if (( idle_timeout_secs > 0 )); then
421
421
  while kill -0 "$codex_pid" 2>/dev/null; do
422
422
  sleep "$poll_interval"
423
423
  kill -0 "$codex_pid" 2>/dev/null || exit 0
424
- # GNU `stat -c` 우선 + 숫자 검증. BSD-ism `stat -f %m` 은 Linux 에서 깨끗이
425
- # 실패하지 않고 비숫자 파일시스템 출력으로 성공해 아래 산술을 set -u 에서
426
- # 깨뜨린다file_mtime(lib/okstra/interactive.sh) 동일 가드.
424
+ # Portable mtime probe: GNU (stat -c %Y) first, macOS/BSD (stat -f %m) as
425
+ # the fallback, 0 as the floor. Validate each result as a bare integer
426
+ # before accepting it on Linux the BSD-ism `stat -f %m` does NOT fail
427
+ # cleanly; it succeeds and prints non-numeric filesystem output, which
428
+ # would break the arithmetic below under `set -u`.
427
429
  #
428
- # `|| true` 필수다. macOS 에는 GNU `stat -c` 없어 exit 1 이고,
429
- # `2>/dev/null` stderr 막지 종료코드는 막지 못한다. 워치독은 명시적
430
- # `( ) &` 서브셸이라 위쪽 `set -e` 상속하므로, 실패한 할당 하나가 첫 폴링에서
431
- # 워치독을 통째로 죽인다 조용히, 로그 없이. file_mtime 같은 코드로도
432
- # 멀쩡한 것은 항상 `$( )` 안에서 호출돼 errexit 상속하지 않기 때문이다.
430
+ # `|| true` is mandatory. macOS has no GNU `stat -c`, so that call exits 1,
431
+ # and `2>/dev/null` suppresses stderr but not the exit code. This watchdog
432
+ # is an explicit `( ) &` subshell, so it inherits whatever errexit setting
433
+ # is in force; under `set -e` a single failed assignment kills the whole
434
+ # watchdog on the first poll silently, without a log line.
433
435
  last_mtime="$(stat -c %Y "$log_path" 2>/dev/null || true)"
434
436
  [[ "$last_mtime" =~ ^[0-9]+$ ]] || last_mtime="$(stat -f %m "$log_path" 2>/dev/null || true)"
435
437
  [[ "$last_mtime" =~ ^[0-9]+$ ]] || last_mtime=0
@@ -10,6 +10,7 @@ from typing import List
10
10
  from okstra_project.dirs import tasks_root
11
11
 
12
12
  from .ids import build_run_id
13
+ from .paths import runs_dir_of
13
14
  from .invocation import save_invocation
14
15
  from .jsonl import append_jsonl, read_jsonl, rotate_recent_if_needed
15
16
  from .project_meta import _project_meta_path
@@ -130,7 +131,7 @@ def backfill_project(home: Path, project_id: str, project_root: Path) -> int:
130
131
  manifest_re = _re.compile(r"^run-manifest-(?P<tt>.+)-(?P<seq>\d+)\.json$")
131
132
  for group_dir in sorted(p for p in base.iterdir() if p.is_dir()):
132
133
  for task_dir in sorted(p for p in group_dir.iterdir() if p.is_dir()):
133
- runs = task_dir / "runs"
134
+ runs = runs_dir_of(task_dir)
134
135
  if not runs.is_dir():
135
136
  continue
136
137
  for manifests in _iter_manifest_dirs(runs):
@@ -13,6 +13,7 @@ import sys
13
13
  from pathlib import Path
14
14
  from typing import Iterable
15
15
 
16
+ from okstra_ctl.paths import RunRef, runs_dir_of
16
17
  from okstra_ctl.task_target import resolve_task_root, project_rel
17
18
 
18
19
 
@@ -102,7 +103,7 @@ def _find_current_run_dir(
102
103
  task_type = manifest.get("workflow", {}).get("currentPhase") or manifest.get("taskType")
103
104
  if not isinstance(task_type, str) or not task_type:
104
105
  return None
105
- candidate = task_root / "runs" / task_type
106
+ candidate = RunRef.from_task_root(task_root, task_type).run_dir
106
107
  return candidate if candidate.is_dir() else None
107
108
 
108
109
 
@@ -407,7 +408,7 @@ def analyze_task_bundle(task_root: Path, project_root: Path) -> dict:
407
408
  task_file_count, task_bytes = _count_files(all_task_files)
408
409
  current_run_file_count, current_run_bytes = _count_files(_all_files(run_dir)) if run_dir else (0, 0)
409
410
  legacy_timestamp_files = [
410
- path for path in (task_root / "runs").rglob("*")
411
+ path for path in runs_dir_of(task_root).rglob("*")
411
412
  if path.is_file() and _is_timestamped_legacy_artifact(path)
412
413
  ]
413
414
 
@@ -8,9 +8,11 @@ from __future__ import annotations
8
8
  import json
9
9
  from pathlib import Path
10
10
 
11
+ from .paths import runs_dir_of
12
+
11
13
 
12
14
  def glob_error_logs(task_root: Path) -> list[Path]:
13
- runs = task_root / "runs"
15
+ runs = runs_dir_of(task_root)
14
16
  if not runs.exists():
15
17
  return []
16
18
  flat = runs.glob("*/logs/errors-*.jsonl")
@@ -14,6 +14,7 @@ from typing import Any, Callable, Dict, List, Optional, Tuple
14
14
 
15
15
  from . import consumers, stage_targets, worktree_registry
16
16
  from .final_report_paths import final_report_markdown_path
17
+ from .paths import RunRef
17
18
  from .worktree import (compute_branch_name, compute_worktree_path,
18
19
  main_worktree_path, is_dirty_excluding_okstra,
19
20
  nested_worktree_excludes, is_ancestor, merge_branch,
@@ -62,7 +63,7 @@ def latest_whole_task_fv_accepted(project_root, project_id: str,
62
63
  f"{project_id}:{task_group}:{task_id}")
63
64
  if root is None:
64
65
  return ""
65
- reports_dir = root / "runs" / "final-verification" / "reports"
66
+ reports_dir = RunRef.from_task_root(root, "final-verification").reports_dir
66
67
  for dj in sorted(reports_dir.glob("final-report-*.data.json"),
67
68
  reverse=True):
68
69
  try:
@@ -7,6 +7,7 @@ from pathlib import Path
7
7
  from typing import Any
8
8
 
9
9
  from .consumers import read_stage_consumer_state
10
+ from .paths import RunRef
10
11
  from .workflow import DEFAULT_NEXT_PHASE, PHASE_SEQUENCE
11
12
 
12
13
 
@@ -27,7 +28,7 @@ def derive_implementation_outcome(task_root: Path) -> ImplementationOutcome:
27
28
  if workflow.get("currentPhase") != "implementation":
28
29
  return ImplementationOutcome(completed=False, reason="current phase is not implementation")
29
30
 
30
- plan_run_root = task_root / "runs" / "implementation-planning"
31
+ plan_run_root = RunRef.from_task_root(task_root, "implementation-planning").run_dir
31
32
  if not plan_run_root.is_dir():
32
33
  return ImplementationOutcome(completed=False, reason="implementation-planning run root missing")
33
34
 
@@ -133,7 +134,7 @@ def _load_stage_map(task_root: Path, manifest: dict[str, Any]) -> list[dict[str,
133
134
 
134
135
 
135
136
  def _source_plan_path(task_root: Path, manifest: dict[str, Any]) -> Path:
136
- carry_dir = task_root / "runs" / "implementation" / "carry"
137
+ carry_dir = RunRef.from_task_root(task_root, "implementation").carry_dir
137
138
  for carry_path in sorted(carry_dir.glob("stage-*.json")):
138
139
  value = _load_json(carry_path).get("sourcePlanPath")
139
140
  if isinstance(value, str) and value:
@@ -143,7 +144,7 @@ def _source_plan_path(task_root: Path, manifest: dict[str, Any]) -> Path:
143
144
  if isinstance(latest, str) and "implementation-planning" in latest:
144
145
  return _resolve_relative(task_root, latest)
145
146
 
146
- reports = task_root / "runs" / "implementation-planning" / "reports"
147
+ reports = RunRef.from_task_root(task_root, "implementation-planning").reports_dir
147
148
  candidates = sorted(reports.glob("final-report-implementation-planning-*.md"))
148
149
  return candidates[-1] if candidates else Path()
149
150
 
@@ -170,7 +171,7 @@ def _project_root_from_task_root(task_root: Path) -> Path:
170
171
 
171
172
 
172
173
  def _load_carry(task_root: Path, stage: int) -> dict[str, Any] | None:
173
- data = _load_json(task_root / "runs" / "implementation" / "carry" / f"stage-{stage}.json")
174
+ data = _load_json(RunRef.from_task_root(task_root, "implementation").carry(stage))
174
175
  return data or None
175
176
 
176
177
 
@@ -205,7 +206,7 @@ def _carry_head_commit(carry: dict[str, Any]) -> str:
205
206
 
206
207
  def _latest_implementation_report(task_root: Path) -> str:
207
208
  reports = sorted(
208
- (task_root / "runs" / "implementation").glob(
209
+ RunRef.from_task_root(task_root, "implementation").run_dir.glob(
209
210
  "stage-*/reports/final-report-implementation-*.md"
210
211
  )
211
212
  )
@@ -12,6 +12,8 @@ from okstra_project.dirs import (
12
12
  okstra_home,
13
13
  )
14
14
 
15
+ from .paths import runs_dir_of
16
+
15
17
  RUN_CONTEXT_KIND = "run-context"
16
18
  RUN_CONTEXT_SCHEMA_VERSION = "2.0"
17
19
  ACTIVE_CONTEXT_KIND = "active-run-context"
@@ -359,7 +361,7 @@ def _task_path_set(task_root: Path) -> dict[str, Path]:
359
361
  "instruction_set": instruction_set,
360
362
  "analysis_packet": instruction_set / "analysis-packet.md",
361
363
  "task_qa": task_root / "qa",
362
- "runs_dir": task_root / "runs",
364
+ "runs_dir": runs_dir_of(task_root),
363
365
  "history_dir": history_dir,
364
366
  "timeline_file": history_dir / "timeline.json",
365
367
  "recap_dir": recap_dir,
@@ -15,6 +15,7 @@ from __future__ import annotations
15
15
 
16
16
  import os
17
17
  import re
18
+ from dataclasses import dataclass, replace
18
19
  from pathlib import Path
19
20
  from typing import Optional
20
21
 
@@ -30,6 +31,8 @@ __all__ = [
30
31
  "OKSTRA_RELATIVE",
31
32
  "TASKS_RELATIVE",
32
33
  "DISCOVERY_RELATIVE",
34
+ "RunRef",
35
+ "runs_dir_of",
33
36
  "compute_run_paths",
34
37
  "next_run_seq",
35
38
  "resolve_under_root",
@@ -40,6 +43,254 @@ __all__ = [
40
43
  ]
41
44
 
42
45
 
46
+ _STAGED_TASK_TYPES = ("implementation", "final-verification")
47
+ # 발견용: bash `find -name 'final-report-*.md'` 와 같은 범위 — seq 이전 세대의
48
+ # 타임스탬프 파일명도 잡아야 옛 번들에서 조용히 실패하지 않는다.
49
+ _REPORT_NAME_RE = re.compile(r"^final-report-.+\.md$")
50
+ # seq 추출용: 정본 이름 `final-report-<task-type>-<NNN>.md` 일 때만.
51
+ _REPORT_SEQ_RE = re.compile(r"^final-report-.+-(?P<seq>\d{3,})\.md$")
52
+ _TASKS_SEGMENTS = Path(TASKS_RELATIVE).parts
53
+
54
+
55
+ def _newest_report(reports_dir: Path) -> Optional[Path]:
56
+ """reports 디렉터리에서 mtime 최신 final-report. 동률이면 basename 큰 쪽."""
57
+ if not reports_dir.is_dir():
58
+ return None
59
+ found = [
60
+ entry for entry in reports_dir.iterdir()
61
+ if entry.is_file() and _REPORT_NAME_RE.match(entry.name)
62
+ ]
63
+ if not found:
64
+ return None
65
+ return max(found, key=lambda p: (p.stat().st_mtime, p.name))
66
+
67
+
68
+ def _project_root_of(task_root: Path) -> Optional[Path]:
69
+ """canonical `<project>/.okstra/tasks/<g>/<t>` 일 때만 project root.
70
+
71
+ 검증기는 이 형태가 아닌 트리(테스트 픽스처, 이식된 번들)에도 돌아간다.
72
+ 그때는 project root 를 알 수 없으므로 지어내지 않고 None 을 돌려준다 —
73
+ 경로 유래 ref 는 task_root 를 고정하므로 계산에 필요하지도 않다.
74
+ """
75
+ parts = task_root.parts
76
+ if len(parts) > len(_TASKS_SEGMENTS) + 2 and parts[-4:-2] == _TASKS_SEGMENTS:
77
+ return Path(*parts[:-4])
78
+ return None
79
+
80
+
81
+ @dataclass(frozen=True)
82
+ class RunRef:
83
+ """하나의 run 을 identity 로 지명하는 read-side 참조.
84
+
85
+ 산출물 경로를 종류별로 계산해 돌려주되 내용은 읽지 않는다 — 파싱은
86
+ consumers / stage_targets / run_context 같은 기존 로더가 계속 소유한다.
87
+
88
+ stage 세그먼트는 아티팩트 종류마다 다르게 적용된다. run 디렉터리는
89
+ stage-isolated 이지만 carry 사이드카는 flat 이므로(stage N+1 이 N 의 사이드카를
90
+ N 의 run 레이아웃을 모른 채 찾아야 한다), 그 예외는 `carry()` 안에만 산다.
91
+ 호출자가 `run_dir` 에서 손으로 올라가면 안 된다.
92
+
93
+ `compute_run_paths()` 가 같은 레이아웃을 write-side 에서 계산한다. 둘의 합의는
94
+ tests/contract/test_task_path_ssot.py 가 잠근다.
95
+ """
96
+
97
+ project_root: Optional[Path]
98
+ task_group: str
99
+ task_id: str
100
+ task_type: str
101
+ seq: Optional[int] = None
102
+ stage: Optional[int] = None
103
+ # 경로에서 만든 ref 는 받은 task_root 를 고정한다. identity 로 재계산하면
104
+ # canonical 하지 않은 트리에서 조용히 다른 곳을 가리킨다.
105
+ task_root_override: Optional[Path] = None
106
+ # 경로에서 만든 ref 는 찾은 파일을 그대로 고정한다 — 레거시 타임스탬프
107
+ # 이름은 identity 로 재구성할 수 없다.
108
+ report_override: Optional[Path] = None
109
+
110
+ @property
111
+ def task_root(self) -> Path:
112
+ if self.task_root_override is not None:
113
+ return self.task_root_override
114
+ if self.project_root is None:
115
+ raise ValueError("RunRef has neither a project_root nor a task_root")
116
+ return task_dir(self.project_root, self.task_group, self.task_id)
117
+
118
+ @property
119
+ def runs_dir(self) -> Path:
120
+ return runs_dir_of(self.task_root)
121
+
122
+ @property
123
+ def run_dir(self) -> Path:
124
+ segment = slugify(self.task_type)
125
+ run_dir = self.runs_dir / segment
126
+ if segment in _STAGED_TASK_TYPES and self.stage is not None:
127
+ return run_dir / f"stage-{int(self.stage)}"
128
+ return run_dir
129
+
130
+ @property
131
+ def reports_dir(self) -> Path:
132
+ return self.run_dir / "reports"
133
+
134
+ @property
135
+ def carry_dir(self) -> Path:
136
+ """implementation 의 carry 는 stage-SHARED 라 flat 이다(§carry 참고)."""
137
+ segment = slugify(self.task_type)
138
+ if segment == "implementation":
139
+ return self.runs_dir / segment / "carry"
140
+ return self.run_dir / "carry"
141
+
142
+ @property
143
+ def report(self) -> Path:
144
+ if self.report_override is not None:
145
+ return self.report_override
146
+ return self.reports_dir / f"final-report{self._suffix}.md"
147
+
148
+ @property
149
+ def manifest(self) -> Path:
150
+ return self.run_dir / "manifests" / f"run-manifest{self._suffix}.json"
151
+
152
+ def carry(self, stage: int) -> Path:
153
+ """stage 의 carry 사이드카. implementation 은 stage-SHARED(flat)다."""
154
+ return self.carry_dir / f"stage-{int(stage)}.json"
155
+
156
+ @property
157
+ def _suffix(self) -> str:
158
+ if self.seq is None:
159
+ raise ValueError(
160
+ "RunRef.seq is unset — a seq-less ref can name the run directory "
161
+ "but not an artifact file. Use RunRef.latest() or pass seq."
162
+ )
163
+ return f"-{slugify(self.task_type)}-{int(self.seq):03d}"
164
+
165
+ @classmethod
166
+ def from_task_root(
167
+ cls,
168
+ task_root: Path,
169
+ task_type: str,
170
+ *,
171
+ seq: Optional[int] = None,
172
+ stage: Optional[int] = None,
173
+ ) -> "RunRef":
174
+ """task_root 경로만 쥔 호출자를 위한 어댑터.
175
+
176
+ 받은 task_root 를 고정하므로 canonical 하지 않은 트리에서도 왕복한다.
177
+ 복원되는 group/id 는 slug 세그먼트다.
178
+ """
179
+ task_root = Path(task_root)
180
+ return cls(
181
+ project_root=_project_root_of(task_root),
182
+ task_group=task_root.parent.name,
183
+ task_id=task_root.name,
184
+ task_type=task_type,
185
+ seq=seq,
186
+ stage=stage,
187
+ task_root_override=task_root,
188
+ )
189
+
190
+ @classmethod
191
+ def from_run_dir(cls, run_dir: Path, *, seq: Optional[int] = None) -> "RunRef":
192
+ """run 디렉터리에서 정체를 복원한다.
193
+
194
+ `<task_root>/runs/<task-type>[/stage-<N>]` 를 해석한다. `runs` 앵커가
195
+ 없으면 ValueError — 관대한 폴백이 필요한 호출자는 직접 감싸라.
196
+ """
197
+ run_dir = Path(run_dir)
198
+ stage = None
199
+ if run_dir.name.startswith("stage-"):
200
+ stage = int(run_dir.name[len("stage-"):])
201
+ run_dir = run_dir.parent
202
+ if run_dir.parent.name != "runs":
203
+ raise ValueError(f"not an okstra run directory: {run_dir}")
204
+
205
+ return cls.from_task_root(
206
+ run_dir.parent.parent, run_dir.name, seq=seq, stage=stage
207
+ )
208
+
209
+ @classmethod
210
+ def from_report_path(cls, report_path: Path) -> "RunRef":
211
+ """final-report 경로에서 정체를 복원한다.
212
+
213
+ 복원되는 task-group/task-id 는 slug 세그먼트다. slugify 가 멱등이라
214
+ 경로 왕복에는 영향이 없지만, 원래 표기가 필요하면 task-manifest 를 읽어라.
215
+ """
216
+ report_path = Path(report_path)
217
+ if not _REPORT_NAME_RE.match(report_path.name):
218
+ raise ValueError(f"not a final-report path: {report_path}")
219
+ matched = _REPORT_SEQ_RE.match(report_path.name)
220
+ ref = cls.from_run_dir(
221
+ report_path.parent.parent,
222
+ seq=int(matched.group("seq")) if matched else None,
223
+ )
224
+ return replace(ref, report_override=report_path)
225
+
226
+ def sibling(self, task_type: str) -> "RunRef":
227
+ """같은 task 의 다른 task-type 을 가리키는 ref.
228
+
229
+ seq/stage 는 이 run 의 것이므로 형제에게 물려주지 않는다.
230
+ """
231
+ return replace(
232
+ self, task_type=task_type, seq=None, stage=None, report_override=None
233
+ )
234
+
235
+ @classmethod
236
+ def latest(
237
+ cls, project_root: Path, task_group: str, task_id: str, task_type: str
238
+ ) -> Optional["RunRef"]:
239
+ """task-type 의 최신 final-report 를 가리키는 ref. 없으면 None.
240
+
241
+ 최신 기준은 mtime 이고, 동률이면 basename 이 큰 쪽이다 — bash
242
+ `find_latest_final_report` 와 wizard 가 쓰던 규칙을 그대로 옮긴 것이다.
243
+ stage 하위 디렉터리는 훑지 않는다(옮겨온 두 구현과 동일 범위).
244
+ """
245
+ ref = cls(
246
+ project_root=Path(project_root), task_group=task_group,
247
+ task_id=task_id, task_type=task_type,
248
+ )
249
+ best = _newest_report(ref.reports_dir)
250
+ return None if best is None else cls.from_report_path(best)
251
+
252
+ @classmethod
253
+ def latest_across(
254
+ cls,
255
+ project_root: Path,
256
+ task_group: str,
257
+ task_id: str,
258
+ task_types: Optional[tuple[str, ...]] = None,
259
+ ) -> Optional["RunRef"]:
260
+ """여러 task-type 을 가로질러 최신 final-report 를 가리키는 ref.
261
+
262
+ `task_types` 를 주지 않으면 runs/ 에 실제로 존재하는 모든 task-type 을
263
+ 훑는다. 비교 규칙은 `latest` 와 같다(mtime, 동률이면 basename).
264
+ """
265
+ return cls.latest_under(
266
+ task_dir(project_root, task_group, task_id), task_types
267
+ )
268
+
269
+ @classmethod
270
+ def latest_under(
271
+ cls, task_root: Path, task_types: Optional[tuple[str, ...]] = None
272
+ ) -> Optional["RunRef"]:
273
+ """`latest_across` 의 task_root 진입점.
274
+
275
+ task-key 단축키/오타 진단을 이미 거쳐 task_root 를 손에 쥔 호출자
276
+ (bash resume-clarification)가 쓴다.
277
+ """
278
+ runs_dir = runs_dir_of(task_root)
279
+ if task_types is None:
280
+ if not runs_dir.is_dir():
281
+ return None
282
+ task_types = tuple(sorted(p.name for p in runs_dir.iterdir() if p.is_dir()))
283
+ candidates = [
284
+ found for task_type in task_types
285
+ if (found := _newest_report(runs_dir / task_type / "reports")) is not None
286
+ ]
287
+ if not candidates:
288
+ return None
289
+ return cls.from_report_path(
290
+ max(candidates, key=lambda p: (p.stat().st_mtime, p.name))
291
+ )
292
+
293
+
43
294
  def task_dir(project_root: Path, task_group: str, task_id: str) -> Path:
44
295
  """task root 경로: ``<project>/.okstra/tasks/<group-seg>/<id-seg>``.
45
296
 
@@ -51,7 +302,15 @@ def task_dir(project_root: Path, task_group: str, task_id: str) -> Path:
51
302
 
52
303
  def task_runs_dir(project_root: Path, task_group: str, task_id: str) -> Path:
53
304
  """task 의 runs 디렉터리: ``task_dir/runs``."""
54
- return task_dir(project_root, task_group, task_id) / "runs"
305
+ return runs_dir_of(task_dir(project_root, task_group, task_id))
306
+
307
+
308
+ def runs_dir_of(task_root: Path) -> Path:
309
+ """task_root 만 쥔 호출자를 위한 runs 디렉터리 접근자.
310
+
311
+ task-type 을 모르는 스캐너(backfill·error-log glob·context-cost)가 쓴다.
312
+ `runs` 세그먼트 리터럴은 이 함수와 `compute_run_paths` 에만 존재한다."""
313
+ return Path(task_root) / "runs"
55
314
 
56
315
 
57
316
  def container_paths(project_root: Path, task_group: str, task_id: str) -> dict:
@@ -161,7 +420,7 @@ def compute_run_paths(
161
420
  instruction_set = task_root / "instruction-set"
162
421
  analysis_packet = instruction_set / "analysis-packet.md"
163
422
  task_qa = task_root / "qa"
164
- runs_dir = task_root / "runs"
423
+ runs_dir = runs_dir_of(task_root)
165
424
  history_dir = task_root / "history"
166
425
  timeline_file = history_dir / "timeline.json"
167
426
  recap_dir = task_root / "recap"
@@ -48,10 +48,10 @@ def resolve_plan_run_root_by_task_key(
48
48
  from . import paths
49
49
  from .consumers import read_consumers
50
50
 
51
- reports_dir = (
52
- paths.task_dir(project_root, task_group, task_id)
53
- / "runs" / "implementation-planning" / "reports"
54
- )
51
+ reports_dir = paths.RunRef(
52
+ project_root=project_root, task_group=task_group, task_id=task_id,
53
+ task_type="implementation-planning",
54
+ ).reports_dir
55
55
  if not reports_dir.is_dir():
56
56
  raise PrepareError(
57
57
  "container up: implementation-planning run 을 찾을 수 없습니다 "
@@ -84,7 +84,7 @@ from okstra_ctl.worktree import (
84
84
  preview_stage_worktree_decision,
85
85
  preview_worktree_decision,
86
86
  )
87
- from okstra_ctl.paths import task_dir, task_runs_dir
87
+ from okstra_ctl.paths import RunRef, task_dir, task_runs_dir
88
88
  from okstra_ctl.work_categories import resolve_work_category
89
89
  from okstra_ctl.run_context import latest_run_inputs
90
90
  from okstra_project.dirs import project_json_path
@@ -1794,8 +1794,10 @@ def _list_implementation_planning_reports(
1794
1794
  # Run seq lives in the filename, not a per-run subdirectory: every
1795
1795
  # implementation-planning run writes into the same flat `reports/`
1796
1796
  # dir (see paths.py — `run_reports = runs/<task-type>/reports`).
1797
- reports_dir = (task_runs_dir(state.project_root, state.task_group, state.task_id)
1798
- / "implementation-planning" / "reports")
1797
+ reports_dir = RunRef(
1798
+ project_root=state.project_root, task_group=state.task_group,
1799
+ task_id=state.task_id, task_type="implementation-planning",
1800
+ ).reports_dir
1799
1801
  if not reports_dir.is_dir():
1800
1802
  return []
1801
1803
  pat = re.compile(r"^final-report-implementation-planning-(\d+)\.md$")
@@ -43,6 +43,7 @@ from okstra_ctl.conformance import ( # noqa: E402
43
43
  qa_result_from_dict,
44
44
  validate_conformance_manifest,
45
45
  )
46
+ from okstra_ctl.paths import RunRef # noqa: E402
46
47
  from okstra_ctl.md_table import ( # noqa: E402
47
48
  is_separator_row as _is_markdown_separator,
48
49
  split_pipe_row as _split_pipe_row,
@@ -1143,17 +1144,15 @@ def _scope_manifest_entries(manifest: dict, stage_name: str | None) -> dict:
1143
1144
 
1144
1145
 
1145
1146
  def _task_root_from_run_dir(run_dir: Path) -> Path:
1146
- """run_dir 에서 `runs` 디렉터리를 앵커로 task_root 를 복원한다.
1147
+ """run_dir 에서 task_root 를 복원한다.
1147
1148
 
1148
- 일반 task-type: run_dir = task_root/runs/<task-type> task_root.
1149
- implementation(stage 격리): run_dir = task_root/runs/implementation/stage-<N>
1150
- → 같은 task_root. `runs` 를 위로 탐색하므로 stage-<N> 레벨이 추가돼도
1151
- 안전하다. `runs` 가 조상에 없으면 기존 동작(두 단계 위)로 폴백한다.
1149
+ 레이아웃 해석은 `RunRef.from_run_dir` 소유한다. run 디렉터리가 아닌
1150
+ 입력에는 기존 동작( 단계 위)으로 폴백해 검증기가 죽지 않게 한다.
1152
1151
  """
1153
- for parent in run_dir.parents:
1154
- if parent.name == "runs":
1155
- return parent.parent
1156
- return run_dir.parent.parent
1152
+ try:
1153
+ return RunRef.from_run_dir(run_dir).task_root
1154
+ except ValueError:
1155
+ return run_dir.parent.parent
1157
1156
 
1158
1157
 
1159
1158
  def _read_run_inputs_payload(
@@ -3129,19 +3128,15 @@ _PASSING_VERDICT_TOKENS = frozenset({"accepted", "conditional-accept"})
3129
3128
  def _consumers_rows(report_path: Path) -> list[dict] | None:
3130
3129
  """`runs/implementation-planning/consumers.jsonl` rows for this task.
3131
3130
 
3132
- The final-verification report sits at `runs/final-verification/[stage-N/]
3133
- reports/...`, so walk up to the `runs/` directory rather than guessing a
3134
- fixed depth. ``None`` when the file is absent, so the caller does not turn
3135
- a missing artifact into a false accusation.
3131
+ ``None`` when the report path is unreadable as a run reference or the file
3132
+ is absent, so the caller does not turn a missing artifact into a false
3133
+ accusation.
3136
3134
  """
3137
- runs_dir = None
3138
- for parent in report_path.parents:
3139
- if parent.name == "runs":
3140
- runs_dir = parent
3141
- break
3142
- if runs_dir is None:
3135
+ try:
3136
+ ref = RunRef.from_report_path(report_path)
3137
+ except ValueError:
3143
3138
  return None
3144
- path = runs_dir / "implementation-planning" / "consumers.jsonl"
3139
+ path = ref.sibling("implementation-planning").run_dir / "consumers.jsonl"
3145
3140
  if not path.is_file():
3146
3141
  return None
3147
3142
  rows = []
@@ -3497,17 +3492,11 @@ def _validate_stage_carry_sidecar_exists(
3497
3492
  stage = evidence.get("stageNumber")
3498
3493
  if not isinstance(stage, int):
3499
3494
  return
3500
- # Carry sidecars are stage-SHARED: they live flat at
3501
- # `runs/implementation/carry/stage-<N>.json`, NOT under the stage-<N>/ run
3502
- # dir, because the next stage's carry-in and `consumers.backfill_done_from_carry`
3503
- # glob them without knowing the producing run's layout (storage-model.md
3504
- # §"representative files"). `report_path` is
3505
- # `runs/implementation/stage-<N>/reports/final-report-...md`, so drop the
3506
- # stage-<N>/ segment to reach the flat carry dir that `consumers` reads —
3507
- # resolving it under the stage run dir forced the lead to write the file twice.
3508
- run_dir = report_path.parent.parent
3509
- carry_root = run_dir.parent if _stage_isolated_name(run_dir) else run_dir
3510
- carry_path = carry_root / "carry" / f"stage-{stage}.json"
3495
+ # Carry sidecars are stage-SHARED: the next stage's carry-in and
3496
+ # `consumers.backfill_done_from_carry` glob them without knowing the
3497
+ # producing run's layout. `RunRef.carry()` owns that flat-vs-staged rule;
3498
+ # resolving it under the stage run dir forced the lead to write it twice.
3499
+ carry_path = RunRef.from_report_path(report_path).carry(stage)
3511
3500
  if not carry_path.exists():
3512
3501
  failures.append(
3513
3502
  f"implementation run declares stage-{stage} sidecar evidence but "