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 +1 -1
- package/runtime/BUILD.json +2 -2
- package/runtime/bin/lib/okstra/interactive.sh +24 -67
- package/runtime/bin/okstra-antigravity-exec.sh +10 -8
- package/runtime/bin/okstra-claude-exec.sh +10 -8
- package/runtime/bin/okstra-codex-exec.sh +10 -8
- package/runtime/python/okstra_ctl/backfill.py +2 -1
- package/runtime/python/okstra_ctl/context_cost.py +3 -2
- package/runtime/python/okstra_ctl/error_log_core.py +3 -1
- package/runtime/python/okstra_ctl/handoff.py +2 -1
- package/runtime/python/okstra_ctl/implementation_outcome.py +6 -5
- package/runtime/python/okstra_ctl/path_hints.py +3 -1
- package/runtime/python/okstra_ctl/paths.py +261 -2
- package/runtime/python/okstra_ctl/plan_run_root.py +4 -4
- package/runtime/python/okstra_ctl/wizard.py +5 -3
- package/runtime/validators/validate-run.py +20 -31
package/package.json
CHANGED
package/runtime/BUILD.json
CHANGED
|
@@ -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
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
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 "$
|
|
356
|
-
printf 'resume-clarification: no final-report-*.md found for task %s:%s under %s\n' \
|
|
357
|
-
"$task_group" "$task_id" "$
|
|
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
|
|
294
|
-
#
|
|
295
|
-
#
|
|
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`
|
|
298
|
-
# `2>/dev/null`
|
|
299
|
-
# `( ) &`
|
|
300
|
-
#
|
|
301
|
-
#
|
|
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
|
|
136
|
-
#
|
|
137
|
-
#
|
|
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`
|
|
140
|
-
# `2>/dev/null`
|
|
141
|
-
# `( ) &`
|
|
142
|
-
#
|
|
143
|
-
#
|
|
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
|
|
425
|
-
#
|
|
426
|
-
#
|
|
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`
|
|
429
|
-
# `2>/dev/null`
|
|
430
|
-
# `( ) &`
|
|
431
|
-
#
|
|
432
|
-
#
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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)
|
|
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
|
|
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
|
-
|
|
53
|
-
|
|
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 = (
|
|
1798
|
-
|
|
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 에서
|
|
1147
|
+
"""run_dir 에서 task_root 를 복원한다.
|
|
1147
1148
|
|
|
1148
|
-
|
|
1149
|
-
|
|
1150
|
-
→ 같은 task_root. `runs` 를 위로 탐색하므로 stage-<N> 레벨이 추가돼도
|
|
1151
|
-
안전하다. `runs` 가 조상에 없으면 기존 동작(두 단계 위)로 폴백한다.
|
|
1149
|
+
레이아웃 해석은 `RunRef.from_run_dir` 이 소유한다. run 디렉터리가 아닌
|
|
1150
|
+
입력에는 기존 동작(두 단계 위)으로 폴백해 검증기가 죽지 않게 한다.
|
|
1152
1151
|
"""
|
|
1153
|
-
|
|
1154
|
-
|
|
1155
|
-
|
|
1156
|
-
|
|
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
|
-
|
|
3133
|
-
|
|
3134
|
-
|
|
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
|
-
|
|
3138
|
-
|
|
3139
|
-
|
|
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 =
|
|
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:
|
|
3501
|
-
# `
|
|
3502
|
-
#
|
|
3503
|
-
#
|
|
3504
|
-
|
|
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 "
|