okstra 0.135.0 → 0.137.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/docs/architecture/storage-model.md +1 -1
- package/docs/contributor-change-matrix.md +1 -1
- package/docs/project-structure-overview.md +8 -3
- package/package.json +2 -3
- package/runtime/BUILD.json +2 -2
- package/runtime/bin/lib/okstra/interactive.sh +24 -67
- package/runtime/bin/lib/okstra-ctl/cmd-rerun.sh +1 -1
- 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/bin/okstra-spawn-followups.py +4 -9
- package/runtime/prompts/lead/plan-body-verification.md +2 -2
- package/runtime/python/okstra_ctl/backfill.py +2 -1
- package/runtime/python/okstra_ctl/codex_dispatch.py +56 -331
- package/runtime/python/okstra_ctl/consumers.py +7 -5
- package/runtime/python/okstra_ctl/context_cost.py +6 -5
- package/runtime/python/okstra_ctl/dispatch_core.py +54 -175
- package/runtime/python/okstra_ctl/dispatch_state.py +231 -0
- package/runtime/python/okstra_ctl/error_log_core.py +3 -1
- package/runtime/python/okstra_ctl/error_report.py +2 -7
- package/runtime/python/okstra_ctl/handoff.py +2 -1
- package/runtime/python/okstra_ctl/implementation_outcome.py +17 -27
- package/runtime/python/okstra_ctl/implementation_stage.py +7 -2
- package/runtime/python/okstra_ctl/path_hints.py +5 -3
- package/runtime/python/okstra_ctl/paths.py +278 -4
- package/runtime/python/okstra_ctl/plan_run_root.py +4 -4
- package/runtime/python/okstra_ctl/recap.py +5 -12
- package/runtime/python/okstra_ctl/render.py +9 -15
- package/runtime/python/okstra_ctl/report_finalize.py +8 -4
- package/runtime/python/okstra_ctl/report_language.py +83 -0
- package/runtime/python/okstra_ctl/run.py +222 -239
- package/runtime/python/okstra_ctl/set_work_status.py +2 -1
- package/runtime/python/okstra_ctl/stage_targets.py +98 -55
- package/runtime/python/okstra_ctl/time_report.py +4 -9
- package/runtime/python/okstra_ctl/wizard.py +50 -50
- package/runtime/python/okstra_ctl/work_categories.py +2 -2
- package/runtime/python/okstra_ctl/worker_prompt_body.py +82 -0
- package/runtime/python/okstra_ctl/worker_prompt_contract.py +0 -13
- package/runtime/python/okstra_ctl/worker_prompt_headers.py +7 -0
- package/runtime/python/okstra_project/__init__.py +4 -0
- package/runtime/python/okstra_project/dirs.py +7 -0
- package/runtime/python/okstra_project/state.py +78 -8
- package/runtime/validators/validate-run.py +23 -52
- package/src/commands/inspect/stage-map.mjs +12 -19
|
@@ -6,7 +6,12 @@ from dataclasses import dataclass, field
|
|
|
6
6
|
from pathlib import Path
|
|
7
7
|
from typing import Any
|
|
8
8
|
|
|
9
|
-
from .consumers import
|
|
9
|
+
from .consumers import (
|
|
10
|
+
FAILED_CARRY_STATUSES,
|
|
11
|
+
carry_head_commit,
|
|
12
|
+
read_stage_consumer_state,
|
|
13
|
+
)
|
|
14
|
+
from .paths import RunRef, task_manifest_file
|
|
10
15
|
from .workflow import DEFAULT_NEXT_PHASE, PHASE_SEQUENCE
|
|
11
16
|
|
|
12
17
|
|
|
@@ -22,16 +27,16 @@ class ImplementationOutcome:
|
|
|
22
27
|
|
|
23
28
|
def derive_implementation_outcome(task_root: Path) -> ImplementationOutcome:
|
|
24
29
|
task_root = Path(task_root)
|
|
25
|
-
manifest = _load_json(task_root
|
|
30
|
+
manifest = _load_json(task_manifest_file(task_root))
|
|
26
31
|
workflow = manifest.get("workflow") if isinstance(manifest.get("workflow"), dict) else {}
|
|
27
32
|
if workflow.get("currentPhase") != "implementation":
|
|
28
33
|
return ImplementationOutcome(completed=False, reason="current phase is not implementation")
|
|
29
34
|
|
|
30
|
-
plan_run_root = task_root
|
|
35
|
+
plan_run_root = RunRef.from_task_root(task_root, "implementation-planning").run_dir
|
|
31
36
|
if not plan_run_root.is_dir():
|
|
32
37
|
return ImplementationOutcome(completed=False, reason="implementation-planning run root missing")
|
|
33
38
|
|
|
34
|
-
stage_map =
|
|
39
|
+
stage_map = load_stage_map(task_root, manifest)
|
|
35
40
|
if not stage_map:
|
|
36
41
|
return ImplementationOutcome(completed=False, reason="stage map unavailable")
|
|
37
42
|
|
|
@@ -52,7 +57,7 @@ def derive_implementation_outcome(task_root: Path) -> ImplementationOutcome:
|
|
|
52
57
|
return ImplementationOutcome(
|
|
53
58
|
completed=True,
|
|
54
59
|
stages=sorted(required_stages),
|
|
55
|
-
head_commit=str(latest_done.get("head_commit") or "") or
|
|
60
|
+
head_commit=str(latest_done.get("head_commit") or "") or carry_head_commit(latest_carry),
|
|
56
61
|
report_path=_latest_implementation_report(task_root),
|
|
57
62
|
next_recommended_phase=DEFAULT_NEXT_PHASE["implementation"],
|
|
58
63
|
reason="all implementation stages have pass-grade carry and done rows",
|
|
@@ -66,7 +71,7 @@ def reconcile_implementation_outcome(project_root: Path, task_root: Path) -> boo
|
|
|
66
71
|
if not outcome.completed:
|
|
67
72
|
return False
|
|
68
73
|
|
|
69
|
-
manifest_path = task_root
|
|
74
|
+
manifest_path = task_manifest_file(task_root)
|
|
70
75
|
manifest = _load_json(manifest_path)
|
|
71
76
|
workflow = manifest.get("workflow")
|
|
72
77
|
if not isinstance(workflow, dict):
|
|
@@ -109,7 +114,7 @@ def _load_json(path: Path) -> dict[str, Any]:
|
|
|
109
114
|
return data if isinstance(data, dict) else {}
|
|
110
115
|
|
|
111
116
|
|
|
112
|
-
def
|
|
117
|
+
def load_stage_map(task_root: Path, manifest: dict[str, Any]) -> list[dict[str, Any]]:
|
|
113
118
|
plan_path = _source_plan_path(task_root, manifest)
|
|
114
119
|
if not plan_path.is_file():
|
|
115
120
|
return []
|
|
@@ -133,7 +138,7 @@ def _load_stage_map(task_root: Path, manifest: dict[str, Any]) -> list[dict[str,
|
|
|
133
138
|
|
|
134
139
|
|
|
135
140
|
def _source_plan_path(task_root: Path, manifest: dict[str, Any]) -> Path:
|
|
136
|
-
carry_dir = task_root
|
|
141
|
+
carry_dir = RunRef.from_task_root(task_root, "implementation").carry_dir
|
|
137
142
|
for carry_path in sorted(carry_dir.glob("stage-*.json")):
|
|
138
143
|
value = _load_json(carry_path).get("sourcePlanPath")
|
|
139
144
|
if isinstance(value, str) and value:
|
|
@@ -143,7 +148,7 @@ def _source_plan_path(task_root: Path, manifest: dict[str, Any]) -> Path:
|
|
|
143
148
|
if isinstance(latest, str) and "implementation-planning" in latest:
|
|
144
149
|
return _resolve_relative(task_root, latest)
|
|
145
150
|
|
|
146
|
-
reports = task_root
|
|
151
|
+
reports = RunRef.from_task_root(task_root, "implementation-planning").reports_dir
|
|
147
152
|
candidates = sorted(reports.glob("final-report-implementation-planning-*.md"))
|
|
148
153
|
return candidates[-1] if candidates else Path()
|
|
149
154
|
|
|
@@ -170,13 +175,13 @@ def _project_root_from_task_root(task_root: Path) -> Path:
|
|
|
170
175
|
|
|
171
176
|
|
|
172
177
|
def _load_carry(task_root: Path, stage: int) -> dict[str, Any] | None:
|
|
173
|
-
data = _load_json(task_root
|
|
178
|
+
data = _load_json(RunRef.from_task_root(task_root, "implementation").carry(stage))
|
|
174
179
|
return data or None
|
|
175
180
|
|
|
176
181
|
|
|
177
182
|
def _carry_passed(carry: dict[str, Any]) -> bool:
|
|
178
183
|
status = str(carry.get("status") or "").lower()
|
|
179
|
-
if status in
|
|
184
|
+
if status in FAILED_CARRY_STATUSES:
|
|
180
185
|
return False
|
|
181
186
|
conformance = carry.get("conformance")
|
|
182
187
|
if isinstance(conformance, dict):
|
|
@@ -187,25 +192,10 @@ def _carry_passed(carry: dict[str, Any]) -> bool:
|
|
|
187
192
|
return not (isinstance(unverified, list) and unverified)
|
|
188
193
|
|
|
189
194
|
|
|
190
|
-
def _carry_head_commit(carry: dict[str, Any]) -> str:
|
|
191
|
-
stage_range = carry.get("stageCommitRange")
|
|
192
|
-
if isinstance(stage_range, dict) and stage_range.get("head"):
|
|
193
|
-
return str(stage_range["head"])
|
|
194
|
-
for key in ("head_sha", "head_commit", "head"):
|
|
195
|
-
value = carry.get(key)
|
|
196
|
-
if value:
|
|
197
|
-
return str(value)
|
|
198
|
-
commits = carry.get("commits")
|
|
199
|
-
if isinstance(commits, list) and commits:
|
|
200
|
-
last = commits[-1]
|
|
201
|
-
if isinstance(last, dict) and last.get("sha"):
|
|
202
|
-
return str(last["sha"])
|
|
203
|
-
return ""
|
|
204
|
-
|
|
205
195
|
|
|
206
196
|
def _latest_implementation_report(task_root: Path) -> str:
|
|
207
197
|
reports = sorted(
|
|
208
|
-
(task_root
|
|
198
|
+
RunRef.from_task_root(task_root, "implementation").run_dir.glob(
|
|
209
199
|
"stage-*/reports/final-report-implementation-*.md"
|
|
210
200
|
)
|
|
211
201
|
)
|
|
@@ -14,7 +14,7 @@ from pathlib import Path
|
|
|
14
14
|
from typing import Any
|
|
15
15
|
|
|
16
16
|
from . import stage_targets
|
|
17
|
-
from .design_prep import DesignPrepDecision, resolve_design_prep
|
|
17
|
+
from .design_prep import DesignPrepDecision, DesignPrepError, resolve_design_prep
|
|
18
18
|
from .final_report_paths import final_report_data_path
|
|
19
19
|
from .stage_reconcile import auto_reconcile_best_effort
|
|
20
20
|
|
|
@@ -56,7 +56,12 @@ def _as_implementation_stage_error(exc: Exception) -> ImplementationStageError:
|
|
|
56
56
|
def _resolve_stage_design_prep(plan_path: Path, stage: int) -> DesignPrepDecision:
|
|
57
57
|
plan_data_path = final_report_data_path(plan_path)
|
|
58
58
|
if plan_data_path.is_file():
|
|
59
|
-
|
|
59
|
+
try:
|
|
60
|
+
return resolve_design_prep(plan_data_path, stage)
|
|
61
|
+
except DesignPrepError as exc:
|
|
62
|
+
# prepare catches ImplementationStageError → PrepareError; an
|
|
63
|
+
# untranslated DesignPrepError leaves okstra_ctl.run as a traceback.
|
|
64
|
+
raise _as_implementation_stage_error(exc) from exc
|
|
60
65
|
return DesignPrepDecision(
|
|
61
66
|
outcome="proceed",
|
|
62
67
|
effective_items=(),
|
|
@@ -12,6 +12,8 @@ from okstra_project.dirs import (
|
|
|
12
12
|
okstra_home,
|
|
13
13
|
)
|
|
14
14
|
|
|
15
|
+
from .paths import runs_dir_of, task_manifest_file, task_timeline_file
|
|
16
|
+
|
|
15
17
|
RUN_CONTEXT_KIND = "run-context"
|
|
16
18
|
RUN_CONTEXT_SCHEMA_VERSION = "2.0"
|
|
17
19
|
ACTIVE_CONTEXT_KIND = "active-run-context"
|
|
@@ -354,14 +356,14 @@ def _task_path_set(task_root: Path) -> dict[str, Path]:
|
|
|
354
356
|
history_dir = task_root / "history"
|
|
355
357
|
recap_dir = task_root / "recap"
|
|
356
358
|
return {
|
|
357
|
-
"task_manifest": task_root
|
|
359
|
+
"task_manifest": task_manifest_file(task_root),
|
|
358
360
|
"task_index": task_root / "task-index.md",
|
|
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
|
-
"timeline_file":
|
|
366
|
+
"timeline_file": task_timeline_file(task_root),
|
|
365
367
|
"recap_dir": recap_dir,
|
|
366
368
|
"recap_log": recap_dir / "recap-log.jsonl",
|
|
367
369
|
"final_report_template": instruction_set / "final-report-template.md",
|
|
@@ -15,11 +15,13 @@ 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
|
|
|
21
22
|
from okstra_project.dirs import (
|
|
22
23
|
DISCOVERY_RELATIVE,
|
|
24
|
+
TASK_MANIFEST_FILENAME,
|
|
23
25
|
OKSTRA_RELATIVE,
|
|
24
26
|
TASKS_RELATIVE,
|
|
25
27
|
okstra_home,
|
|
@@ -30,6 +32,8 @@ __all__ = [
|
|
|
30
32
|
"OKSTRA_RELATIVE",
|
|
31
33
|
"TASKS_RELATIVE",
|
|
32
34
|
"DISCOVERY_RELATIVE",
|
|
35
|
+
"RunRef",
|
|
36
|
+
"runs_dir_of",
|
|
33
37
|
"compute_run_paths",
|
|
34
38
|
"next_run_seq",
|
|
35
39
|
"resolve_under_root",
|
|
@@ -40,6 +44,254 @@ __all__ = [
|
|
|
40
44
|
]
|
|
41
45
|
|
|
42
46
|
|
|
47
|
+
_STAGED_TASK_TYPES = ("implementation", "final-verification")
|
|
48
|
+
# 발견용: bash `find -name 'final-report-*.md'` 와 같은 범위 — seq 이전 세대의
|
|
49
|
+
# 타임스탬프 파일명도 잡아야 옛 번들에서 조용히 실패하지 않는다.
|
|
50
|
+
_REPORT_NAME_RE = re.compile(r"^final-report-.+\.md$")
|
|
51
|
+
# seq 추출용: 정본 이름 `final-report-<task-type>-<NNN>.md` 일 때만.
|
|
52
|
+
_REPORT_SEQ_RE = re.compile(r"^final-report-.+-(?P<seq>\d{3,})\.md$")
|
|
53
|
+
_TASKS_SEGMENTS = Path(TASKS_RELATIVE).parts
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def _newest_report(reports_dir: Path) -> Optional[Path]:
|
|
57
|
+
"""reports 디렉터리에서 mtime 최신 final-report. 동률이면 basename 큰 쪽."""
|
|
58
|
+
if not reports_dir.is_dir():
|
|
59
|
+
return None
|
|
60
|
+
found = [
|
|
61
|
+
entry for entry in reports_dir.iterdir()
|
|
62
|
+
if entry.is_file() and _REPORT_NAME_RE.match(entry.name)
|
|
63
|
+
]
|
|
64
|
+
if not found:
|
|
65
|
+
return None
|
|
66
|
+
return max(found, key=lambda p: (p.stat().st_mtime, p.name))
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def _project_root_of(task_root: Path) -> Optional[Path]:
|
|
70
|
+
"""canonical `<project>/.okstra/tasks/<g>/<t>` 일 때만 project root.
|
|
71
|
+
|
|
72
|
+
검증기는 이 형태가 아닌 트리(테스트 픽스처, 이식된 번들)에도 돌아간다.
|
|
73
|
+
그때는 project root 를 알 수 없으므로 지어내지 않고 None 을 돌려준다 —
|
|
74
|
+
경로 유래 ref 는 task_root 를 고정하므로 계산에 필요하지도 않다.
|
|
75
|
+
"""
|
|
76
|
+
parts = task_root.parts
|
|
77
|
+
if len(parts) > len(_TASKS_SEGMENTS) + 2 and parts[-4:-2] == _TASKS_SEGMENTS:
|
|
78
|
+
return Path(*parts[:-4])
|
|
79
|
+
return None
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
@dataclass(frozen=True)
|
|
83
|
+
class RunRef:
|
|
84
|
+
"""하나의 run 을 identity 로 지명하는 read-side 참조.
|
|
85
|
+
|
|
86
|
+
산출물 경로를 종류별로 계산해 돌려주되 내용은 읽지 않는다 — 파싱은
|
|
87
|
+
consumers / stage_targets / run_context 같은 기존 로더가 계속 소유한다.
|
|
88
|
+
|
|
89
|
+
stage 세그먼트는 아티팩트 종류마다 다르게 적용된다. run 디렉터리는
|
|
90
|
+
stage-isolated 이지만 carry 사이드카는 flat 이므로(stage N+1 이 N 의 사이드카를
|
|
91
|
+
N 의 run 레이아웃을 모른 채 찾아야 한다), 그 예외는 `carry()` 안에만 산다.
|
|
92
|
+
호출자가 `run_dir` 에서 손으로 올라가면 안 된다.
|
|
93
|
+
|
|
94
|
+
`compute_run_paths()` 가 같은 레이아웃을 write-side 에서 계산한다. 둘의 합의는
|
|
95
|
+
tests/contract/test_task_path_ssot.py 가 잠근다.
|
|
96
|
+
"""
|
|
97
|
+
|
|
98
|
+
project_root: Optional[Path]
|
|
99
|
+
task_group: str
|
|
100
|
+
task_id: str
|
|
101
|
+
task_type: str
|
|
102
|
+
seq: Optional[int] = None
|
|
103
|
+
stage: Optional[int] = None
|
|
104
|
+
# 경로에서 만든 ref 는 받은 task_root 를 고정한다. identity 로 재계산하면
|
|
105
|
+
# canonical 하지 않은 트리에서 조용히 다른 곳을 가리킨다.
|
|
106
|
+
task_root_override: Optional[Path] = None
|
|
107
|
+
# 경로에서 만든 ref 는 찾은 파일을 그대로 고정한다 — 레거시 타임스탬프
|
|
108
|
+
# 이름은 identity 로 재구성할 수 없다.
|
|
109
|
+
report_override: Optional[Path] = None
|
|
110
|
+
|
|
111
|
+
@property
|
|
112
|
+
def task_root(self) -> Path:
|
|
113
|
+
if self.task_root_override is not None:
|
|
114
|
+
return self.task_root_override
|
|
115
|
+
if self.project_root is None:
|
|
116
|
+
raise ValueError("RunRef has neither a project_root nor a task_root")
|
|
117
|
+
return task_dir(self.project_root, self.task_group, self.task_id)
|
|
118
|
+
|
|
119
|
+
@property
|
|
120
|
+
def runs_dir(self) -> Path:
|
|
121
|
+
return runs_dir_of(self.task_root)
|
|
122
|
+
|
|
123
|
+
@property
|
|
124
|
+
def run_dir(self) -> Path:
|
|
125
|
+
segment = slugify(self.task_type)
|
|
126
|
+
run_dir = self.runs_dir / segment
|
|
127
|
+
if segment in _STAGED_TASK_TYPES and self.stage is not None:
|
|
128
|
+
return run_dir / f"stage-{int(self.stage)}"
|
|
129
|
+
return run_dir
|
|
130
|
+
|
|
131
|
+
@property
|
|
132
|
+
def reports_dir(self) -> Path:
|
|
133
|
+
return self.run_dir / "reports"
|
|
134
|
+
|
|
135
|
+
@property
|
|
136
|
+
def carry_dir(self) -> Path:
|
|
137
|
+
"""implementation 의 carry 는 stage-SHARED 라 flat 이다(§carry 참고)."""
|
|
138
|
+
segment = slugify(self.task_type)
|
|
139
|
+
if segment == "implementation":
|
|
140
|
+
return self.runs_dir / segment / "carry"
|
|
141
|
+
return self.run_dir / "carry"
|
|
142
|
+
|
|
143
|
+
@property
|
|
144
|
+
def report(self) -> Path:
|
|
145
|
+
if self.report_override is not None:
|
|
146
|
+
return self.report_override
|
|
147
|
+
return self.reports_dir / f"final-report{self._suffix}.md"
|
|
148
|
+
|
|
149
|
+
@property
|
|
150
|
+
def manifest(self) -> Path:
|
|
151
|
+
return self.run_dir / "manifests" / f"run-manifest{self._suffix}.json"
|
|
152
|
+
|
|
153
|
+
def carry(self, stage: int) -> Path:
|
|
154
|
+
"""stage 의 carry 사이드카. implementation 은 stage-SHARED(flat)다."""
|
|
155
|
+
return self.carry_dir / f"stage-{int(stage)}.json"
|
|
156
|
+
|
|
157
|
+
@property
|
|
158
|
+
def _suffix(self) -> str:
|
|
159
|
+
if self.seq is None:
|
|
160
|
+
raise ValueError(
|
|
161
|
+
"RunRef.seq is unset — a seq-less ref can name the run directory "
|
|
162
|
+
"but not an artifact file. Use RunRef.latest() or pass seq."
|
|
163
|
+
)
|
|
164
|
+
return f"-{slugify(self.task_type)}-{int(self.seq):03d}"
|
|
165
|
+
|
|
166
|
+
@classmethod
|
|
167
|
+
def from_task_root(
|
|
168
|
+
cls,
|
|
169
|
+
task_root: Path,
|
|
170
|
+
task_type: str,
|
|
171
|
+
*,
|
|
172
|
+
seq: Optional[int] = None,
|
|
173
|
+
stage: Optional[int] = None,
|
|
174
|
+
) -> "RunRef":
|
|
175
|
+
"""task_root 경로만 쥔 호출자를 위한 어댑터.
|
|
176
|
+
|
|
177
|
+
받은 task_root 를 고정하므로 canonical 하지 않은 트리에서도 왕복한다.
|
|
178
|
+
복원되는 group/id 는 slug 세그먼트다.
|
|
179
|
+
"""
|
|
180
|
+
task_root = Path(task_root)
|
|
181
|
+
return cls(
|
|
182
|
+
project_root=_project_root_of(task_root),
|
|
183
|
+
task_group=task_root.parent.name,
|
|
184
|
+
task_id=task_root.name,
|
|
185
|
+
task_type=task_type,
|
|
186
|
+
seq=seq,
|
|
187
|
+
stage=stage,
|
|
188
|
+
task_root_override=task_root,
|
|
189
|
+
)
|
|
190
|
+
|
|
191
|
+
@classmethod
|
|
192
|
+
def from_run_dir(cls, run_dir: Path, *, seq: Optional[int] = None) -> "RunRef":
|
|
193
|
+
"""run 디렉터리에서 정체를 복원한다.
|
|
194
|
+
|
|
195
|
+
`<task_root>/runs/<task-type>[/stage-<N>]` 를 해석한다. `runs` 앵커가
|
|
196
|
+
없으면 ValueError — 관대한 폴백이 필요한 호출자는 직접 감싸라.
|
|
197
|
+
"""
|
|
198
|
+
run_dir = Path(run_dir)
|
|
199
|
+
stage = None
|
|
200
|
+
if run_dir.name.startswith("stage-"):
|
|
201
|
+
stage = int(run_dir.name[len("stage-"):])
|
|
202
|
+
run_dir = run_dir.parent
|
|
203
|
+
if run_dir.parent.name != "runs":
|
|
204
|
+
raise ValueError(f"not an okstra run directory: {run_dir}")
|
|
205
|
+
|
|
206
|
+
return cls.from_task_root(
|
|
207
|
+
run_dir.parent.parent, run_dir.name, seq=seq, stage=stage
|
|
208
|
+
)
|
|
209
|
+
|
|
210
|
+
@classmethod
|
|
211
|
+
def from_report_path(cls, report_path: Path) -> "RunRef":
|
|
212
|
+
"""final-report 경로에서 정체를 복원한다.
|
|
213
|
+
|
|
214
|
+
복원되는 task-group/task-id 는 slug 세그먼트다. slugify 가 멱등이라
|
|
215
|
+
경로 왕복에는 영향이 없지만, 원래 표기가 필요하면 task-manifest 를 읽어라.
|
|
216
|
+
"""
|
|
217
|
+
report_path = Path(report_path)
|
|
218
|
+
if not _REPORT_NAME_RE.match(report_path.name):
|
|
219
|
+
raise ValueError(f"not a final-report path: {report_path}")
|
|
220
|
+
matched = _REPORT_SEQ_RE.match(report_path.name)
|
|
221
|
+
ref = cls.from_run_dir(
|
|
222
|
+
report_path.parent.parent,
|
|
223
|
+
seq=int(matched.group("seq")) if matched else None,
|
|
224
|
+
)
|
|
225
|
+
return replace(ref, report_override=report_path)
|
|
226
|
+
|
|
227
|
+
def sibling(self, task_type: str) -> "RunRef":
|
|
228
|
+
"""같은 task 의 다른 task-type 을 가리키는 ref.
|
|
229
|
+
|
|
230
|
+
seq/stage 는 이 run 의 것이므로 형제에게 물려주지 않는다.
|
|
231
|
+
"""
|
|
232
|
+
return replace(
|
|
233
|
+
self, task_type=task_type, seq=None, stage=None, report_override=None
|
|
234
|
+
)
|
|
235
|
+
|
|
236
|
+
@classmethod
|
|
237
|
+
def latest(
|
|
238
|
+
cls, project_root: Path, task_group: str, task_id: str, task_type: str
|
|
239
|
+
) -> Optional["RunRef"]:
|
|
240
|
+
"""task-type 의 최신 final-report 를 가리키는 ref. 없으면 None.
|
|
241
|
+
|
|
242
|
+
최신 기준은 mtime 이고, 동률이면 basename 이 큰 쪽이다 — bash
|
|
243
|
+
`find_latest_final_report` 와 wizard 가 쓰던 규칙을 그대로 옮긴 것이다.
|
|
244
|
+
stage 하위 디렉터리는 훑지 않는다(옮겨온 두 구현과 동일 범위).
|
|
245
|
+
"""
|
|
246
|
+
ref = cls(
|
|
247
|
+
project_root=Path(project_root), task_group=task_group,
|
|
248
|
+
task_id=task_id, task_type=task_type,
|
|
249
|
+
)
|
|
250
|
+
best = _newest_report(ref.reports_dir)
|
|
251
|
+
return None if best is None else cls.from_report_path(best)
|
|
252
|
+
|
|
253
|
+
@classmethod
|
|
254
|
+
def latest_across(
|
|
255
|
+
cls,
|
|
256
|
+
project_root: Path,
|
|
257
|
+
task_group: str,
|
|
258
|
+
task_id: str,
|
|
259
|
+
task_types: Optional[tuple[str, ...]] = None,
|
|
260
|
+
) -> Optional["RunRef"]:
|
|
261
|
+
"""여러 task-type 을 가로질러 최신 final-report 를 가리키는 ref.
|
|
262
|
+
|
|
263
|
+
`task_types` 를 주지 않으면 runs/ 에 실제로 존재하는 모든 task-type 을
|
|
264
|
+
훑는다. 비교 규칙은 `latest` 와 같다(mtime, 동률이면 basename).
|
|
265
|
+
"""
|
|
266
|
+
return cls.latest_under(
|
|
267
|
+
task_dir(project_root, task_group, task_id), task_types
|
|
268
|
+
)
|
|
269
|
+
|
|
270
|
+
@classmethod
|
|
271
|
+
def latest_under(
|
|
272
|
+
cls, task_root: Path, task_types: Optional[tuple[str, ...]] = None
|
|
273
|
+
) -> Optional["RunRef"]:
|
|
274
|
+
"""`latest_across` 의 task_root 진입점.
|
|
275
|
+
|
|
276
|
+
task-key 단축키/오타 진단을 이미 거쳐 task_root 를 손에 쥔 호출자
|
|
277
|
+
(bash resume-clarification)가 쓴다.
|
|
278
|
+
"""
|
|
279
|
+
runs_dir = runs_dir_of(task_root)
|
|
280
|
+
if task_types is None:
|
|
281
|
+
if not runs_dir.is_dir():
|
|
282
|
+
return None
|
|
283
|
+
task_types = tuple(sorted(p.name for p in runs_dir.iterdir() if p.is_dir()))
|
|
284
|
+
candidates = [
|
|
285
|
+
found for task_type in task_types
|
|
286
|
+
if (found := _newest_report(runs_dir / task_type / "reports")) is not None
|
|
287
|
+
]
|
|
288
|
+
if not candidates:
|
|
289
|
+
return None
|
|
290
|
+
return cls.from_report_path(
|
|
291
|
+
max(candidates, key=lambda p: (p.stat().st_mtime, p.name))
|
|
292
|
+
)
|
|
293
|
+
|
|
294
|
+
|
|
43
295
|
def task_dir(project_root: Path, task_group: str, task_id: str) -> Path:
|
|
44
296
|
"""task root 경로: ``<project>/.okstra/tasks/<group-seg>/<id-seg>``.
|
|
45
297
|
|
|
@@ -51,7 +303,29 @@ def task_dir(project_root: Path, task_group: str, task_id: str) -> Path:
|
|
|
51
303
|
|
|
52
304
|
def task_runs_dir(project_root: Path, task_group: str, task_id: str) -> Path:
|
|
53
305
|
"""task 의 runs 디렉터리: ``task_dir/runs``."""
|
|
54
|
-
return task_dir(project_root, task_group, task_id)
|
|
306
|
+
return runs_dir_of(task_dir(project_root, task_group, task_id))
|
|
307
|
+
|
|
308
|
+
|
|
309
|
+
def runs_dir_of(task_root: Path) -> Path:
|
|
310
|
+
"""task_root 만 쥔 호출자를 위한 runs 디렉터리 접근자.
|
|
311
|
+
|
|
312
|
+
task-type 을 모르는 스캐너(backfill·error-log glob·context-cost)가 쓴다.
|
|
313
|
+
`runs` 세그먼트 리터럴은 이 함수와 `compute_run_paths` 에만 존재한다."""
|
|
314
|
+
return Path(task_root) / "runs"
|
|
315
|
+
|
|
316
|
+
|
|
317
|
+
def task_manifest_file(task_root: Path) -> Path:
|
|
318
|
+
"""task 의 lifecycle manifest 경로.
|
|
319
|
+
|
|
320
|
+
task-type 을 모르는 호출자(recap·time-report·error-report·work-categories)가
|
|
321
|
+
쓰는 진입점이다. `RunRef.manifest` 는 한 *run* 의 run-manifest 이고, `report_finalize.task_manifest_path`
|
|
322
|
+
는 run manifest 의 fallback 체인을 타는 해소기다 — 셋 다 다른 것이므로 이름을 나눈다."""
|
|
323
|
+
return Path(task_root) / TASK_MANIFEST_FILENAME
|
|
324
|
+
|
|
325
|
+
|
|
326
|
+
def task_timeline_file(task_root: Path) -> Path:
|
|
327
|
+
"""task 의 history timeline 경로."""
|
|
328
|
+
return Path(task_root) / "history" / "timeline.json"
|
|
55
329
|
|
|
56
330
|
|
|
57
331
|
def container_paths(project_root: Path, task_group: str, task_id: str) -> dict:
|
|
@@ -156,14 +430,14 @@ def compute_run_paths(
|
|
|
156
430
|
discovery_dir = project_root / DISCOVERY_RELATIVE
|
|
157
431
|
|
|
158
432
|
task_root = tasks_root / task_group_segment / task_id_segment
|
|
159
|
-
task_manifest = task_root
|
|
433
|
+
task_manifest = task_manifest_file(task_root)
|
|
160
434
|
task_index = task_root / "task-index.md"
|
|
161
435
|
instruction_set = task_root / "instruction-set"
|
|
162
436
|
analysis_packet = instruction_set / "analysis-packet.md"
|
|
163
437
|
task_qa = task_root / "qa"
|
|
164
|
-
runs_dir = task_root
|
|
438
|
+
runs_dir = runs_dir_of(task_root)
|
|
165
439
|
history_dir = task_root / "history"
|
|
166
|
-
timeline_file =
|
|
440
|
+
timeline_file = task_timeline_file(task_root)
|
|
167
441
|
recap_dir = task_root / "recap"
|
|
168
442
|
recap_log = recap_dir / "recap-log.jsonl"
|
|
169
443
|
|
|
@@ -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 을 찾을 수 없습니다 "
|
|
@@ -12,6 +12,8 @@ import sys
|
|
|
12
12
|
from pathlib import Path
|
|
13
13
|
|
|
14
14
|
from okstra_ctl.ids import slugify_task_segment
|
|
15
|
+
from okstra_ctl.paths import task_timeline_file
|
|
16
|
+
from okstra_project import read_task_key
|
|
15
17
|
from okstra_ctl.run_context import dir_flock
|
|
16
18
|
from okstra_ctl.task_target import resolve_task_root, project_rel
|
|
17
19
|
|
|
@@ -19,7 +21,7 @@ NOTE_KINDS = ("verification-evidence", "decision-draft", "analysis-note")
|
|
|
19
21
|
|
|
20
22
|
|
|
21
23
|
def _load_timeline(task_root: Path) -> list[dict]:
|
|
22
|
-
path = task_root
|
|
24
|
+
path = task_timeline_file(task_root)
|
|
23
25
|
if not path.is_file():
|
|
24
26
|
return []
|
|
25
27
|
try:
|
|
@@ -30,15 +32,6 @@ def _load_timeline(task_root: Path) -> list[dict]:
|
|
|
30
32
|
return runs if isinstance(runs, list) else []
|
|
31
33
|
|
|
32
34
|
|
|
33
|
-
def _task_key(task_root: Path) -> str:
|
|
34
|
-
path = task_root / "task-manifest.json"
|
|
35
|
-
if not path.is_file():
|
|
36
|
-
return ""
|
|
37
|
-
try:
|
|
38
|
-
return json.loads(path.read_text(encoding="utf-8")).get("taskKey", "")
|
|
39
|
-
except Exception:
|
|
40
|
-
return ""
|
|
41
|
-
|
|
42
35
|
|
|
43
36
|
def assemble_recap(task_root: Path, project_root: Path) -> dict:
|
|
44
37
|
runs = _load_timeline(task_root)
|
|
@@ -65,7 +58,7 @@ def assemble_recap(task_root: Path, project_root: Path) -> dict:
|
|
|
65
58
|
# 최신 상태인 양 보고하게 된다.
|
|
66
59
|
latest_states = snap.get("phaseStates", {}) or {}
|
|
67
60
|
return {
|
|
68
|
-
"taskKey":
|
|
61
|
+
"taskKey": read_task_key(task_root),
|
|
69
62
|
"runCount": len(runs),
|
|
70
63
|
"transitions": transitions,
|
|
71
64
|
"latestPhaseStates": latest_states,
|
|
@@ -128,7 +121,7 @@ def write_note(task_root, project_root, *, kind, slug, purpose, scope_note,
|
|
|
128
121
|
if not slug_seg:
|
|
129
122
|
raise ValueError("slug must contain at least one alphanumeric character")
|
|
130
123
|
path = _note_path(notes_dir, slug_seg, created_at)
|
|
131
|
-
task_key =
|
|
124
|
+
task_key = read_task_key(task_root)
|
|
132
125
|
frontmatter = _note_frontmatter(
|
|
133
126
|
task_key=task_key, kind=kind, created_at=created_at,
|
|
134
127
|
purpose=purpose, scope_note=scope_note)
|
|
@@ -23,7 +23,7 @@ import re
|
|
|
23
23
|
import sys
|
|
24
24
|
from pathlib import Path
|
|
25
25
|
|
|
26
|
-
from okstra_project.dirs import OKSTRA_DIR_NAME, project_json_path
|
|
26
|
+
from okstra_project.dirs import TASK_MANIFEST_FILENAME, OKSTRA_DIR_NAME, project_json_path
|
|
27
27
|
|
|
28
28
|
# phase 시퀀스 / 기본 next-phase 매핑의 SSOT 는 workflow 모듈이다. 과거
|
|
29
29
|
# render_task_manifest 가 동일한 리스트/딕셔너리를 로컬에 중복 정의했는데,
|
|
@@ -32,6 +32,7 @@ from . import fix_cycles
|
|
|
32
32
|
from .paths import okstra_home
|
|
33
33
|
from .lead_runtime import lead_runtime_info
|
|
34
34
|
from .path_hints import compact_active_run_context, hydrate_run_context
|
|
35
|
+
from .paths import task_timeline_file
|
|
35
36
|
from .workflow import DEFAULT_NEXT_PHASE, PHASE_SEQUENCE
|
|
36
37
|
|
|
37
38
|
|
|
@@ -589,7 +590,7 @@ def render_task_catalog_discovery(output_path: str, ctx: dict) -> None:
|
|
|
589
590
|
return str(p)
|
|
590
591
|
|
|
591
592
|
entries = []
|
|
592
|
-
for manifest_path in sorted(tasks_root.rglob(
|
|
593
|
+
for manifest_path in sorted(tasks_root.rglob(TASK_MANIFEST_FILENAME)):
|
|
593
594
|
try:
|
|
594
595
|
manifest = json.loads(manifest_path.read_text(encoding="utf-8"))
|
|
595
596
|
except Exception:
|
|
@@ -601,15 +602,15 @@ def render_task_catalog_discovery(output_path: str, ctx: dict) -> None:
|
|
|
601
602
|
continue
|
|
602
603
|
task_root = manifest_path.parent
|
|
603
604
|
timeline_relative = s(manifest, "historyTimelinePath").strip()
|
|
604
|
-
|
|
605
|
+
timeline_file = (
|
|
605
606
|
(project_root / timeline_relative)
|
|
606
607
|
if timeline_relative
|
|
607
|
-
else (task_root
|
|
608
|
+
else task_timeline_file(task_root)
|
|
608
609
|
)
|
|
609
610
|
latest_run = {}
|
|
610
|
-
if
|
|
611
|
+
if timeline_file.is_file():
|
|
611
612
|
try:
|
|
612
|
-
payload = json.loads(
|
|
613
|
+
payload = json.loads(timeline_file.read_text(encoding="utf-8"))
|
|
613
614
|
except Exception:
|
|
614
615
|
payload = {}
|
|
615
616
|
runs = payload.get("runs", []) if isinstance(payload, dict) else []
|
|
@@ -675,7 +676,7 @@ def render_task_catalog_discovery(output_path: str, ctx: dict) -> None:
|
|
|
675
676
|
or s(latest_run, "reportPath"),
|
|
676
677
|
"latestResumeCommandPath": s(manifest, "latestResumeCommandPath")
|
|
677
678
|
or s(latest_run, "resumeCommandPath"),
|
|
678
|
-
"historyTimelinePath": timeline_relative or rel(
|
|
679
|
+
"historyTimelinePath": timeline_relative or rel(timeline_file),
|
|
679
680
|
"fixCycles": manifest.get("fixCycles")
|
|
680
681
|
or {"count": 0, "openCycleId": None, "latest": None},
|
|
681
682
|
}
|
|
@@ -1525,14 +1526,7 @@ def render_task_index(template_path: str, output_path: str, ctx: dict) -> None:
|
|
|
1525
1526
|
)
|
|
1526
1527
|
phase_order = workflow.get("phaseSequence", [])
|
|
1527
1528
|
if not isinstance(phase_order, list) or not phase_order:
|
|
1528
|
-
phase_order =
|
|
1529
|
-
"requirements-discovery",
|
|
1530
|
-
"error-analysis",
|
|
1531
|
-
"implementation-planning",
|
|
1532
|
-
"implementation",
|
|
1533
|
-
"final-verification",
|
|
1534
|
-
"release-handoff",
|
|
1535
|
-
]
|
|
1529
|
+
phase_order = list(PHASE_SEQUENCE)
|
|
1536
1530
|
phase_state_lines = [
|
|
1537
1531
|
f"- `{phase}`: `{phase_states.get(phase, 'not-started')}`"
|
|
1538
1532
|
for phase in phase_order
|
|
@@ -21,7 +21,7 @@ from pathlib import Path
|
|
|
21
21
|
from typing import Any, Callable, Mapping, Sequence
|
|
22
22
|
|
|
23
23
|
from .final_report_paths import final_report_data_path, final_report_markdown_path
|
|
24
|
-
from .paths import task_dir
|
|
24
|
+
from .paths import task_dir, task_manifest_file
|
|
25
25
|
|
|
26
26
|
|
|
27
27
|
STEP_TOKEN_USAGE = "token-usage"
|
|
@@ -108,15 +108,19 @@ def run_seq(manifest: Mapping[str, Any]) -> str:
|
|
|
108
108
|
|
|
109
109
|
|
|
110
110
|
def task_manifest_path(project_root: Path, manifest: Mapping[str, Any]) -> Path:
|
|
111
|
+
"""Resolve a run manifest's task manifest, falling back through the identity.
|
|
112
|
+
|
|
113
|
+
Distinct from `paths.task_manifest_file`, which only names the file inside a
|
|
114
|
+
known task root — this one decides *which* task root applies.
|
|
115
|
+
"""
|
|
111
116
|
value = string_value(manifest.get("taskManifestPath"))
|
|
112
117
|
if value:
|
|
113
118
|
return resolve_project_path(project_root, value)
|
|
114
119
|
task_root = string_value(manifest.get("taskRootPath"))
|
|
115
120
|
if task_root:
|
|
116
|
-
return resolve_project_path(project_root, task_root)
|
|
117
|
-
return (
|
|
121
|
+
return task_manifest_file(resolve_project_path(project_root, task_root))
|
|
122
|
+
return task_manifest_file(
|
|
118
123
|
task_dir(project_root, task_group(manifest), task_id(manifest))
|
|
119
|
-
/ "task-manifest.json"
|
|
120
124
|
)
|
|
121
125
|
|
|
122
126
|
|