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.
- package/docs/architecture/storage-model.md +1 -1
- package/docs/cli.md +1 -1
- package/docs/for-ai/skills/okstra-pr-gen.md +2 -1
- package/docs/for-ai/skills/okstra-user-response.md +8 -3
- package/package.json +1 -1
- package/runtime/BUILD.json +2 -2
- package/runtime/agents/workers/antigravity-worker.md +1 -1
- package/runtime/agents/workers/codex-worker.md +1 -1
- package/runtime/bin/lib/okstra/interactive.sh +24 -67
- package/runtime/bin/okstra-antigravity-exec.sh +22 -10
- package/runtime/bin/okstra-claude-exec.sh +20 -9
- package/runtime/bin/okstra-codex-exec.sh +22 -10
- package/runtime/prompts/lead/convergence.md +1 -1
- package/runtime/prompts/lead/team-contract.md +2 -2
- package/runtime/prompts/profiles/_common-contract.md +0 -1
- package/runtime/prompts/profiles/_implementation-executor.md +20 -1
- package/runtime/prompts/profiles/final-verification.md +1 -1
- package/runtime/prompts/profiles/implementation-planning.md +1 -0
- package/runtime/python/okstra_ctl/backfill.py +2 -1
- package/runtime/python/okstra_ctl/context_cost.py +3 -2
- package/runtime/python/okstra_ctl/convergence_engine.py +11 -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/models.py +2 -0
- 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/render_final_report.py +1 -1
- package/runtime/python/okstra_ctl/run.py +12 -1
- package/runtime/python/okstra_ctl/wizard.py +5 -3
- package/runtime/python/okstra_ctl/worker_artifact_paths.py +5 -1
- package/runtime/python/okstra_token_usage/collect.py +25 -15
- package/runtime/python/okstra_token_usage/pricing.py +4 -1
- package/runtime/skills/okstra-pr-gen/SKILL.md +24 -0
- package/runtime/skills/okstra-user-response/SKILL.md +65 -17
- package/runtime/validators/forbidden_actions.py +1 -1
- package/runtime/validators/validate-run.py +20 -22
- 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(
|
|
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
|
|
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
|
)
|
|
@@ -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
|
|
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 을 찾을 수 없습니다 "
|
|
@@ -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-
|
|
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
|
-
|
|
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 = (
|
|
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$")
|
|
@@ -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
|
-
|
|
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(
|
|
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
|
-
시작
|
|
242
|
-
늦게 찍히면(관측: dev-9902 — createdAt
|
|
243
|
-
since 가 앞선 세션을 잘라낸다. Task 3 이
|
|
244
|
-
첫 ts 최소값이 createdAt 보다 앞서면 그 값으로
|
|
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
|
-
|
|
256
|
-
|
|
257
|
-
since
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
since
|
|
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
|