okstra 0.134.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.
Files changed (39) hide show
  1. package/docs/architecture/storage-model.md +1 -1
  2. package/docs/cli.md +1 -1
  3. package/docs/for-ai/skills/okstra-pr-gen.md +2 -1
  4. package/docs/for-ai/skills/okstra-user-response.md +8 -3
  5. package/package.json +1 -1
  6. package/runtime/BUILD.json +2 -2
  7. package/runtime/agents/workers/antigravity-worker.md +1 -1
  8. package/runtime/agents/workers/codex-worker.md +1 -1
  9. package/runtime/bin/lib/okstra/interactive.sh +24 -67
  10. package/runtime/bin/okstra-antigravity-exec.sh +22 -10
  11. package/runtime/bin/okstra-claude-exec.sh +20 -9
  12. package/runtime/bin/okstra-codex-exec.sh +22 -10
  13. package/runtime/prompts/lead/convergence.md +1 -1
  14. package/runtime/prompts/lead/team-contract.md +2 -2
  15. package/runtime/prompts/profiles/_common-contract.md +0 -1
  16. package/runtime/prompts/profiles/_implementation-executor.md +20 -1
  17. package/runtime/prompts/profiles/final-verification.md +1 -1
  18. package/runtime/prompts/profiles/implementation-planning.md +1 -0
  19. package/runtime/python/okstra_ctl/backfill.py +2 -1
  20. package/runtime/python/okstra_ctl/context_cost.py +3 -2
  21. package/runtime/python/okstra_ctl/convergence_engine.py +11 -2
  22. package/runtime/python/okstra_ctl/error_log_core.py +3 -1
  23. package/runtime/python/okstra_ctl/handoff.py +2 -1
  24. package/runtime/python/okstra_ctl/implementation_outcome.py +6 -5
  25. package/runtime/python/okstra_ctl/models.py +2 -0
  26. package/runtime/python/okstra_ctl/path_hints.py +3 -1
  27. package/runtime/python/okstra_ctl/paths.py +261 -2
  28. package/runtime/python/okstra_ctl/plan_run_root.py +4 -4
  29. package/runtime/python/okstra_ctl/render_final_report.py +1 -1
  30. package/runtime/python/okstra_ctl/run.py +12 -1
  31. package/runtime/python/okstra_ctl/wizard.py +5 -3
  32. package/runtime/python/okstra_ctl/worker_artifact_paths.py +5 -1
  33. package/runtime/python/okstra_token_usage/collect.py +25 -15
  34. package/runtime/python/okstra_token_usage/pricing.py +4 -1
  35. package/runtime/skills/okstra-pr-gen/SKILL.md +24 -0
  36. package/runtime/skills/okstra-user-response/SKILL.md +65 -17
  37. package/runtime/validators/forbidden_actions.py +1 -1
  38. package/runtime/validators/validate-run.py +20 -22
  39. package/runtime/validators/validate_session_conformance.py +1 -1
@@ -457,7 +457,12 @@ def validate_working_state(state: Mapping[str, Any]) -> list[str]:
457
457
  else:
458
458
  seen_workers.add(worker_id)
459
459
  if not _nonempty_string(audience) or audience not in _AUDIENCES:
460
- errors.append(f"workers[{index}].audience is unsupported")
460
+ errors.append(
461
+ f"workers[{index}].audience is unsupported: {audience!r}. "
462
+ "Finding-producing workers (implementation verifiers included) "
463
+ "use 'analysis'; only the report author uses 'report-writer'. "
464
+ f"Allowed: {sorted(_AUDIENCES)}."
465
+ )
461
466
  findings = state.get("findings")
462
467
  if not isinstance(findings, list):
463
468
  errors.append("findings must be an array")
@@ -1821,7 +1826,11 @@ def _parse_workers(value: Any) -> list[dict[str, str]]:
1821
1826
  audience = _required_string(worker, "audience", f"workers[{index}]")
1822
1827
  if audience not in _AUDIENCES:
1823
1828
  raise ConvergenceContractError(
1824
- f"workers[{index}] has unsupported audience: {audience}"
1829
+ f"workers[{index}] has unsupported audience: {audience}. "
1830
+ "Convergence audience is a functional role, not a phase label: "
1831
+ "every finding-producing worker uses 'analysis' (an "
1832
+ "implementation run's verifiers included), and only the report "
1833
+ f"author uses 'report-writer'. Allowed: {sorted(_AUDIENCES)}."
1825
1834
  )
1826
1835
  if worker_id in seen:
1827
1836
  raise ConvergenceContractError(f"duplicate workerId: {worker_id}")
@@ -8,9 +8,11 @@ from __future__ import annotations
8
8
  import json
9
9
  from pathlib import Path
10
10
 
11
+ from .paths import runs_dir_of
12
+
11
13
 
12
14
  def glob_error_logs(task_root: Path) -> list[Path]:
