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.
Files changed (44) hide show
  1. package/docs/architecture/storage-model.md +1 -1
  2. package/docs/contributor-change-matrix.md +1 -1
  3. package/docs/project-structure-overview.md +8 -3
  4. package/package.json +2 -3
  5. package/runtime/BUILD.json +2 -2
  6. package/runtime/bin/lib/okstra/interactive.sh +24 -67
  7. package/runtime/bin/lib/okstra-ctl/cmd-rerun.sh +1 -1
  8. package/runtime/bin/okstra-antigravity-exec.sh +10 -8
  9. package/runtime/bin/okstra-claude-exec.sh +10 -8
  10. package/runtime/bin/okstra-codex-exec.sh +10 -8
  11. package/runtime/bin/okstra-spawn-followups.py +4 -9
  12. package/runtime/prompts/lead/plan-body-verification.md +2 -2
  13. package/runtime/python/okstra_ctl/backfill.py +2 -1
  14. package/runtime/python/okstra_ctl/codex_dispatch.py +56 -331
  15. package/runtime/python/okstra_ctl/consumers.py +7 -5
  16. package/runtime/python/okstra_ctl/context_cost.py +6 -5
  17. package/runtime/python/okstra_ctl/dispatch_core.py +54 -175
  18. package/runtime/python/okstra_ctl/dispatch_state.py +231 -0
  19. package/runtime/python/okstra_ctl/error_log_core.py +3 -1
  20. package/runtime/python/okstra_ctl/error_report.py +2 -7
  21. package/runtime/python/okstra_ctl/handoff.py +2 -1
  22. package/runtime/python/okstra_ctl/implementation_outcome.py +17 -27
  23. package/runtime/python/okstra_ctl/implementation_stage.py +7 -2
  24. package/runtime/python/okstra_ctl/path_hints.py +5 -3
  25. package/runtime/python/okstra_ctl/paths.py +278 -4
  26. package/runtime/python/okstra_ctl/plan_run_root.py +4 -4
  27. package/runtime/python/okstra_ctl/recap.py +5 -12
  28. package/runtime/python/okstra_ctl/render.py +9 -15
  29. package/runtime/python/okstra_ctl/report_finalize.py +8 -4
  30. package/runtime/python/okstra_ctl/report_language.py +83 -0
  31. package/runtime/python/okstra_ctl/run.py +222 -239
  32. package/runtime/python/okstra_ctl/set_work_status.py +2 -1
  33. package/runtime/python/okstra_ctl/stage_targets.py +98 -55
  34. package/runtime/python/okstra_ctl/time_report.py +4 -9
  35. package/runtime/python/okstra_ctl/wizard.py +50 -50
  36. package/runtime/python/okstra_ctl/work_categories.py +2 -2
  37. package/runtime/python/okstra_ctl/worker_prompt_body.py +82 -0
  38. package/runtime/python/okstra_ctl/worker_prompt_contract.py +0 -13
  39. package/runtime/python/okstra_ctl/worker_prompt_headers.py +7 -0
  40. package/runtime/python/okstra_project/__init__.py +4 -0
  41. package/runtime/python/okstra_project/dirs.py +7 -0
  42. package/runtime/python/okstra_project/state.py +78 -8
  43. package/runtime/validators/validate-run.py +23 -52
  44. 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 read_stage_consumer_state
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 / "task-manifest.json")
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 / "runs" / "implementation-planning"
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 = _load_stage_map(task_root, manifest)
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 _carry_head_commit(latest_carry),
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 / "task-manifest.json"
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 _load_stage_map(task_root: Path, manifest: dict[str, Any]) -> list[dict[str, Any]]:
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 / "runs" / "implementation" / "carry"
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 / "runs" / "implementation-planning" / "reports"
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 / "runs" / "implementation" / "carry" / f"stage-{stage}.json")
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 {"fail", "failed", "blocked", "error", "aborted"}:
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 / "runs" / "implementation").glob(
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
- return resolve_design_prep(plan_data_path, stage)
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 / "task-manifest.json",
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 / "runs",
364
+ "runs_dir": runs_dir_of(task_root),
363
365
  "history_dir": history_dir,
364
- "timeline_file": history_dir / "timeline.json",
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) / "runs"
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 / "task-manifest.json"
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 / "runs"
438
+ runs_dir = runs_dir_of(task_root)
165
439
  history_dir = task_root / "history"
166
- timeline_file = history_dir / "timeline.json"
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
- 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 을 찾을 수 없습니다 "
@@ -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 / "history" / "timeline.json"
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": _task_key(task_root),
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 = _task_key(task_root)
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("task-manifest.json")):
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
- timeline_path = (
605
+ timeline_file = (
605
606
  (project_root / timeline_relative)
606
607
  if timeline_relative
607
- else (task_root / "history" / "timeline.json")
608
+ else task_timeline_file(task_root)
608
609
  )
609
610
  latest_run = {}
610
- if timeline_path.is_file():
611
+ if timeline_file.is_file():
611
612
  try:
612
- payload = json.loads(timeline_path.read_text(encoding="utf-8"))
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(timeline_path),
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) / "task-manifest.json"
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