13
- runs = task_root / "runs"
15
+ runs = runs_dir_of(task_root)
14
16
  if not runs.exists():
15
17
  return []
16
18
  flat = runs.glob("*/logs/errors-*.jsonl")
@@ -14,6 +14,7 @@ from typing import Any, Callable, Dict, List, Optional, Tuple
14
14
 
15
15
  from . import consumers, stage_targets, worktree_registry
16
16
  from .final_report_paths import final_report_markdown_path
17
+ from .paths import RunRef
17
18
  from .worktree import (compute_branch_name, compute_worktree_path,
18
19
  main_worktree_path, is_dirty_excluding_okstra,
19
20
  nested_worktree_excludes, is_ancestor, merge_branch,
@@ -62,7 +63,7 @@ def latest_whole_task_fv_accepted(project_root, project_id: str,
62
63
  f"{project_id}:{task_group}:{task_id}")
63
64
  if root is None:
64
65
  return ""
65
- reports_dir = root / "runs" / "final-verification" / "reports"
66
+ reports_dir = RunRef.from_task_root(root, "final-verification").reports_dir
66
67
  for dj in sorted(reports_dir.glob("final-report-*.data.json"),
67
68
  reverse=True):
68
69
  try:
@@ -7,6 +7,7 @@ from pathlib import Path
7
7
  from typing import Any
8
8
 
9
9
  from .consumers import read_stage_consumer_state
10
+ from .paths import RunRef
10
11
  from .workflow import DEFAULT_NEXT_PHASE, PHASE_SEQUENCE
11
12
 
12
13
 
@@ -27,7 +28,7 @@ def derive_implementation_outcome(task_root: Path) -> ImplementationOutcome:
27
28
  if workflow.get("currentPhase") != "implementation":
28
29
  return ImplementationOutcome(completed=False, reason="current phase is not implementation")
29
30
 
30
- plan_run_root = task_root / "runs" / "implementation-planning"
31
+ plan_run_root = RunRef.from_task_root(task_root, "implementation-planning").run_dir
31
32
  if not plan_run_root.is_dir():
32
33
  return ImplementationOutcome(completed=False, reason="implementation-planning run root missing")
33
34
 
@@ -133,7 +134,7 @@ def _load_stage_map(task_root: Path, manifest: dict[str, Any]) -> list[dict[str,
133
134
 
134
135
 
135
136
  def _source_plan_path(task_root: Path, manifest: dict[str, Any]) -> Path:
136
- carry_dir = task_root / "runs" / "implementation" / "carry"
137
+ carry_dir = RunRef.from_task_root(task_root, "implementation").carry_dir
137
138
  for carry_path in sorted(carry_dir.glob("stage-*.json")):
138
139
  value = _load_json(carry_path).get("sourcePlanPath")
139
140
  if isinstance(value, str) and value:
@@ -143,7 +144,7 @@ def _source_plan_path(task_root: Path, manifest: dict[str, Any]) -> Path:
143
144
  if isinstance(latest, str) and "implementation-planning" in latest:
144
145
  return _resolve_relative(task_root, latest)
145
146
 
146
- reports = task_root / "runs" / "implementation-planning" / "reports"
147
+ reports = RunRef.from_task_root(task_root, "implementation-planning").reports_dir
147
148
  candidates = sorted(reports.glob("final-report-implementation-planning-*.md"))
148
149
  return candidates[-1] if candidates else Path()
149
150
 
@@ -170,7 +171,7 @@ def _project_root_from_task_root(task_root: Path) -> Path:
170
171
 
171
172
 
172
173
  def _load_carry(task_root: Path, stage: int) -> dict[str, Any] | None:
173
- data = _load_json(task_root / "runs" / "implementation" / "carry" / f"stage-{stage}.json")
174
+ data = _load_json(RunRef.from_task_root(task_root, "implementation").carry(stage))
174
175
  return data or None
175
176
 
176
177
 
@@ -205,7 +206,7 @@ def _carry_head_commit(carry: dict[str, Any]) -> str:
205
206
 
206
207
  def _latest_implementation_report(task_root: Path) -> str:
207
208
  reports = sorted(
208
- (task_root / "runs" / "implementation").glob(
209
+ RunRef.from_task_root(task_root, "implementation").run_dir.glob(
209
210
  "stage-*/reports/final-report-implementation-*.md"
210
211
  )
211
212
  )
@@ -22,6 +22,8 @@ CLAUDE = {
22
22
  "fable-5": ModelSpec("fable-5", "claude-fable-5"),
23
23
  "claude-fable-5": ModelSpec("fable-5", "claude-fable-5"),
24
24
  "opus": ModelSpec("opus", "opus", in_picker=True),
25
+ "opus-5": ModelSpec("opus-5", "claude-opus-5"),
26
+ "claude-opus-5": ModelSpec("opus-5", "claude-opus-5"),
25
27
  "opus-4-8": ModelSpec("opus-4-8", "claude-opus-4-8"),
26
28
  "claude-opus-4-8": ModelSpec("opus-4-8", "claude-opus-4-8"),
27
29
  "opus-4-7": ModelSpec("opus-4-7", "claude-opus-4-7"),
@@ -12,6 +12,8 @@ from okstra_project.dirs import (
12
12
  okstra_home,
13
13
  )
14
14
 
15
+ from .paths import runs_dir_of
16
+
15
17
  RUN_CONTEXT_KIND = "run-context"
16
18
  RUN_CONTEXT_SCHEMA_VERSION = "2.0"
17
19
  ACTIVE_CONTEXT_KIND = "active-run-context"
@@ -359,7 +361,7 @@ def _task_path_set(task_root: Path) -> dict[str, Path]:
359
361
  "instruction_set": instruction_set,
360
362
  "analysis_packet": instruction_set / "analysis-packet.md",
361
363
  "task_qa": task_root / "qa",
362
- "runs_dir": task_root / "runs",
364
+ "runs_dir": runs_dir_of(task_root),
363
365
  "history_dir": history_dir,
364
366
  "timeline_file": history_dir / "timeline.json",
365
367
  "recap_dir": recap_dir,
@@ -15,6 +15,7 @@ from __future__ import annotations
15
15
 
16
16
  import os
17
17
  import re
18
+ from dataclasses import dataclass, replace
18
19
  from pathlib import Path
19
20
  from typing import Optional
20
21
 
@@ -30,6 +31,8 @@ __all__ = [
30
31
  "OKSTRA_RELATIVE",
31
32
  "TASKS_RELATIVE",
32
33
  "DISCOVERY_RELATIVE",
34
+ "RunRef",
35
+ "runs_dir_of",
33
36
  "compute_run_paths",
34
37
  "next_run_seq",
35
38
  "resolve_under_root",
@@ -40,6 +43,254 @@ __all__ = [
40
43
  ]
41
44
 
42
45
 
46
+ _STAGED_TASK_TYPES = ("implementation", "final-verification")
47
+ # 발견용: bash `find -name 'final-report-*.md'` 와 같은 범위 — seq 이전 세대의
48
+ # 타임스탬프 파일명도 잡아야 옛 번들에서 조용히 실패하지 않는다.
49
+ _REPORT_NAME_RE = re.compile(r"^final-report-.+\.md$")
50
+ # seq 추출용: 정본 이름 `final-report-<task-type>-<NNN>.md` 일 때만.
51
+ _REPORT_SEQ_RE = re.compile(r"^final-report-.+-(?P<seq>\d{3,})\.md$")
52
+ _TASKS_SEGMENTS = Path(TASKS_RELATIVE).parts
53
+
54
+
55
+ def _newest_report(reports_dir: Path) -> Optional[Path]:
56
+ """reports 디렉터리에서 mtime 최신 final-report. 동률이면 basename 큰 쪽."""
57
+ if not reports_dir.is_dir():
58
+ return None
59
+ found = [
60
+ entry for entry in reports_dir.iterdir()
61
+ if entry.is_file() and _REPORT_NAME_RE.match(entry.name)
62
+ ]
63
+ if not found:
64
+ return None
65
+ return max(found, key=lambda p: (p.stat().st_mtime, p.name))
66
+
67
+
68
+ def _project_root_of(task_root: Path) -> Optional[Path]:
69
+ """canonical `<project>/.okstra/tasks/<g>/<t>` 일 때만 project root.
70
+
71
+ 검증기는 이 형태가 아닌 트리(테스트 픽스처, 이식된 번들)에도 돌아간다.
72
+ 그때는 project root 를 알 수 없으므로 지어내지 않고 None 을 돌려준다 —
73
+ 경로 유래 ref 는 task_root 를 고정하므로 계산에 필요하지도 않다.
74
+ """
75
+ parts = task_root.parts
76
+ if len(parts) > len(_TASKS_SEGMENTS) + 2 and parts[-4:-2] == _TASKS_SEGMENTS:
77
+ return Path(*parts[:-4])
78
+ return None
79
+
80
+
81
+ @dataclass(frozen=True)
82
+ class RunRef:
83
+ """하나의 run 을 identity 로 지명하는 read-side 참조.
84
+
85
+ 산출물 경로를 종류별로 계산해 돌려주되 내용은 읽지 않는다 — 파싱은
86
+ consumers / stage_targets / run_context 같은 기존 로더가 계속 소유한다.
87
+
88
+ stage 세그먼트는 아티팩트 종류마다 다르게 적용된다. run 디렉터리는
89
+ stage-isolated 이지만 carry 사이드카는 flat 이므로(stage N+1 이 N 의 사이드카를
90
+ N 의 run 레이아웃을 모른 채 찾아야 한다), 그 예외는 `carry()` 안에만 산다.
91
+ 호출자가 `run_dir` 에서 손으로 올라가면 안 된다.
92
+
93
+ `compute_run_paths()` 가 같은 레이아웃을 write-side 에서 계산한다. 둘의 합의는
94
+ tests/contract/test_task_path_ssot.py 가 잠근다.
95
+ """
96
+
97
+ project_root: Optional[Path]
98
+ task_group: str
99
+ task_id: str
100
+ task_type: str
101
+ seq: Optional[int] = None
102
+ stage: Optional[int] = None
103
+ # 경로에서 만든 ref 는 받은 task_root 를 고정한다. identity 로 재계산하면
104
+ # canonical 하지 않은 트리에서 조용히 다른 곳을 가리킨다.
105
+ task_root_override: Optional[Path] = None
106
+ # 경로에서 만든 ref 는 찾은 파일을 그대로 고정한다 — 레거시 타임스탬프
107
+ # 이름은 identity 로 재구성할 수 없다.
108
+ report_override: Optional[Path] = None
109
+
110
+ @property
111
+ def task_root(self) -> Path:
112
+ if self.task_root_override is not None:
113
+ return self.task_root_override
114
+ if self.project_root is None:
115
+ raise ValueError("RunRef has neither a project_root nor a task_root")
116
+ return task_dir(self.project_root, self.task_group, self.task_id)
117
+
118
+ @property
119
+ def runs_dir(self) -> Path:
120
+ return runs_dir_of(self.task_root)
121
+
122
+ @property
123
+ def run_dir(self) -> Path:
124
+ segment = slugify(self.task_type)
125
+ run_dir = self.runs_dir / segment
126
+ if segment in _STAGED_TASK_TYPES and self.stage is not None:
127
+ return run_dir / f"stage-{int(self.stage)}"
128
+ return run_dir
129
+
130
+ @property
131
+ def reports_dir(self) -> Path:
132
+ return self.run_dir / "reports"
133
+
134
+ @property
135
+ def carry_dir(self) -> Path:
136
+ """implementation 의 carry 는 stage-SHARED 라 flat 이다(§carry 참고)."""
137
+ segment = slugify(self.task_type)
138
+ if segment == "implementation":
139
+ return self.runs_dir / segment / "carry"
140
+ return self.run_dir / "carry"
141
+
142
+ @property
143
+ def report(self) -> Path:
144
+ if self.report_override is not None:
145
+ return self.report_override
146
+ return self.reports_dir / f"final-report{self._suffix}.md"
147
+
148
+ @property
149
+ def manifest(self) -> Path:
150
+ return self.run_dir / "manifests" / f"run-manifest{self._suffix}.json"
151
+
152
+ def carry(self, stage: int) -> Path:
153
+ """stage 의 carry 사이드카. implementation 은 stage-SHARED(flat)다."""
154
+ return self.carry_dir / f"stage-{int(stage)}.json"
155
+
156
+ @property
157
+ def _suffix(self) -> str:
158
+ if self.seq is None:
159
+ raise ValueError(
160
+ "RunRef.seq is unset — a seq-less ref can name the run directory "
161
+ "but not an artifact file. Use RunRef.latest() or pass seq."
162
+ )
163
+ return f"-{slugify(self.task_type)}-{int(self.seq):03d}"
164
+
165
+ @classmethod
166
+ def from_task_root(
167
+ cls,
168
+ task_root: Path,
169
+ task_type: str,
170
+ *,
171
+ seq: Optional[int] = None,
172
+ stage: Optional[int] = None,
173
+ ) -> "RunRef":
174
+ """task_root 경로만 쥔 호출자를 위한 어댑터.
175
+
176
+ 받은 task_root 를 고정하므로 canonical 하지 않은 트리에서도 왕복한다.
177
+ 복원되는 group/id 는 slug 세그먼트다.
178
+ """
179
+ task_root = Path(task_root)
180
+ return cls(
181
+ project_root=_project_root_of(task_root),
182
+ task_group=task_root.parent.name,
183
+ task_id=task_root.name,
184
+ task_type=task_type,
185
+ seq=seq,
186
+ stage=stage,
187
+ task_root_override=task_root,
188
+ )
189
+
190
+ @classmethod
191
+ def from_run_dir(cls, run_dir: Path, *, seq: Optional[int] = None) -> "RunRef":
192
+ """run 디렉터리에서 정체를 복원한다.
193
+
194
+ `<task_root>/runs/<task-type>[/stage-<N>]` 를 해석한다. `runs` 앵커가
195
+ 없으면 ValueError — 관대한 폴백이 필요한 호출자는 직접 감싸라.
196
+ """
197
+ run_dir = Path(run_dir)
198
+ stage = None
199
+ if run_dir.name.startswith("stage-"):
200
+ stage = int(run_dir.name[len("stage-"):])
201
+ run_dir = run_dir.parent
202
+ if run_dir.parent.name != "runs":
203
+ raise ValueError(f"not an okstra run directory: {run_dir}")
204
+
205
+ return cls.from_task_root(
206
+ run_dir.parent.parent, run_dir.name, seq=seq, stage=stage
207
+ )
208
+
209
+ @classmethod
210
+ def from_report_path(cls, report_path: Path) -> "RunRef":
211
+ """final-report 경로에서 정체를 복원한다.
212
+
213
+ 복원되는 task-group/task-id 는 slug 세그먼트다. slugify 가 멱등이라
214
+ 경로 왕복에는 영향이 없지만, 원래 표기가 필요하면 task-manifest 를 읽어라.
215
+ """
216
+ report_path = Path(report_path)
217
+ if not _REPORT_NAME_RE.match(report_path.name):
218
+ raise ValueError(f"not a final-report path: {report_path}")
219
+ matched = _REPORT_SEQ_RE.match(report_path.name)
220
+ ref = cls.from_run_dir(
221
+ report_path.parent.parent,
222
+ seq=int(matched.group("seq")) if matched else None,
223
+ )
224
+ return replace(ref, report_override=report_path)
225
+
226
+ def sibling(self, task_type: str) -> "RunRef":
227
+ """같은 task 의 다른 task-type 을 가리키는 ref.
228
+
229
+ seq/stage 는 이 run 의 것이므로 형제에게 물려주지 않는다.
230
+ """
231
+ return replace(
232
+ self, task_type=task_type, seq=None, stage=None, report_override=None
233
+ )
234
+
235
+ @classmethod
236
+ def latest(
237
+ cls, project_root: Path, task_group: str, task_id: str, task_type: str
238
+ ) -> Optional["RunRef"]:
239
+ """task-type 의 최신 final-report 를 가리키는 ref. 없으면 None.
240
+
241
+ 최신 기준은 mtime 이고, 동률이면 basename 이 큰 쪽이다 — bash
242
+ `find_latest_final_report` 와 wizard 가 쓰던 규칙을 그대로 옮긴 것이다.
243
+ stage 하위 디렉터리는 훑지 않는다(옮겨온 두 구현과 동일 범위).
244
+ """
245
+ ref = cls(
246
+ project_root=Path(project_root), task_group=task_group,
247
+ task_id=task_id, task_type=task_type,
248
+ )
249
+ best = _newest_report(ref.reports_dir)
250
+ return None if best is None else cls.from_report_path(best)
251
+
252
+ @classmethod
253
+ def latest_across(
254
+ cls,
255
+ project_root: Path,
256
+ task_group: str,
257
+ task_id: str,
258
+ task_types: Optional[tuple[str, ...]] = None,
259
+ ) -> Optional["RunRef"]:
260
+ """여러 task-type 을 가로질러 최신 final-report 를 가리키는 ref.
261
+
262
+ `task_types` 를 주지 않으면 runs/ 에 실제로 존재하는 모든 task-type 을
263
+ 훑는다. 비교 규칙은 `latest` 와 같다(mtime, 동률이면 basename).
264
+ """
265
+ return cls.latest_under(
266
+ task_dir(project_root, task_group, task_id), task_types
267
+ )
268
+
269
+ @classmethod
270
+ def latest_under(
271
+ cls, task_root: Path, task_types: Optional[tuple[str, ...]] = None
272
+ ) -> Optional["RunRef"]:
273
+ """`latest_across` 의 task_root 진입점.
274
+
275
+ task-key 단축키/오타 진단을 이미 거쳐 task_root 를 손에 쥔 호출자
276
+ (bash resume-clarification)가 쓴다.
277
+ """
278
+ runs_dir = runs_dir_of(task_root)
279
+ if task_types is None:
280
+ if not runs_dir.is_dir():
281
+ return None
282
+ task_types = tuple(sorted(p.name for p in runs_dir.iterdir() if p.is_dir()))
283
+ candidates = [
284
+ found for task_type in task_types
285
+ if (found := _newest_report(runs_dir / task_type / "reports")) is not None
286
+ ]
287
+ if not candidates:
288
+ return None
289
+ return cls.from_report_path(
290
+ max(candidates, key=lambda p: (p.stat().st_mtime, p.name))
291
+ )
292
+
293
+
43
294
  def task_dir(project_root: Path, task_group: str, task_id: str) -> Path:
44
295
  """task root 경로: ``<project>/.okstra/tasks/<group-seg>/<id-seg>``.
45
296
 
@@ -51,7 +302,15 @@ def task_dir(project_root: Path, task_group: str, task_id: str) -> Path:
51
302
 
52
303
  def task_runs_dir(project_root: Path, task_group: str, task_id: str) -> Path:
53
304
  """task 의 runs 디렉터리: ``task_dir/runs``."""
54
- return task_dir(project_root, task_group, task_id) / "runs"
305
+ return runs_dir_of(task_dir(project_root, task_group, task_id))
306
+
307
+
308
+ def runs_dir_of(task_root: Path) -> Path:
309
+ """task_root 만 쥔 호출자를 위한 runs 디렉터리 접근자.
310
+
311
+ task-type 을 모르는 스캐너(backfill·error-log glob·context-cost)가 쓴다.
312
+ `runs` 세그먼트 리터럴은 이 함수와 `compute_run_paths` 에만 존재한다."""
313
+ return Path(task_root) / "runs"
55
314
 
56
315
 
57
316
  def container_paths(project_root: Path, task_group: str, task_id: str) -> dict:
@@ -161,7 +420,7 @@ def compute_run_paths(
161
420
  instruction_set = task_root / "instruction-set"
162
421
  analysis_packet = instruction_set / "analysis-packet.md"
163
422
  task_qa = task_root / "qa"
164
- runs_dir = task_root / "runs"
423
+ runs_dir = runs_dir_of(task_root)
165
424
  history_dir = task_root / "history"
166
425
  timeline_file = history_dir / "timeline.json"
167
426
  recap_dir = task_root / "recap"
@@ -48,10 +48,10 @@ def resolve_plan_run_root_by_task_key(
48
48
  from . import paths
49
49
  from .consumers import read_consumers
50
50
 
51
- reports_dir = (
52
- paths.task_dir(project_root, task_group, task_id)
53
- / "runs" / "implementation-planning" / "reports"
54
- )
51
+ reports_dir = paths.RunRef(
52
+ project_root=project_root, task_group=task_group, task_id=task_id,
53
+ task_type="implementation-planning",
54
+ ).reports_dir
55
55
  if not reports_dir.is_dir():
56
56
  raise PrepareError(
57
57
  "container up: implementation-planning run 을 찾을 수 없습니다 "
@@ -492,7 +492,7 @@ def _enforce_schema(data: dict) -> None:
492
492
  # 있으나 표시이므로 실행에는 무해하다. 새 버전이 나오면 여기만 갱신한다.
493
493
  _DISPLAY_CONCRETE_CLAUDE = {
494
494
  "fable": "claude-fable-5",
495
- "opus": "claude-opus-4-8",
495
+ "opus": "claude-opus-5",
496
496
  "sonnet": "claude-sonnet-4-6",
497
497
  "haiku": "claude-haiku-4-5",
498
498
  }
@@ -891,6 +891,7 @@ def _canonical_argv(inp: PrepareInputs, ctx: dict) -> list[str]:
891
891
 
892
892
 
893
893
  _INCLUDE_DIRECTIVE = re.compile(r"\{\{INCLUDE:([^}]+?)\}\}")
894
+ _HTML_COMMENT = re.compile(r"<!--.*?-->", re.DOTALL)
894
895
 
895
896
 
896
897
  def _expand_profile_includes(profile_path: Path, _depth: int = 0) -> str:
@@ -901,6 +902,12 @@ def _expand_profile_includes(profile_path: Path, _depth: int = 0) -> str:
901
902
  contents replace the directive line in-place. Missing include targets
902
903
  raise PrepareError so a bad reference fails fast instead of silently
903
904
  leaving a `{{INCLUDE:...}}` token in the rendered profile.
905
+
906
+ The top-level result is stripped of HTML comments: the shared fragments
907
+ carry maintainer-only `<!-- ... -->` notes (edit-here-once guidance, dedup
908
+ rationale) that would otherwise ride into the rendered profile the lead
909
+ reads every run without changing a single instruction. Nested-include
910
+ bodies are left intact and get stripped once by this depth-0 pass.
904
911
  """
905
912
  if _depth > 4:
906
913
  raise PrepareError(
@@ -917,7 +924,11 @@ def _expand_profile_includes(profile_path: Path, _depth: int = 0) -> str:
917
924
  )
918
925
  return _expand_profile_includes(target, _depth + 1).rstrip("\n")
919
926
 
920
- return _INCLUDE_DIRECTIVE.sub(_sub, text)
927
+ expanded = _INCLUDE_DIRECTIVE.sub(_sub, text)
928
+ if _depth == 0:
929
+ expanded = _HTML_COMMENT.sub("", expanded)
930
+ expanded = re.sub(r"\n{3,}", "\n\n", expanded)
931
+ return expanded
921
932
 
922
933
 
923
934
  # ---------------------------------------------------------------------------
@@ -84,7 +84,7 @@ from okstra_ctl.worktree import (
84
84
  preview_stage_worktree_decision,
85
85
  preview_worktree_decision,
86
86
  )
87
- from okstra_ctl.paths import task_dir, task_runs_dir
87
+ from okstra_ctl.paths import RunRef, task_dir, task_runs_dir
88
88
  from okstra_ctl.work_categories import resolve_work_category
89
89
  from okstra_ctl.run_context import latest_run_inputs
90
90
  from okstra_project.dirs import project_json_path
@@ -1794,8 +1794,10 @@ def _list_implementation_planning_reports(
1794
1794
  # Run seq lives in the filename, not a per-run subdirectory: every
1795
1795
  # implementation-planning run writes into the same flat `reports/`
1796
1796
  # dir (see paths.py — `run_reports = runs/<task-type>/reports`).
1797
- reports_dir = (task_runs_dir(state.project_root, state.task_group, state.task_id)
1798
- / "implementation-planning" / "reports")
1797
+ reports_dir = RunRef(
1798
+ project_root=state.project_root, task_group=state.task_group,
1799
+ task_id=state.task_id, task_type="implementation-planning",
1800
+ ).reports_dir
1799
1801
  if not reports_dir.is_dir():
1800
1802
  return []
1801
1803
  pat = re.compile(r"^final-report-implementation-planning-(\d+)\.md$")
@@ -17,7 +17,11 @@ def audit_sidecar_rel(worker_result_rel: str) -> str:
17
17
  prefix, separator, suffix = basename.partition("-worker-")
18
18
  if not separator:
19
19
  raise WorkerArtifactPathError(
20
- f"worker result path has no canonical -worker- token: {worker_result_rel}"
20
+ "worker result path has no canonical -worker- token: "
21
+ f"{worker_result_rel}. The audit sidecar is derived from the "
22
+ "canonical worker-result name `<role>-worker-<task-type>-<seq>.md` "
23
+ "(e.g. `codex-worker-implementation-001.md`); name the result that "
24
+ "way — a role label like `codex-verifier-...` drops the -worker- token."
21
25
  )
22
26
  audit_filename = f"{prefix}-worker-audit-{suffix}"
23
27
  return f"{directory}{slash}{audit_filename}" if slash else audit_filename
@@ -227,7 +227,9 @@ def _previous_run_end(run_dir: Path, suffix: str) -> str | None:
227
227
  return _run_end_estimate(run_dir, prev) or _run_manifest_created_at(run_dir, prev)
228
228
 
229
229
 
230
- def resolve_run_window(team_state_path: Path, state: dict) -> tuple[str | None, str | None]:
230
+ def resolve_run_window(
231
+ team_state_path: Path, state: dict, *, relax_start: bool = True
232
+ ) -> tuple[str | None, str | None]:
231
233
  """이 run 의 [시작, 종료] ISO 윈도우.
232
234
 
233
235
  in-session lead 는 자기 run 을 사용자의 *세션 전체* jsonl 에 기록하므로,
@@ -238,26 +240,34 @@ def resolve_run_window(team_state_path: Path, state: dict) -> tuple[str | None,
238
240
  status mtime → 현재 시각(아직 진행 중) 순으로 해소한다. 접미사를 못 뽑으면
239
241
  (None, None) — 윈도우 없이 전체를 세는 기존 동작으로 안전 폴백.
240
242
 
241
- 시작 완화: run-manifest createdAt 이 이 run 의 실제 lead/worker 세션보다
242
- 늦게 찍히면(관측: dev-9902 — createdAt 11:43Z 가 05:34Z 시작 세션보다 늦음)
243
- since 가 앞선 세션을 잘라낸다. Task 3 이 기록한 `leadSessionIds[]` 세션의
244
- 첫 ts 최소값이 createdAt 보다 앞서면 그 값으로 since 를 앞당긴다.
245
- `leadSessionIds` 부재(legacy)면 createdAt 그대로 — 기존 동작 불변.
246
-
247
- 완화 하한: 직전 run 의 종료 시각 이전으로는 내려가지 않는다
243
+ 시작 완화(`relax_start`, 기본 True — 토큰 수집용): run-manifest createdAt 이
244
+ 이 run 의 실제 lead/worker 세션보다 늦게 찍히면(관측: dev-9902 — createdAt
245
+ 11:43Z 가 05:34Z 시작 세션보다 늦음) since 가 앞선 세션을 잘라낸다. Task 3 이
246
+ 기록한 `leadSessionIds[]` 세션의 첫 ts 최소값이 createdAt 보다 앞서면 그 값으로
247
+ since 를 앞당긴다. `leadSessionIds` 부재(legacy)면 createdAt 그대로 — 기존
248
+ 동작 불변. 완화 하한: 직전 run 의 종료 시각 이전으로는 내려가지 않는다
248
249
  (`_previous_run_end`). 세션 jsonl 은 run 마다 새로 생기지 않으므로 하한이
249
- 없으면 같은 세션의 이전 run 이 이 윈도우에 통째로 들어온다."""
250
+ 없으면 같은 세션의 이전 run 이 이 윈도우에 통째로 들어온다.
251
+
252
+ `relax_start=False` (세션 스코프 검증기 전용 — session-conformance /
253
+ forbidden-actions): 완화를 건너뛰고 since 를 createdAt 에 고정한다. createdAt
254
+ 은 prep 시각이라 이 run 의 lead·worker 활동보다 반드시 앞서므로 건전한 하한이고,
255
+ 재사용 세션에 섞인 같은 세션의 이전 run·다른 task 활동을 창에서 배제한다
256
+ (dev-10172 D-3: 이전 planning run 의 phase-6/7 앵커, 다른 task 의 git push 를
257
+ 이 run 의 위반으로 오탐하던 근본 원인). 완화는 토큰을 놓치지 않으려고 세션
258
+ birth 까지 내려가지만, 검증기에는 그 관대함이 곧 오탐이다."""
250
259
  suffix = run_artifact_suffix(team_state_path)
251
260
  if not suffix:
252
261
  return None, None
253
262
  run_dir = team_state_path.parent.parent
254
263
  since = _run_manifest_created_at(run_dir, suffix)
255
- earliest = _earliest_lead_session_ts(state, team_state_path)
256
- if earliest and (since is None or earliest < since):
257
- since = earliest
258
- floor = _previous_run_end(run_dir, suffix)
259
- if floor and since and since < floor:
260
- since = floor
264
+ if relax_start:
265
+ earliest = _earliest_lead_session_ts(state, team_state_path)
266
+ if earliest and (since is None or earliest < since):
267
+ since = earliest
268
+ floor = _previous_run_end(run_dir, suffix)
269
+ if floor and since and since < floor:
270
+ since = floor
261
271
  until = state.get("runEndedAt") or _run_end_estimate(run_dir, suffix) or utc_now()
262
272
  return since, until
263
273
 
@@ -4,7 +4,7 @@ Pricing is matched by substring against the model id recorded in the session
4
4
  transcript, so keys must reflect the *actual* model id form emitted by each
5
5
  provider:
6
6
 
7
- * Anthropic — `claude-fable-5*`, `claude-opus-4-*`, `claude-sonnet-4-*`,
7
+ * Anthropic — `claude-fable-5*`, `claude-opus-5*`, `claude-opus-4-*`, `claude-sonnet-4-*`,
8
8
  `claude-haiku-4-5-*`, `claude-3-5-sonnet-*`, `claude-3-5-haiku-*`,
9
9
  `claude-3-opus-*`, `claude-3-haiku-*`.
10
10
  * OpenAI / Codex — `gpt-5*`, `gpt-4o*`, `gpt-4*`.
@@ -52,6 +52,9 @@ CLAUDE_PRICING = {
52
52
  # Claude Fable 5 (tier above Opus).
53
53
  "fable-5": (10.0, 12.5, 1.0, 50.0), # Fable 5 (cache prices derived from ratios)
54
54
 
55
+ # Claude Opus 5.
56
+ "opus-5": (5.0, 6.25, 0.50, 25.0), # Opus 5 (cache prices derived from ratios)
57
+
55
58
  # Claude 4 point releases (explicit so future divergence is easy to see).
56
59
  "opus-4-8": (5.0, 6.25, 0.50, 25.0), # Opus 4.8 (cache prices derived from ratios)
57
60
  "opus-4-7": (5.0, 6.25, 0.50, 25.0), # Opus 4.7 (cache prices derived from ratios)
@@ -96,6 +96,30 @@ diff and commits: describe only what actually changed. Mark checklist boxes
96
96
  docs box). If `commits`/`diffStat` are empty, tell the user there is nothing to
97
97
  describe and stop. **Never** append AI trailers/footers.
98
98
 
99
+ ### A3b. Identifier allowlist for the body
100
+
101
+ Reviewers have never seen okstra, so an okstra-internal id reads as a nonsense
102
+ token. Identifiers in the PR title and body come from this list and nothing else:
103
+
104
+ | allowed | example |
105
+ | --- | --- |
106
+ | repo-relative source path, optionally with a line | `src/commands/pr/pr.mjs:42` |
107
+ | symbol name that exists in the diff | `resolveTemplate()` |
108
+ | branch name, commit subject, commit SHA | `feature/dev-9436`, `a1b2c3d` |
109
+ | issue-tracker ticket id the reviewer can open | `dev-9436` |
110
+
111
+ okstra's own artifact identifiers are out of the allowlist — drop them:
112
+
113
+ - report item ids — `F-001`, `C-001`, `R-001`, `D-0001`, `PREP-001`
114
+ - run artifact names and their `<task-type>-<seq>` suffixes —
115
+ `final-report-implementation-3.md`, `run-manifest-implementation-3.json`
116
+ - phase / stage / worker labels — `final-verification`, `stage-2`, `codex-worker`
117
+ - any path under `.okstra/` — reports, decisions, glossary, user-response sidecars
118
+
119
+ When the only source for a claim is such an artifact, restate its substance in
120
+ the reviewer's terms — what changed in the code, and why — instead of citing the
121
+ id.
122
+
99
123
  ### A4. Output and offer to create the PR
100
124
 
101
125
  Print the filled PR body to chat as a single fenced markdown block. Then ask the