okstra 0.102.3 → 0.102.4
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/kr/architecture/storage-model.md +7 -2
- package/docs/kr/architecture.md +5 -2
- package/package.json +1 -1
- package/runtime/BUILD.json +2 -2
- package/runtime/bin/lib/okstra/interactive.sh +12 -0
- package/runtime/python/okstra_ctl/codex_dispatch.py +2 -1
- package/runtime/python/okstra_ctl/dispatch_core.py +4 -1
- package/runtime/python/okstra_ctl/implementation_outcome.py +271 -0
- package/runtime/python/okstra_ctl/path_hints.py +665 -0
- package/runtime/python/okstra_ctl/render.py +4 -3
- package/runtime/python/okstra_ctl/run_context.py +4 -2
- package/runtime/python/okstra_ctl/wizard.py +13 -1
- package/runtime/python/okstra_project/state.py +40 -2
|
@@ -158,6 +158,7 @@ scripts/okstra.sh workflow가 사용하는 project-local Claude assets는 아래
|
|
|
158
158
|
- `workflow.awaitingApproval`
|
|
159
159
|
- `workflow.routingStatus`
|
|
160
160
|
- `workflow.lastSafeCheckpoint`
|
|
161
|
+
- `phaseOutcome` *(artifact-derived semantic phase outcome; 예: implementation carry 가 모든 stage 통과를 증명하면 run contract validation 실패가 남아 있어도 implementation phase outcome 은 completed 로 기록될 수 있음)*
|
|
161
162
|
- `inputs`
|
|
162
163
|
- `artifacts`
|
|
163
164
|
- `resultContract`
|
|
@@ -197,7 +198,11 @@ canonical metadata는 항상 `task-manifest.json`을 기준으로 확인합니
|
|
|
197
198
|
|
|
198
199
|
각 실행은 `runs/<task-type>/manifests/run-manifest-<task-type>-<seq>.json`에 현재 run 계약을 남깁니다.
|
|
199
200
|
|
|
200
|
-
`manifests/run-
|
|
201
|
+
`manifests/run-context-<task-type>-<seq>.json`은 schemaVersion `2.0`부터 모든 legacy path key를 직접 저장하지 않고 `identity` + `pathHints`를 저장합니다. host-side reader는 `pathHints`를 hydrate해 기존 flat key(`RUN_MANIFEST_RELATIVE_PATH`, `TEAM_STATE_PATH` 등)를 메모리에서 재구성합니다.
|
|
202
|
+
|
|
203
|
+
`state/active-run-context-<task-type>-<seq>.json`도 schemaVersion `2.0`부터 lead Phase 1용 compact intake입니다. 반복 path 문자열 대신 `identity` + `pathHints`와 worker identity만 저장하고, deterministic dispatcher는 읽을 때 legacy active context shape로 hydrate합니다.
|
|
204
|
+
|
|
205
|
+
`manifests/run-manifest-<task-type>-<seq>.json`의 path 계열 필드는 아직 validator / team dispatch / inspect 호환을 위해 대상 프로젝트 루트 기준 상대경로로 저장합니다.
|
|
201
206
|
`okstra`가 Claude handoff를 시작한 직후에는 현재 run 상태가 보통 `in-progress`로 기록됩니다.
|
|
202
207
|
이후 최종 결과 저장과 상태 갱신은 Claude가 이어서 수행합니다.
|
|
203
208
|
또한 `okstra`는 launch 전에 session ID를 선할당하고, 같은 run의 `sessions/` 아래에 `claude-resume-<task-type>-<seq>.sh`를 생성합니다.
|
|
@@ -253,7 +258,7 @@ canonical metadata는 항상 `task-manifest.json`을 기준으로 확인합니
|
|
|
253
258
|
1. task browsing 또는 task-id disambiguation이 필요하면 `.okstra/discovery/task-catalog.json`을 먼저 읽습니다.
|
|
254
259
|
2. 현재 task key나 task path가 명시되지 않았다면 `.okstra/discovery/latest-task.json`을 current-task pointer로 읽습니다.
|
|
255
260
|
3. `task-manifest.json`을 읽습니다.
|
|
256
|
-
4. current `state/active-run-context-<task-type>-<seq>.json`이 있으면 lead Phase 1의 1차 입력으로 읽습니다. 없으면 current `manifests/run-manifest-<task-type>-<seq>.json`과 `team-state`로 fallback합니다.
|
|
261
|
+
4. current `state/active-run-context-<task-type>-<seq>.json`이 있으면 lead Phase 1의 1차 입력으로 읽습니다. 이 파일은 `identity` + `pathHints` 단서로 run artifact 경로를 재구성하는 compact intake입니다. 없으면 current `manifests/run-manifest-<task-type>-<seq>.json`과 `team-state`로 fallback합니다.
|
|
257
262
|
5. `instruction-set/analysis-profile.md`와 `instruction-set/analysis-packet.md`를 읽습니다.
|
|
258
263
|
6. `task-index.md`는 quick summary가 필요할 때만 선택적으로 읽습니다.
|
|
259
264
|
7. `analysis-material.md`, `reference-expectations.md`, `task-brief.md`, `final-report-template.md`는 packet이 불충분하거나 source citation/보고서 작성에 필요할 때 lazy read합니다.
|
package/docs/kr/architecture.md
CHANGED
|
@@ -190,7 +190,7 @@ per-process 환경 변수에 task 정체성·경로·workflow 상태를 보관
|
|
|
190
190
|
| task 후보 목록 | `<PROJECT_ROOT>/.okstra/discovery/task-catalog.json` |
|
|
191
191
|
| 최신 task 포인터 | `<PROJECT_ROOT>/.okstra/discovery/latest-task.json` |
|
|
192
192
|
| run 입력값 | `<run-dir>/manifests/run-inputs-<task-type>-<seq>.json` |
|
|
193
|
-
| run
|
|
193
|
+
| run path hints / seq | `<run-dir>/manifests/run-context-<task-type>-<seq>.json` |
|
|
194
194
|
| run 이력 | `<task-root>/history/timeline.json` |
|
|
195
195
|
| 글로벌 인덱스 | `~/.okstra/{active,recent}.jsonl`, `~/.okstra/projects/<id>/{index.jsonl, meta.json}` |
|
|
196
196
|
|
|
@@ -198,7 +198,7 @@ per-process 환경 변수에 task 정체성·경로·workflow 상태를 보관
|
|
|
198
198
|
|
|
199
199
|
두 종류의 파일 락이 모든 동시성을 처리합니다.
|
|
200
200
|
|
|
201
|
-
- `~/.okstra/.locks/<task-key>.lock` — per-task mutex. `compute_and_write_run_context` 가 seq 계산과 `run-context.json` 영속화를 한 트랜잭션으로 묶음. 같은 task 의 두 호출은 직렬화.
|
|
201
|
+
- `~/.okstra/.locks/<task-key>.lock` — per-task mutex. `compute_and_write_run_context` 가 seq 계산과 compact `run-context.json` 영속화를 한 트랜잭션으로 묶음. 같은 task 의 두 호출은 직렬화.
|
|
202
202
|
- `~/.okstra/.lock` — 중앙 인덱스 mutex. `record_start` / `reconcile` / `rotate_recent_if_needed` 에서 사용.
|
|
203
203
|
|
|
204
204
|
다른 (project, task-group, task-id) 사이에는 직렬화가 없습니다 — 디스크 경로가 분리되므로 충돌 자체가 없음. 환경 변수 race 가 사라졌으므로 같은 claude 세션이 여러 task 를 병렬 진행해도 안전.
|
|
@@ -604,6 +604,7 @@ scripts/okstra.sh --task-key <project-id>:<task-group>:<task-id> --task-type fin
|
|
|
604
604
|
- `workflow.awaitingApproval`
|
|
605
605
|
- `workflow.routingStatus`
|
|
606
606
|
- `workflow.lastSafeCheckpoint`
|
|
607
|
+
- `phaseOutcome`
|
|
607
608
|
|
|
608
609
|
프로젝트 전체 상태를 훑을 때는 `.okstra/discovery/task-catalog.json`을 사용합니다.
|
|
609
610
|
특정 task의 최신 상태를 볼 때는 `.okstra/discovery/latest-task.json`, `task-manifest.json`, 최신 `run-manifest`, `history/timeline.json` 순서로 확인합니다.
|
|
@@ -716,6 +717,8 @@ phase 산출물의 출고 가능 여부를 강제하는 진입점:
|
|
|
716
717
|
|
|
717
718
|
각 validator 는 contract 위반 시 `contract-violated` exit code 로 phase 를 차단합니다. 위반은 다음 phase 실행 시점에 적용되므로 이전 산출물은 그대로 둡니다.
|
|
718
719
|
|
|
720
|
+
`contractValidation.status=failed`는 run artifact contract 실패를 뜻하며, 구현 결과 자체가 항상 미완이라는 뜻은 아닙니다. implementation run 은 `runs/implementation/carry/stage-<N>.json`, `runs/implementation-planning/consumers.jsonl`, 승인된 Stage Map 을 함께 읽어 `phaseOutcome.implementation`을 도출합니다. 모든 Stage Map stage 가 pass-grade carry evidence 를 가지면 contract failure 는 감사 정보로 유지하면서 `workflow.nextRecommendedPhase`를 `final-verification`으로 보정할 수 있습니다.
|
|
721
|
+
|
|
719
722
|
## Practical notes
|
|
720
723
|
|
|
721
724
|
- `okstra`는 brief 없이 쓰는 옛 방식이 아닙니다.
|
package/package.json
CHANGED
package/runtime/BUILD.json
CHANGED
|
@@ -189,6 +189,18 @@ autofill_from_manifest() {
|
|
|
189
189
|
return 0
|
|
190
190
|
fi
|
|
191
191
|
|
|
192
|
+
PYTHONPATH="$WORKSPACE_ROOT/scripts:${PYTHONPATH-}" python3 - "$manifest_project_root" "$manifest_task_root" <<'PY' || true
|
|
193
|
+
import sys
|
|
194
|
+
from pathlib import Path
|
|
195
|
+
|
|
196
|
+
from okstra_ctl.implementation_outcome import reconcile_implementation_outcome
|
|
197
|
+
|
|
198
|
+
try:
|
|
199
|
+
reconcile_implementation_outcome(Path(sys.argv[1]), Path(sys.argv[2]))
|
|
200
|
+
except Exception:
|
|
201
|
+
pass
|
|
202
|
+
PY
|
|
203
|
+
|
|
192
204
|
local need_brief_val need_type_val
|
|
193
205
|
need_brief_val="$([[ -z "$BRIEF_PATH" ]] && printf '1' || printf '0')"
|
|
194
206
|
need_type_val="$([[ -z "$TASK_TYPE" ]] && printf '1' || printf '0')"
|
|
@@ -23,6 +23,7 @@ from .final_report_paths import (
|
|
|
23
23
|
final_report_markdown_path as _final_report_markdown_path,
|
|
24
24
|
)
|
|
25
25
|
from .lead_events import LeadEvent, append_lead_event
|
|
26
|
+
from .path_hints import hydrate_active_run_context
|
|
26
27
|
from .wrapper_status import status_path_for_prompt
|
|
27
28
|
from .paths import task_dir
|
|
28
29
|
from .worker_prompt_headers import WorkerPromptHeaderError, worker_prompt_headers
|
|
@@ -1402,7 +1403,7 @@ def _load_json_object(path: Path, label: str) -> dict[str, Any]:
|
|
|
1402
1403
|
def _load_optional_json_object(path: Path | None) -> dict[str, Any]:
|
|
1403
1404
|
if path is None or not path.is_file():
|
|
1404
1405
|
return {}
|
|
1405
|
-
return _load_json_object(path, "active-run-context")
|
|
1406
|
+
return hydrate_active_run_context(_load_json_object(path, "active-run-context"))
|
|
1406
1407
|
|
|
1407
1408
|
|
|
1408
1409
|
def _resolve_required_path(
|
|
@@ -16,6 +16,7 @@ from .final_report_paths import (
|
|
|
16
16
|
final_report_markdown_path as _final_report_markdown_path,
|
|
17
17
|
)
|
|
18
18
|
from .lead_events import LeadEvent, append_lead_event
|
|
19
|
+
from .path_hints import hydrate_active_run_context
|
|
19
20
|
from .worker_prompt_headers import WorkerPromptHeaderError, worker_prompt_headers
|
|
20
21
|
from .wrapper_status import read_wrapper_status, status_path_for_prompt
|
|
21
22
|
|
|
@@ -868,7 +869,9 @@ def _load_optional_json(project_root: Path, value: Any) -> dict[str, Any]:
|
|
|
868
869
|
if not isinstance(value, str) or not value.strip():
|
|
869
870
|
return {}
|
|
870
871
|
path = _resolve_project_path(project_root, value)
|
|
871
|
-
|
|
872
|
+
if not path.is_file():
|
|
873
|
+
return {}
|
|
874
|
+
return hydrate_active_run_context(_load_json_object(path, "active-run-context"))
|
|
872
875
|
|
|
873
876
|
|
|
874
877
|
def _resolve_required_path(project_root: Path, manifest: Mapping[str, Any], key: str) -> Path:
|
|
@@ -0,0 +1,271 @@
|
|
|
1
|
+
"""Artifact-derived implementation phase outcome reconciliation."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import json
|
|
5
|
+
from dataclasses import dataclass, field
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
from typing import Any
|
|
8
|
+
|
|
9
|
+
from .consumers import read_stage_consumer_state
|
|
10
|
+
from .workflow import DEFAULT_NEXT_PHASE, PHASE_SEQUENCE
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
@dataclass(frozen=True)
|
|
14
|
+
class ImplementationOutcome:
|
|
15
|
+
completed: bool
|
|
16
|
+
stages: list[int] = field(default_factory=list)
|
|
17
|
+
head_commit: str = ""
|
|
18
|
+
report_path: str = ""
|
|
19
|
+
next_recommended_phase: str = ""
|
|
20
|
+
reason: str = ""
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def derive_implementation_outcome(task_root: Path) -> ImplementationOutcome:
|
|
24
|
+
task_root = Path(task_root)
|
|
25
|
+
manifest = _load_json(task_root / "task-manifest.json")
|
|
26
|
+
workflow = manifest.get("workflow") if isinstance(manifest.get("workflow"), dict) else {}
|
|
27
|
+
if workflow.get("currentPhase") != "implementation":
|
|
28
|
+
return ImplementationOutcome(completed=False, reason="current phase is not implementation")
|
|
29
|
+
|
|
30
|
+
plan_run_root = task_root / "runs" / "implementation-planning"
|
|
31
|
+
if not plan_run_root.is_dir():
|
|
32
|
+
return ImplementationOutcome(completed=False, reason="implementation-planning run root missing")
|
|
33
|
+
|
|
34
|
+
stage_map = _load_stage_map(task_root, manifest)
|
|
35
|
+
if not stage_map:
|
|
36
|
+
return ImplementationOutcome(completed=False, reason="stage map unavailable")
|
|
37
|
+
|
|
38
|
+
consumers = read_stage_consumer_state(plan_run_root, recover_from_carry=True)
|
|
39
|
+
required_stages = {stage["stage_number"] for stage in stage_map}
|
|
40
|
+
if not required_stages or not required_stages.issubset(consumers.done_stages):
|
|
41
|
+
return ImplementationOutcome(completed=False, reason="not all stages are done")
|
|
42
|
+
|
|
43
|
+
carries = [_load_carry(task_root, stage) for stage in sorted(required_stages)]
|
|
44
|
+
if any(carry is None for carry in carries):
|
|
45
|
+
return ImplementationOutcome(completed=False, reason="carry sidecar missing")
|
|
46
|
+
if any(not _carry_passed(carry) for carry in carries if carry is not None):
|
|
47
|
+
return ImplementationOutcome(completed=False, reason="carry sidecar is not pass-grade")
|
|
48
|
+
|
|
49
|
+
latest_stage = max(required_stages)
|
|
50
|
+
latest_done = consumers.done_by_stage.get(latest_stage) or {}
|
|
51
|
+
latest_carry = carries[-1] or {}
|
|
52
|
+
return ImplementationOutcome(
|
|
53
|
+
completed=True,
|
|
54
|
+
stages=sorted(required_stages),
|
|
55
|
+
head_commit=str(latest_done.get("head_commit") or "") or _carry_head_commit(latest_carry),
|
|
56
|
+
report_path=_latest_implementation_report(task_root),
|
|
57
|
+
next_recommended_phase=DEFAULT_NEXT_PHASE["implementation"],
|
|
58
|
+
reason="all implementation stages have pass-grade carry and done rows",
|
|
59
|
+
)
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def reconcile_implementation_outcome(project_root: Path, task_root: Path) -> bool:
|
|
63
|
+
project_root = Path(project_root)
|
|
64
|
+
task_root = Path(task_root)
|
|
65
|
+
outcome = derive_implementation_outcome(task_root)
|
|
66
|
+
if not outcome.completed:
|
|
67
|
+
return False
|
|
68
|
+
|
|
69
|
+
manifest_path = task_root / "task-manifest.json"
|
|
70
|
+
manifest = _load_json(manifest_path)
|
|
71
|
+
workflow = manifest.get("workflow")
|
|
72
|
+
if not isinstance(workflow, dict):
|
|
73
|
+
workflow = {}
|
|
74
|
+
manifest["workflow"] = workflow
|
|
75
|
+
|
|
76
|
+
changed = _promote_workflow(workflow, outcome)
|
|
77
|
+
phase_outcome = manifest.get("phaseOutcome")
|
|
78
|
+
if not isinstance(phase_outcome, dict):
|
|
79
|
+
phase_outcome = {}
|
|
80
|
+
manifest["phaseOutcome"] = phase_outcome
|
|
81
|
+
|
|
82
|
+
contract = manifest.get("contractValidation")
|
|
83
|
+
contract_status = contract.get("status", "") if isinstance(contract, dict) else ""
|
|
84
|
+
implementation = {
|
|
85
|
+
"state": "completed",
|
|
86
|
+
"source": "implementation-carry",
|
|
87
|
+
"stages": outcome.stages,
|
|
88
|
+
"headCommit": outcome.head_commit,
|
|
89
|
+
"reportPath": outcome.report_path,
|
|
90
|
+
"contractValidationStatus": contract_status,
|
|
91
|
+
"reason": outcome.reason,
|
|
92
|
+
}
|
|
93
|
+
if phase_outcome.get("implementation") != implementation:
|
|
94
|
+
phase_outcome["implementation"] = implementation
|
|
95
|
+
changed = True
|
|
96
|
+
|
|
97
|
+
if changed:
|
|
98
|
+
_write_json(manifest_path, manifest)
|
|
99
|
+
return changed or _update_task_catalog(project_root, manifest)
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def _load_json(path: Path) -> dict[str, Any]:
|
|
103
|
+
if not path.is_file():
|
|
104
|
+
return {}
|
|
105
|
+
try:
|
|
106
|
+
data = json.loads(path.read_text(encoding="utf-8"))
|
|
107
|
+
except json.JSONDecodeError:
|
|
108
|
+
return {}
|
|
109
|
+
return data if isinstance(data, dict) else {}
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def _load_stage_map(task_root: Path, manifest: dict[str, Any]) -> list[dict[str, Any]]:
|
|
113
|
+
plan_path = _source_plan_path(task_root, manifest)
|
|
114
|
+
if not plan_path.is_file():
|
|
115
|
+
return []
|
|
116
|
+
from .run import PrepareError, _load_parsed_stage_map, _stage_map_reject_detail
|
|
117
|
+
|
|
118
|
+
try:
|
|
119
|
+
stages, errs = _load_parsed_stage_map(plan_path.read_text(encoding="utf-8"))
|
|
120
|
+
except PrepareError:
|
|
121
|
+
return []
|
|
122
|
+
if _stage_map_reject_detail(stages, errs) is not None:
|
|
123
|
+
return []
|
|
124
|
+
return [
|
|
125
|
+
{
|
|
126
|
+
"stage_number": stage.stage_number,
|
|
127
|
+
"title": stage.title,
|
|
128
|
+
"depends_on": list(stage.depends_on),
|
|
129
|
+
"step_count": stage.step_count,
|
|
130
|
+
}
|
|
131
|
+
for stage in stages
|
|
132
|
+
]
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
def _source_plan_path(task_root: Path, manifest: dict[str, Any]) -> Path:
|
|
136
|
+
carry_dir = task_root / "runs" / "implementation" / "carry"
|
|
137
|
+
for carry_path in sorted(carry_dir.glob("stage-*.json")):
|
|
138
|
+
value = _load_json(carry_path).get("sourcePlanPath")
|
|
139
|
+
if isinstance(value, str) and value:
|
|
140
|
+
return _resolve_relative(task_root, value)
|
|
141
|
+
|
|
142
|
+
latest = manifest.get("latestReportPath")
|
|
143
|
+
if isinstance(latest, str) and "implementation-planning" in latest:
|
|
144
|
+
return _resolve_relative(task_root, latest)
|
|
145
|
+
|
|
146
|
+
reports = task_root / "runs" / "implementation-planning" / "reports"
|
|
147
|
+
candidates = sorted(reports.glob("final-report-implementation-planning-*.md"))
|
|
148
|
+
return candidates[-1] if candidates else Path()
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
def _resolve_relative(task_root: Path, value: str) -> Path:
|
|
152
|
+
path = Path(value)
|
|
153
|
+
if path.is_absolute():
|
|
154
|
+
return path
|
|
155
|
+
project_root = _project_root_from_task_root(task_root)
|
|
156
|
+
project_relative = project_root / path
|
|
157
|
+
if project_relative.exists():
|
|
158
|
+
return project_relative
|
|
159
|
+
task_relative = task_root / path
|
|
160
|
+
if task_relative.exists():
|
|
161
|
+
return task_relative
|
|
162
|
+
return project_relative
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
def _project_root_from_task_root(task_root: Path) -> Path:
|
|
166
|
+
parts = task_root.resolve().parts
|
|
167
|
+
if ".okstra" not in parts:
|
|
168
|
+
return task_root.resolve()
|
|
169
|
+
return Path(*parts[: parts.index(".okstra")])
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
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
|
+
return data or None
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
def _carry_passed(carry: dict[str, Any]) -> bool:
|
|
178
|
+
status = str(carry.get("status") or "").lower()
|
|
179
|
+
if status in {"fail", "failed", "blocked", "error", "aborted"}:
|
|
180
|
+
return False
|
|
181
|
+
conformance = carry.get("conformance")
|
|
182
|
+
if isinstance(conformance, dict):
|
|
183
|
+
overall = str(conformance.get("overall") or "").upper()
|
|
184
|
+
if overall and overall != "PASS":
|
|
185
|
+
return False
|
|
186
|
+
unverified = carry.get("unverified")
|
|
187
|
+
return not (isinstance(unverified, list) and unverified)
|
|
188
|
+
|
|
189
|
+
|
|
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
|
+
|
|
206
|
+
def _latest_implementation_report(task_root: Path) -> str:
|
|
207
|
+
reports = sorted(
|
|
208
|
+
(task_root / "runs" / "implementation").glob(
|
|
209
|
+
"stage-*/reports/final-report-implementation-*.md"
|
|
210
|
+
)
|
|
211
|
+
)
|
|
212
|
+
if not reports:
|
|
213
|
+
return ""
|
|
214
|
+
project_root = _project_root_from_task_root(task_root)
|
|
215
|
+
try:
|
|
216
|
+
return str(reports[-1].relative_to(project_root))
|
|
217
|
+
except ValueError:
|
|
218
|
+
return str(reports[-1])
|
|
219
|
+
|
|
220
|
+
|
|
221
|
+
def _promote_workflow(workflow: dict[str, Any], outcome: ImplementationOutcome) -> bool:
|
|
222
|
+
before = json.dumps(workflow, sort_keys=True, ensure_ascii=False)
|
|
223
|
+
phase_states = workflow.get("phaseStates")
|
|
224
|
+
if not isinstance(phase_states, dict):
|
|
225
|
+
phase_states = {}
|
|
226
|
+
workflow["phaseStates"] = phase_states
|
|
227
|
+
for phase in PHASE_SEQUENCE:
|
|
228
|
+
phase_states.setdefault(phase, "not-started")
|
|
229
|
+
workflow["currentPhase"] = "implementation"
|
|
230
|
+
workflow["currentPhaseState"] = "completed"
|
|
231
|
+
phase_states["implementation"] = "completed"
|
|
232
|
+
workflow["lastCompletedPhase"] = "implementation"
|
|
233
|
+
workflow["nextRecommendedPhase"] = outcome.next_recommended_phase
|
|
234
|
+
after = json.dumps(workflow, sort_keys=True, ensure_ascii=False)
|
|
235
|
+
return before != after
|
|
236
|
+
|
|
237
|
+
|
|
238
|
+
def _update_task_catalog(project_root: Path, manifest: dict[str, Any]) -> bool:
|
|
239
|
+
catalog_path = project_root / ".okstra" / "discovery" / "task-catalog.json"
|
|
240
|
+
catalog = _load_json(catalog_path)
|
|
241
|
+
tasks = catalog.get("tasks")
|
|
242
|
+
if not isinstance(tasks, list):
|
|
243
|
+
return False
|
|
244
|
+
task_key = manifest.get("taskKey")
|
|
245
|
+
workflow = manifest.get("workflow") if isinstance(manifest.get("workflow"), dict) else {}
|
|
246
|
+
outcome = manifest.get("phaseOutcome") if isinstance(manifest.get("phaseOutcome"), dict) else {}
|
|
247
|
+
for entry in tasks:
|
|
248
|
+
if not isinstance(entry, dict) or entry.get("taskKey") != task_key:
|
|
249
|
+
continue
|
|
250
|
+
before = json.dumps(entry, sort_keys=True, ensure_ascii=False)
|
|
251
|
+
entry["currentPhase"] = workflow.get("currentPhase", "")
|
|
252
|
+
entry["currentPhaseState"] = workflow.get("currentPhaseState", "")
|
|
253
|
+
entry["lastCompletedPhase"] = workflow.get("lastCompletedPhase", "")
|
|
254
|
+
entry["nextRecommendedPhase"] = workflow.get("nextRecommendedPhase", "")
|
|
255
|
+
entry["latestReportPath"] = manifest.get("latestReportPath", "")
|
|
256
|
+
entry["latestRunStatus"] = manifest.get("latestRunStatus", entry.get("latestRunStatus", ""))
|
|
257
|
+
entry["currentStatus"] = manifest.get("currentStatus", entry.get("currentStatus", ""))
|
|
258
|
+
entry["phaseOutcome"] = outcome
|
|
259
|
+
if before == json.dumps(entry, sort_keys=True, ensure_ascii=False):
|
|
260
|
+
return False
|
|
261
|
+
_write_json(catalog_path, catalog)
|
|
262
|
+
return True
|
|
263
|
+
return False
|
|
264
|
+
|
|
265
|
+
|
|
266
|
+
def _write_json(path: Path, payload: dict[str, Any]) -> None:
|
|
267
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
268
|
+
path.write_text(
|
|
269
|
+
json.dumps(payload, ensure_ascii=False, indent=2) + "\n",
|
|
270
|
+
encoding="utf-8",
|
|
271
|
+
)
|
|
@@ -0,0 +1,665 @@
|
|
|
1
|
+
"""Compact path-hint persistence and legacy context hydration."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
from typing import Any, Mapping
|
|
6
|
+
|
|
7
|
+
from okstra_project.dirs import (
|
|
8
|
+
DISCOVERY_RELATIVE,
|
|
9
|
+
OKSTRA_RELATIVE,
|
|
10
|
+
TASKS_RELATIVE,
|
|
11
|
+
okstra_home,
|
|
12
|
+
)
|
|
13
|
+
|
|
14
|
+
RUN_CONTEXT_KIND = "run-context"
|
|
15
|
+
RUN_CONTEXT_SCHEMA_VERSION = "2.0"
|
|
16
|
+
ACTIVE_CONTEXT_KIND = "active-run-context"
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def compact_run_context(ctx: Mapping[str, Any]) -> dict[str, Any]:
|
|
20
|
+
"""Persist only path clues that can rehydrate the legacy flat context."""
|
|
21
|
+
return {
|
|
22
|
+
"schemaVersion": RUN_CONTEXT_SCHEMA_VERSION,
|
|
23
|
+
"kind": RUN_CONTEXT_KIND,
|
|
24
|
+
"identity": _compact_identity(ctx),
|
|
25
|
+
"pathHints": {
|
|
26
|
+
"project": _compact_project_hints(),
|
|
27
|
+
"task": {
|
|
28
|
+
"taskRoot": ctx.get("TASK_ROOT_RELATIVE_PATH", ""),
|
|
29
|
+
},
|
|
30
|
+
"run": _compact_run_hints(ctx),
|
|
31
|
+
"runtime": {
|
|
32
|
+
"okstraHome": str(okstra_home()),
|
|
33
|
+
},
|
|
34
|
+
},
|
|
35
|
+
"timestamps": {
|
|
36
|
+
"runTimestampIso": ctx.get("RUN_TIMESTAMP_ISO", ""),
|
|
37
|
+
"taskDate": ctx.get("TASK_DATE", ""),
|
|
38
|
+
},
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def compact_active_run_context(
|
|
43
|
+
ctx: Mapping[str, Any],
|
|
44
|
+
payload: Mapping[str, Any],
|
|
45
|
+
) -> dict[str, Any]:
|
|
46
|
+
"""Persist active-run-context with path hints instead of repeated paths."""
|
|
47
|
+
run_context = compact_run_context(ctx)
|
|
48
|
+
return {
|
|
49
|
+
"schemaVersion": RUN_CONTEXT_SCHEMA_VERSION,
|
|
50
|
+
"kind": ACTIVE_CONTEXT_KIND,
|
|
51
|
+
"identity": run_context["identity"],
|
|
52
|
+
"pathHints": run_context["pathHints"],
|
|
53
|
+
"task": _compact_active_task(payload),
|
|
54
|
+
"workflow": dict(_mapping(payload.get("workflow"))),
|
|
55
|
+
"run": {
|
|
56
|
+
"stage": ctx.get("RUN_STAGE", ""),
|
|
57
|
+
},
|
|
58
|
+
"inputs": _compact_active_inputs(payload),
|
|
59
|
+
"workers": _compact_active_workers(payload),
|
|
60
|
+
"executorWorktree": dict(_mapping(payload.get("executorWorktree"))),
|
|
61
|
+
"lazyReadPlan": dict(_mapping(payload.get("lazyReadPlan"))),
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def hydrate_run_context(payload: Mapping[str, Any]) -> dict[str, Any]:
|
|
66
|
+
"""Return the legacy flat context for compact run-context payloads."""
|
|
67
|
+
if payload.get("kind") != RUN_CONTEXT_KIND:
|
|
68
|
+
return dict(payload)
|
|
69
|
+
hints = payload.get("pathHints")
|
|
70
|
+
identity = payload.get("identity")
|
|
71
|
+
if not isinstance(hints, Mapping) or not isinstance(identity, Mapping):
|
|
72
|
+
return dict(payload)
|
|
73
|
+
return _hydrate_from_hints(identity, hints, payload.get("timestamps", {}))
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def hydrate_active_run_context(payload: Mapping[str, Any]) -> dict[str, Any]:
|
|
77
|
+
"""Return the legacy active-run-context shape for compact payloads."""
|
|
78
|
+
if payload.get("kind") != ACTIVE_CONTEXT_KIND or "pathHints" not in payload:
|
|
79
|
+
return dict(payload)
|
|
80
|
+
identity = _mapping(payload.get("identity"))
|
|
81
|
+
hints = _mapping(payload.get("pathHints"))
|
|
82
|
+
ctx = hydrate_run_context({
|
|
83
|
+
"kind": RUN_CONTEXT_KIND,
|
|
84
|
+
"identity": identity,
|
|
85
|
+
"pathHints": hints,
|
|
86
|
+
"timestamps": {},
|
|
87
|
+
})
|
|
88
|
+
return {
|
|
89
|
+
"schemaVersion": "1.0",
|
|
90
|
+
"kind": ACTIVE_CONTEXT_KIND,
|
|
91
|
+
"task": _hydrate_active_task(payload, ctx),
|
|
92
|
+
"workflow": dict(_mapping(payload.get("workflow"))),
|
|
93
|
+
"run": _hydrate_active_run(ctx),
|
|
94
|
+
"instructionSet": _hydrate_active_instruction_set(payload, ctx),
|
|
95
|
+
"workers": _hydrate_active_workers(payload, ctx),
|
|
96
|
+
"errorLogs": _hydrate_active_error_logs(ctx),
|
|
97
|
+
"runtimeResources": {
|
|
98
|
+
"codingPreflightDir": ctx.get("OKSTRA_CODING_PREFLIGHT_DIR", ""),
|
|
99
|
+
},
|
|
100
|
+
"executorWorktree": dict(_mapping(payload.get("executorWorktree"))),
|
|
101
|
+
"sourceArtifacts": _hydrate_active_source_artifacts(ctx),
|
|
102
|
+
"lazyReadPlan": dict(_mapping(payload.get("lazyReadPlan"))),
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def _compact_identity(ctx: Mapping[str, Any]) -> dict[str, Any]:
|
|
107
|
+
return {
|
|
108
|
+
"projectId": ctx.get("PROJECT_ID", ""),
|
|
109
|
+
"projectRoot": ctx.get("PROJECT_ROOT", ""),
|
|
110
|
+
"workspaceRoot": ctx.get("WORKSPACE_ROOT", ""),
|
|
111
|
+
"taskGroup": ctx.get("TASK_GROUP", ""),
|
|
112
|
+
"taskId": ctx.get("TASK_ID", ""),
|
|
113
|
+
"taskKey": ctx.get("TASK_KEY", ""),
|
|
114
|
+
"taskType": ctx.get("TASK_TYPE", ""),
|
|
115
|
+
"segments": {
|
|
116
|
+
"taskGroup": ctx.get("TASK_GROUP_SEGMENT", ""),
|
|
117
|
+
"taskId": ctx.get("TASK_ID_SEGMENT", ""),
|
|
118
|
+
"taskType": ctx.get("TASK_TYPE_SEGMENT", ""),
|
|
119
|
+
},
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def _compact_active_task(payload: Mapping[str, Any]) -> dict[str, Any]:
|
|
124
|
+
task = _mapping(payload.get("task"))
|
|
125
|
+
keep = (
|
|
126
|
+
"projectId", "taskGroup", "taskId", "taskKey", "taskType",
|
|
127
|
+
"workCategory", "projectRoot",
|
|
128
|
+
)
|
|
129
|
+
return {key: task.get(key, "") for key in keep}
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def _compact_active_inputs(payload: Mapping[str, Any]) -> dict[str, bool]:
|
|
133
|
+
instruction_set = _mapping(payload.get("instructionSet"))
|
|
134
|
+
return {
|
|
135
|
+
"hasClarificationResponse": bool(instruction_set.get("clarificationResponsePath")),
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
def _compact_active_workers(payload: Mapping[str, Any]) -> list[dict[str, Any]]:
|
|
140
|
+
workers = payload.get("workers")
|
|
141
|
+
if not isinstance(workers, list):
|
|
142
|
+
return []
|
|
143
|
+
return [_compact_active_worker(worker) for worker in workers if isinstance(worker, Mapping)]
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
def _compact_active_worker(worker: Mapping[str, Any]) -> dict[str, Any]:
|
|
147
|
+
keep = (
|
|
148
|
+
"workerId", "role", "agent", "agentLabel", "model",
|
|
149
|
+
"modelExecutionValue", "attemptRequired",
|
|
150
|
+
)
|
|
151
|
+
return {key: worker.get(key, "") for key in keep}
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
def _compact_project_hints() -> dict[str, str]:
|
|
155
|
+
return {
|
|
156
|
+
"okstraRoot": str(OKSTRA_RELATIVE),
|
|
157
|
+
"tasksRoot": str(TASKS_RELATIVE),
|
|
158
|
+
"discoveryRoot": str(DISCOVERY_RELATIVE),
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
def _hydrate_active_task(payload: Mapping[str, Any], ctx: Mapping[str, str]) -> dict[str, str]:
|
|
163
|
+
task = dict(_mapping(payload.get("task")))
|
|
164
|
+
task["taskRootPath"] = ctx.get("TASK_ROOT_RELATIVE_PATH", "")
|
|
165
|
+
return task
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
def _hydrate_active_run(ctx: Mapping[str, str]) -> dict[str, str]:
|
|
169
|
+
return {
|
|
170
|
+
"runDirectoryPath": ctx.get("RUN_DIR_RELATIVE_PATH", ""),
|
|
171
|
+
"runManifestPath": ctx.get("RUN_MANIFEST_RELATIVE_PATH", ""),
|
|
172
|
+
"teamStatePath": ctx.get("TEAM_STATE_RELATIVE_PATH", ""),
|
|
173
|
+
"promptSnapshotPath": ctx.get("RUN_PROMPT_SNAPSHOT_RELATIVE_PATH", ""),
|
|
174
|
+
"finalReportPath": ctx.get("FINAL_REPORT_RELATIVE_PATH", ""),
|
|
175
|
+
"finalStatusPath": ctx.get("FINAL_STATUS_RELATIVE_PATH", ""),
|
|
176
|
+
"validatorScriptPath": ctx.get("RUN_VALIDATOR_RELATIVE_PATH", ""),
|
|
177
|
+
"resumeCommandPath": ctx.get("CLAUDE_RESUME_COMMAND_RELATIVE_PATH", ""),
|
|
178
|
+
"workerPromptsDirectoryPath": ctx.get("RUN_PROMPTS_RELATIVE_PATH", ""),
|
|
179
|
+
"workerResultsDirectoryPath": ctx.get("WORKER_RESULTS_RELATIVE_PATH", ""),
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
def _hydrate_active_instruction_set(
|
|
184
|
+
payload: Mapping[str, Any],
|
|
185
|
+
ctx: Mapping[str, str],
|
|
186
|
+
) -> dict[str, str]:
|
|
187
|
+
instruction_set = ctx.get("INSTRUCTION_SET_RELATIVE_PATH", "")
|
|
188
|
+
inputs = _mapping(payload.get("inputs"))
|
|
189
|
+
clarification_response = ""
|
|
190
|
+
if inputs.get("hasClarificationResponse"):
|
|
191
|
+
clarification_response = f"{instruction_set}/clarification-response.md"
|
|
192
|
+
return {
|
|
193
|
+
"path": instruction_set,
|
|
194
|
+
"analysisPacketPath": ctx.get("ANALYSIS_PACKET_RELATIVE_PATH", ""),
|
|
195
|
+
"taskBriefPath": f"{instruction_set}/task-brief.md",
|
|
196
|
+
"analysisProfilePath": f"{instruction_set}/analysis-profile.md",
|
|
197
|
+
"analysisMaterialPath": f"{instruction_set}/analysis-material.md",
|
|
198
|
+
"referenceExpectationsPath": ctx.get("REFERENCE_EXPECTATIONS_RELATIVE_PATH", ""),
|
|
199
|
+
"clarificationResponsePath": clarification_response,
|
|
200
|
+
"finalReportTemplatePath": ctx.get("FINAL_REPORT_TEMPLATE_RELATIVE_PATH", ""),
|
|
201
|
+
"finalReportSchemaPath": ctx.get("FINAL_REPORT_SCHEMA_RELATIVE_PATH", ""),
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
|
|
205
|
+
def _hydrate_active_workers(
|
|
206
|
+
payload: Mapping[str, Any],
|
|
207
|
+
ctx: Mapping[str, str],
|
|
208
|
+
) -> list[dict[str, Any]]:
|
|
209
|
+
workers = payload.get("workers")
|
|
210
|
+
if not isinstance(workers, list):
|
|
211
|
+
return []
|
|
212
|
+
return [
|
|
213
|
+
_hydrate_active_worker(worker, ctx)
|
|
214
|
+
for worker in workers
|
|
215
|
+
if isinstance(worker, Mapping)
|
|
216
|
+
]
|
|
217
|
+
|
|
218
|
+
|
|
219
|
+
def _hydrate_active_worker(
|
|
220
|
+
worker: Mapping[str, Any],
|
|
221
|
+
ctx: Mapping[str, str],
|
|
222
|
+
) -> dict[str, Any]:
|
|
223
|
+
worker_id = str(worker.get("workerId", ""))
|
|
224
|
+
hydrated = dict(worker)
|
|
225
|
+
hydrated["promptPath"] = ctx.get(_worker_path_key(worker_id, "prompt"), "")
|
|
226
|
+
hydrated["resultPath"] = ctx.get(_worker_path_key(worker_id, "result"), "")
|
|
227
|
+
return hydrated
|
|
228
|
+
|
|
229
|
+
|
|
230
|
+
def _hydrate_active_error_logs(ctx: Mapping[str, str]) -> dict[str, Any]:
|
|
231
|
+
return {
|
|
232
|
+
"runErrorsLogPath": ctx.get("RUN_ERRORS_LOG_RELATIVE_PATH", ""),
|
|
233
|
+
"sidecarsByWorkerId": {
|
|
234
|
+
"claude": ctx.get("CLAUDE_WORKER_ERRORS_SIDECAR_RELATIVE_PATH", ""),
|
|
235
|
+
"codex": ctx.get("CODEX_WORKER_ERRORS_SIDECAR_RELATIVE_PATH", ""),
|
|
236
|
+
"antigravity": ctx.get("ANTIGRAVITY_WORKER_ERRORS_SIDECAR_RELATIVE_PATH", ""),
|
|
237
|
+
"report-writer": ctx.get("REPORT_WRITER_WORKER_ERRORS_SIDECAR_RELATIVE_PATH", ""),
|
|
238
|
+
},
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
|
|
242
|
+
def _hydrate_active_source_artifacts(ctx: Mapping[str, str]) -> dict[str, str]:
|
|
243
|
+
return {
|
|
244
|
+
"taskManifestPath": ctx.get("TASK_MANIFEST_RELATIVE_PATH", ""),
|
|
245
|
+
"runContextPath": ctx.get("RUN_CONTEXT_RELATIVE_PATH", ""),
|
|
246
|
+
"runInputsPath": ctx.get("RUN_INPUTS_RELATIVE_PATH", ""),
|
|
247
|
+
"historyTimelinePath": ctx.get("TIMELINE_RELATIVE_PATH", ""),
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
|
|
251
|
+
def _worker_path_key(worker_id: str, kind: str) -> str:
|
|
252
|
+
by_worker = {
|
|
253
|
+
"claude": ("CLAUDE_WORKER_PROMPT_RELATIVE_PATH", "CLAUDE_WORKER_RESULT_RELATIVE_PATH"),
|
|
254
|
+
"codex": ("CODEX_WORKER_PROMPT_RELATIVE_PATH", "CODEX_WORKER_RESULT_RELATIVE_PATH"),
|
|
255
|
+
"antigravity": (
|
|
256
|
+
"ANTIGRAVITY_WORKER_PROMPT_RELATIVE_PATH",
|
|
257
|
+
"ANTIGRAVITY_WORKER_RESULT_RELATIVE_PATH",
|
|
258
|
+
),
|
|
259
|
+
"report-writer": (
|
|
260
|
+
"REPORT_WRITER_WORKER_PROMPT_RELATIVE_PATH",
|
|
261
|
+
"REPORT_WRITER_WORKER_RESULT_RELATIVE_PATH",
|
|
262
|
+
),
|
|
263
|
+
}
|
|
264
|
+
index = 0 if kind == "prompt" else 1
|
|
265
|
+
return by_worker.get(worker_id, ("", ""))[index]
|
|
266
|
+
|
|
267
|
+
|
|
268
|
+
def _compact_run_hints(ctx: Mapping[str, Any]) -> dict[str, Any]:
|
|
269
|
+
return {
|
|
270
|
+
"runRoot": ctx.get("RUN_DIR_RELATIVE_PATH", ""),
|
|
271
|
+
"stage": ctx.get("RUN_STAGE", ""),
|
|
272
|
+
"fileSuffix": ctx.get("RUN_FILE_SUFFIX", ""),
|
|
273
|
+
"sequences": {
|
|
274
|
+
"manifests": ctx.get("RUN_MANIFESTS_SEQ", ""),
|
|
275
|
+
"prompts": ctx.get("RUN_PROMPTS_SEQ", ""),
|
|
276
|
+
"reports": ctx.get("RUN_REPORTS_SEQ", ""),
|
|
277
|
+
"status": ctx.get("RUN_STATUS_SEQ", ""),
|
|
278
|
+
"state": ctx.get("RUN_STATE_SEQ", ""),
|
|
279
|
+
"sessions": ctx.get("RUN_SESSIONS_SEQ", ""),
|
|
280
|
+
"workerResults": ctx.get("WORKER_RESULTS_SEQ", ""),
|
|
281
|
+
},
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
|
|
285
|
+
def _hydrate_from_hints(
|
|
286
|
+
identity: Mapping[str, Any],
|
|
287
|
+
hints: Mapping[str, Any],
|
|
288
|
+
timestamps: Any,
|
|
289
|
+
) -> dict[str, Any]:
|
|
290
|
+
paths = _build_path_set(identity, hints)
|
|
291
|
+
filenames = _filename_fields(paths)
|
|
292
|
+
relative = _relative_fields(paths["project_root"], paths)
|
|
293
|
+
return {
|
|
294
|
+
**_identity_fields(identity),
|
|
295
|
+
**_runtime_fields(paths),
|
|
296
|
+
**_absolute_fields(paths),
|
|
297
|
+
**filenames,
|
|
298
|
+
**_sequence_fields(paths),
|
|
299
|
+
**relative,
|
|
300
|
+
**_timestamp_fields(timestamps),
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
|
|
304
|
+
def _build_path_set(identity: Mapping[str, Any], hints: Mapping[str, Any]) -> dict[str, Any]:
|
|
305
|
+
project_root = Path(str(identity.get("projectRoot", "")))
|
|
306
|
+
workspace_root = Path(str(identity.get("workspaceRoot", "")))
|
|
307
|
+
project_hints = _mapping(hints.get("project"))
|
|
308
|
+
run_hints = _mapping(hints.get("run"))
|
|
309
|
+
task_root = _project_path(project_root, _task_root_hint(hints))
|
|
310
|
+
run_root = _project_path(project_root, str(run_hints.get("runRoot", "")))
|
|
311
|
+
task_type_segment = _segment(identity, "taskType")
|
|
312
|
+
sequences = _sequences(run_hints)
|
|
313
|
+
suffixes = _suffixes(task_type_segment, sequences)
|
|
314
|
+
paths = {
|
|
315
|
+
"project_root": project_root,
|
|
316
|
+
"workspace_root": workspace_root,
|
|
317
|
+
"okstra_root": _project_path(project_root, str(project_hints.get("okstraRoot", OKSTRA_RELATIVE))),
|
|
318
|
+
"tasks_root": _project_path(project_root, str(project_hints.get("tasksRoot", TASKS_RELATIVE))),
|
|
319
|
+
"discovery_dir": _project_path(project_root, str(project_hints.get("discoveryRoot", DISCOVERY_RELATIVE))),
|
|
320
|
+
"task_root": task_root,
|
|
321
|
+
"run_dir": run_root,
|
|
322
|
+
"runtime_home": Path(str(_mapping(hints.get("runtime")).get("okstraHome", okstra_home()))),
|
|
323
|
+
"task_type_segment": task_type_segment,
|
|
324
|
+
"run_stage": str(run_hints.get("stage", "")),
|
|
325
|
+
"run_file_suffix": str(run_hints.get("fileSuffix", suffixes["reports"])),
|
|
326
|
+
"sequences": sequences,
|
|
327
|
+
"suffixes": suffixes,
|
|
328
|
+
}
|
|
329
|
+
paths.update(_task_path_set(task_root))
|
|
330
|
+
paths.update(_run_path_set(run_root, task_type_segment, sequences, suffixes))
|
|
331
|
+
paths.update(_discovery_path_set(paths["discovery_dir"]))
|
|
332
|
+
return paths
|
|
333
|
+
|
|
334
|
+
|
|
335
|
+
def _task_path_set(task_root: Path) -> dict[str, Path]:
|
|
336
|
+
instruction_set = task_root / "instruction-set"
|
|
337
|
+
history_dir = task_root / "history"
|
|
338
|
+
recap_dir = task_root / "recap"
|
|
339
|
+
return {
|
|
340
|
+
"task_manifest": task_root / "task-manifest.json",
|
|
341
|
+
"task_index": task_root / "task-index.md",
|
|
342
|
+
"instruction_set": instruction_set,
|
|
343
|
+
"analysis_packet": instruction_set / "analysis-packet.md",
|
|
344
|
+
"task_qa": task_root / "qa",
|
|
345
|
+
"runs_dir": task_root / "runs",
|
|
346
|
+
"history_dir": history_dir,
|
|
347
|
+
"timeline_file": history_dir / "timeline.json",
|
|
348
|
+
"recap_dir": recap_dir,
|
|
349
|
+
"recap_log": recap_dir / "recap-log.jsonl",
|
|
350
|
+
"final_report_template": instruction_set / "final-report-template.md",
|
|
351
|
+
"final_report_schema": instruction_set / "final-report-schema.json",
|
|
352
|
+
"reference_expectations": instruction_set / "reference-expectations.md",
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
|
|
356
|
+
def _run_path_set(
|
|
357
|
+
run_dir: Path,
|
|
358
|
+
task_type_segment: str,
|
|
359
|
+
sequences: Mapping[str, str],
|
|
360
|
+
suffixes: Mapping[str, str],
|
|
361
|
+
) -> dict[str, Path]:
|
|
362
|
+
run_prompts = run_dir / "prompts"
|
|
363
|
+
worker_results = run_dir / "worker-results"
|
|
364
|
+
return {
|
|
365
|
+
"run_manifests": run_dir / "manifests",
|
|
366
|
+
"run_state": run_dir / "state",
|
|
367
|
+
"run_prompts": run_prompts,
|
|
368
|
+
"run_reports": run_dir / "reports",
|
|
369
|
+
"run_status": run_dir / "status",
|
|
370
|
+
"run_sessions": run_dir / "sessions",
|
|
371
|
+
"run_logs": run_dir / "logs",
|
|
372
|
+
"worker_results": worker_results,
|
|
373
|
+
"run_carry": run_dir / "carry",
|
|
374
|
+
**_run_files(run_dir, run_prompts, worker_results, task_type_segment, sequences, suffixes),
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
|
|
378
|
+
def _run_files(
|
|
379
|
+
run_dir: Path,
|
|
380
|
+
run_prompts: Path,
|
|
381
|
+
worker_results: Path,
|
|
382
|
+
task_type_segment: str,
|
|
383
|
+
sequences: Mapping[str, str],
|
|
384
|
+
suffixes: Mapping[str, str],
|
|
385
|
+
) -> dict[str, Path]:
|
|
386
|
+
run_manifests = run_dir / "manifests"
|
|
387
|
+
run_state = run_dir / "state"
|
|
388
|
+
run_reports = run_dir / "reports"
|
|
389
|
+
run_status = run_dir / "status"
|
|
390
|
+
run_sessions = run_dir / "sessions"
|
|
391
|
+
run_logs = run_dir / "logs"
|
|
392
|
+
return {
|
|
393
|
+
"run_manifest_file": run_manifests / f"run-manifest{suffixes['manifests']}.json",
|
|
394
|
+
"run_context_file": run_manifests / f"run-context-{task_type_segment}-{sequences['manifests']}.json",
|
|
395
|
+
"run_inputs_file": run_manifests / f"run-inputs-{task_type_segment}-{sequences['manifests']}.json",
|
|
396
|
+
"run_prompt_snapshot": run_prompts / f"claude-execution-prompt{suffixes['prompts']}.md",
|
|
397
|
+
"claude_worker_prompt": run_prompts / f"claude-worker-prompt{suffixes['prompts']}.md",
|
|
398
|
+
"codex_worker_prompt": run_prompts / f"codex-worker-prompt{suffixes['prompts']}.md",
|
|
399
|
+
"antigravity_worker_prompt": run_prompts / f"antigravity-worker-prompt{suffixes['prompts']}.md",
|
|
400
|
+
"report_writer_worker_prompt": run_prompts / f"report-writer-worker-prompt{suffixes['prompts']}.md",
|
|
401
|
+
"final_report": run_reports / f"final-report{suffixes['reports']}.md",
|
|
402
|
+
"final_status": run_status / f"final{suffixes['status']}.status",
|
|
403
|
+
"team_state": run_state / f"team-state{suffixes['state']}.json",
|
|
404
|
+
"active_run_context": run_state / f"active-run-context{suffixes['state']}.json",
|
|
405
|
+
"lead_events": run_state / f"lead-events-{task_type_segment}-{sequences['state']}.jsonl",
|
|
406
|
+
"claude_resume_command": run_sessions / f"claude-resume{suffixes['sessions']}.sh",
|
|
407
|
+
"claude_worker_result": worker_results / f"claude-worker{suffixes['worker_results']}.md",
|
|
408
|
+
"codex_worker_result": worker_results / f"codex-worker{suffixes['worker_results']}.md",
|
|
409
|
+
"antigravity_worker_result": worker_results / f"antigravity-worker{suffixes['worker_results']}.md",
|
|
410
|
+
"report_writer_worker_result": worker_results / f"report-writer-worker{suffixes['worker_results']}.md",
|
|
411
|
+
"run_errors_log": run_logs / f"errors-{task_type_segment}-{sequences['state']}.jsonl",
|
|
412
|
+
"claude_worker_errors_sidecar": worker_results / f"claude-worker-errors{suffixes['worker_results']}.json",
|
|
413
|
+
"codex_worker_errors_sidecar": worker_results / f"codex-worker-errors{suffixes['worker_results']}.json",
|
|
414
|
+
"antigravity_worker_errors_sidecar": worker_results / f"antigravity-worker-errors{suffixes['worker_results']}.json",
|
|
415
|
+
"report_writer_errors_sidecar": worker_results / f"report-writer-worker-errors{suffixes['worker_results']}.json",
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
|
|
419
|
+
def _discovery_path_set(discovery_dir: Path) -> dict[str, Path]:
|
|
420
|
+
return {
|
|
421
|
+
"latest_task_file": discovery_dir / "latest-task.json",
|
|
422
|
+
"task_catalog_file": discovery_dir / "task-catalog.json",
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
|
|
426
|
+
def _identity_fields(identity: Mapping[str, Any]) -> dict[str, str]:
|
|
427
|
+
return {
|
|
428
|
+
"PROJECT_ID": str(identity.get("projectId", "")),
|
|
429
|
+
"PROJECT_ROOT": str(identity.get("projectRoot", "")),
|
|
430
|
+
"WORKSPACE_ROOT": str(identity.get("workspaceRoot", "")),
|
|
431
|
+
"TASK_GROUP": str(identity.get("taskGroup", "")),
|
|
432
|
+
"TASK_ID": str(identity.get("taskId", "")),
|
|
433
|
+
"TASK_KEY": str(identity.get("taskKey", "")),
|
|
434
|
+
"TASK_TYPE": str(identity.get("taskType", "")),
|
|
435
|
+
"TASK_GROUP_SEGMENT": _segment(identity, "taskGroup"),
|
|
436
|
+
"TASK_ID_SEGMENT": _segment(identity, "taskId"),
|
|
437
|
+
"TASK_TYPE_SEGMENT": _segment(identity, "taskType"),
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
|
|
441
|
+
def _runtime_fields(paths: Mapping[str, Any]) -> dict[str, str]:
|
|
442
|
+
runtime_home = Path(paths["runtime_home"])
|
|
443
|
+
lead = runtime_home / "prompts" / "lead"
|
|
444
|
+
return {
|
|
445
|
+
"WORKER_PROMPT_PREAMBLE_PATH": str(runtime_home / "templates" / "worker-prompt-preamble.md"),
|
|
446
|
+
"OKSTRA_LEAD_CONTRACT_PATH": str(lead / "okstra-lead-contract.md"),
|
|
447
|
+
"OKSTRA_CONTEXT_LOADER_PATH": str(lead / "context-loader.md"),
|
|
448
|
+
"OKSTRA_TEAM_CONTRACT_PATH": str(lead / "team-contract.md"),
|
|
449
|
+
"OKSTRA_CONVERGENCE_PATH": str(lead / "convergence.md"),
|
|
450
|
+
"OKSTRA_REPORT_WRITER_PATH": str(lead / "report-writer.md"),
|
|
451
|
+
"OKSTRA_CODING_PREFLIGHT_DIR": str(runtime_home / "prompts" / "coding-preflight"),
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
|
|
455
|
+
def _absolute_fields(paths: Mapping[str, Any]) -> dict[str, str]:
|
|
456
|
+
return {
|
|
457
|
+
**_absolute_project_task_fields(paths),
|
|
458
|
+
**_absolute_run_fields(paths),
|
|
459
|
+
**_absolute_worker_fields(paths),
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
|
|
463
|
+
def _absolute_project_task_fields(paths: Mapping[str, Any]) -> dict[str, str]:
|
|
464
|
+
return {
|
|
465
|
+
"OKSTRA_ROOT": str(paths["okstra_root"]),
|
|
466
|
+
"OKSTRA_TASKS_ROOT": str(paths["tasks_root"]),
|
|
467
|
+
"OKSTRA_DISCOVERY_DIR": str(paths["discovery_dir"]),
|
|
468
|
+
"TASK_ROOT": str(paths["task_root"]),
|
|
469
|
+
"TASK_MANIFEST_PATH": str(paths["task_manifest"]),
|
|
470
|
+
"TASK_INDEX_PATH": str(paths["task_index"]),
|
|
471
|
+
"INSTRUCTION_SET_PATH": str(paths["instruction_set"]),
|
|
472
|
+
"ANALYSIS_PACKET_PATH": str(paths["analysis_packet"]),
|
|
473
|
+
"TASK_QA_PATH": str(paths["task_qa"]),
|
|
474
|
+
"RUNS_DIR": str(paths["runs_dir"]),
|
|
475
|
+
"HISTORY_DIR": str(paths["history_dir"]),
|
|
476
|
+
"TIMELINE_PATH": str(paths["timeline_file"]),
|
|
477
|
+
"RECAP_DIR": str(paths["recap_dir"]),
|
|
478
|
+
"RECAP_LOG_PATH": str(paths["recap_log"]),
|
|
479
|
+
"FINAL_REPORT_TEMPLATE_PATH": str(paths["final_report_template"]),
|
|
480
|
+
"FINAL_REPORT_SCHEMA_PATH": str(paths["final_report_schema"]),
|
|
481
|
+
"REFERENCE_EXPECTATIONS_FILE": str(paths["reference_expectations"]),
|
|
482
|
+
"OKSTRA_LATEST_TASK_FILE": str(paths["latest_task_file"]),
|
|
483
|
+
"OKSTRA_TASK_CATALOG_FILE": str(paths["task_catalog_file"]),
|
|
484
|
+
}
|
|
485
|
+
|
|
486
|
+
|
|
487
|
+
def _absolute_run_fields(paths: Mapping[str, Any]) -> dict[str, str]:
|
|
488
|
+
return {
|
|
489
|
+
"RUN_DIR": str(paths["run_dir"]),
|
|
490
|
+
"RUN_STAGE": str(paths["run_stage"]),
|
|
491
|
+
"RUN_MANIFESTS_DIR": str(paths["run_manifests"]),
|
|
492
|
+
"RUN_STATE_DIR": str(paths["run_state"]),
|
|
493
|
+
"RUN_PROMPTS_DIR": str(paths["run_prompts"]),
|
|
494
|
+
"RUN_REPORTS_DIR": str(paths["run_reports"]),
|
|
495
|
+
"RUN_STATUS_DIR": str(paths["run_status"]),
|
|
496
|
+
"RUN_SESSIONS_DIR": str(paths["run_sessions"]),
|
|
497
|
+
"RUN_LOGS_DIR": str(paths["run_logs"]),
|
|
498
|
+
"WORKER_RESULTS_PATH": str(paths["worker_results"]),
|
|
499
|
+
"RUN_CARRY_PATH": str(paths["run_carry"]),
|
|
500
|
+
"RUN_MANIFEST_PATH": str(paths["run_manifest_file"]),
|
|
501
|
+
"RUN_CONTEXT_FILE": str(paths["run_context_file"]),
|
|
502
|
+
"RUN_PROMPT_SNAPSHOT_FILE": str(paths["run_prompt_snapshot"]),
|
|
503
|
+
"FINAL_REPORT_PATH": str(paths["final_report"]),
|
|
504
|
+
"FINAL_STATUS_PATH": str(paths["final_status"]),
|
|
505
|
+
"TEAM_STATE_PATH": str(paths["team_state"]),
|
|
506
|
+
"ACTIVE_RUN_CONTEXT_PATH": str(paths["active_run_context"]),
|
|
507
|
+
"LEAD_EVENTS_PATH": str(paths["lead_events"]),
|
|
508
|
+
"CLAUDE_RESUME_COMMAND_PATH": str(paths["claude_resume_command"]),
|
|
509
|
+
"RUN_ERRORS_LOG_PATH": str(paths["run_errors_log"]),
|
|
510
|
+
"RUN_VALIDATOR_PATH": str(paths["workspace_root"] / "validators" / "validate-run.py"),
|
|
511
|
+
"LATEST_RUN_PATH": str(paths["run_dir"]),
|
|
512
|
+
}
|
|
513
|
+
|
|
514
|
+
|
|
515
|
+
def _absolute_worker_fields(paths: Mapping[str, Any]) -> dict[str, str]:
|
|
516
|
+
return {
|
|
517
|
+
"CLAUDE_WORKER_PROMPT_FILE": str(paths["claude_worker_prompt"]),
|
|
518
|
+
"CODEX_WORKER_PROMPT_FILE": str(paths["codex_worker_prompt"]),
|
|
519
|
+
"ANTIGRAVITY_WORKER_PROMPT_FILE": str(paths["antigravity_worker_prompt"]),
|
|
520
|
+
"REPORT_WRITER_WORKER_PROMPT_FILE": str(paths["report_writer_worker_prompt"]),
|
|
521
|
+
"CLAUDE_WORKER_RESULT_FILE": str(paths["claude_worker_result"]),
|
|
522
|
+
"CODEX_WORKER_RESULT_FILE": str(paths["codex_worker_result"]),
|
|
523
|
+
"ANTIGRAVITY_WORKER_RESULT_FILE": str(paths["antigravity_worker_result"]),
|
|
524
|
+
"REPORT_WRITER_WORKER_RESULT_FILE": str(paths["report_writer_worker_result"]),
|
|
525
|
+
"CLAUDE_WORKER_ERRORS_SIDECAR_PATH": str(paths["claude_worker_errors_sidecar"]),
|
|
526
|
+
"CODEX_WORKER_ERRORS_SIDECAR_PATH": str(paths["codex_worker_errors_sidecar"]),
|
|
527
|
+
"ANTIGRAVITY_WORKER_ERRORS_SIDECAR_PATH": str(paths["antigravity_worker_errors_sidecar"]),
|
|
528
|
+
"REPORT_WRITER_WORKER_ERRORS_SIDECAR_PATH": str(paths["report_writer_errors_sidecar"]),
|
|
529
|
+
}
|
|
530
|
+
|
|
531
|
+
|
|
532
|
+
def _filename_fields(paths: Mapping[str, Any]) -> dict[str, str]:
|
|
533
|
+
return {
|
|
534
|
+
"RUN_MANIFEST_FILENAME": Path(paths["run_manifest_file"]).name,
|
|
535
|
+
"RUN_PROMPT_SNAPSHOT_FILENAME": Path(paths["run_prompt_snapshot"]).name,
|
|
536
|
+
"FINAL_REPORT_FILENAME": Path(paths["final_report"]).name,
|
|
537
|
+
"FINAL_STATUS_FILENAME": Path(paths["final_status"]).name,
|
|
538
|
+
"CLAUDE_RESUME_COMMAND_FILENAME": Path(paths["claude_resume_command"]).name,
|
|
539
|
+
"RUN_FILE_SUFFIX": str(paths["run_file_suffix"]),
|
|
540
|
+
}
|
|
541
|
+
|
|
542
|
+
|
|
543
|
+
def _sequence_fields(paths: Mapping[str, Any]) -> dict[str, str]:
|
|
544
|
+
sequences = paths["sequences"]
|
|
545
|
+
return {
|
|
546
|
+
"RUN_MANIFESTS_SEQ": sequences["manifests"],
|
|
547
|
+
"RUN_PROMPTS_SEQ": sequences["prompts"],
|
|
548
|
+
"RUN_REPORTS_SEQ": sequences["reports"],
|
|
549
|
+
"RUN_STATUS_SEQ": sequences["status"],
|
|
550
|
+
"RUN_STATE_SEQ": sequences["state"],
|
|
551
|
+
"RUN_SESSIONS_SEQ": sequences["sessions"],
|
|
552
|
+
"WORKER_RESULTS_SEQ": sequences["worker_results"],
|
|
553
|
+
}
|
|
554
|
+
|
|
555
|
+
|
|
556
|
+
def _relative_fields(project_root: Path, paths: Mapping[str, Any]) -> dict[str, str]:
|
|
557
|
+
return {
|
|
558
|
+
"OKSTRA_DISCOVERY_RELATIVE_PATH": _rel(project_root, paths["discovery_dir"]),
|
|
559
|
+
"OKSTRA_LATEST_TASK_RELATIVE_PATH": _rel(project_root, paths["latest_task_file"]),
|
|
560
|
+
"OKSTRA_TASK_CATALOG_RELATIVE_PATH": _rel(project_root, paths["task_catalog_file"]),
|
|
561
|
+
"TASK_ROOT_RELATIVE_PATH": _rel(project_root, paths["task_root"]),
|
|
562
|
+
"TASK_MANIFEST_RELATIVE_PATH": _rel(project_root, paths["task_manifest"]),
|
|
563
|
+
"TASK_INDEX_RELATIVE_PATH": _rel(project_root, paths["task_index"]),
|
|
564
|
+
"INSTRUCTION_SET_RELATIVE_PATH": _rel(project_root, paths["instruction_set"]),
|
|
565
|
+
"ANALYSIS_PACKET_RELATIVE_PATH": _rel(project_root, paths["analysis_packet"]),
|
|
566
|
+
"RUNS_RELATIVE_PATH": _rel(project_root, paths["runs_dir"]),
|
|
567
|
+
"HISTORY_RELATIVE_PATH": _rel(project_root, paths["history_dir"]),
|
|
568
|
+
"TIMELINE_RELATIVE_PATH": _rel(project_root, paths["timeline_file"]),
|
|
569
|
+
"RECAP_DIR_RELATIVE_PATH": _rel(project_root, paths["recap_dir"]),
|
|
570
|
+
"RECAP_LOG_RELATIVE_PATH": _rel(project_root, paths["recap_log"]),
|
|
571
|
+
"RUN_DIR_RELATIVE_PATH": _rel(project_root, paths["run_dir"]),
|
|
572
|
+
"RUN_MANIFESTS_RELATIVE_PATH": _rel(project_root, paths["run_manifests"]),
|
|
573
|
+
"RUN_STATE_RELATIVE_PATH": _rel(project_root, paths["run_state"]),
|
|
574
|
+
"RUN_PROMPTS_RELATIVE_PATH": _rel(project_root, paths["run_prompts"]),
|
|
575
|
+
"RUN_REPORTS_RELATIVE_PATH": _rel(project_root, paths["run_reports"]),
|
|
576
|
+
"RUN_STATUS_RELATIVE_PATH": _rel(project_root, paths["run_status"]),
|
|
577
|
+
"RUN_SESSIONS_RELATIVE_PATH": _rel(project_root, paths["run_sessions"]),
|
|
578
|
+
**_relative_file_fields(project_root, paths),
|
|
579
|
+
}
|
|
580
|
+
|
|
581
|
+
|
|
582
|
+
def _relative_file_fields(project_root: Path, paths: Mapping[str, Any]) -> dict[str, str]:
|
|
583
|
+
return {
|
|
584
|
+
"RUN_MANIFEST_RELATIVE_PATH": _rel(project_root, paths["run_manifest_file"]),
|
|
585
|
+
"RUN_CONTEXT_RELATIVE_PATH": _rel(project_root, paths["run_context_file"]),
|
|
586
|
+
"RUN_INPUTS_RELATIVE_PATH": _rel(project_root, paths["run_inputs_file"]),
|
|
587
|
+
"RUN_PROMPT_SNAPSHOT_RELATIVE_PATH": _rel(project_root, paths["run_prompt_snapshot"]),
|
|
588
|
+
"CLAUDE_WORKER_PROMPT_RELATIVE_PATH": _rel(project_root, paths["claude_worker_prompt"]),
|
|
589
|
+
"CODEX_WORKER_PROMPT_RELATIVE_PATH": _rel(project_root, paths["codex_worker_prompt"]),
|
|
590
|
+
"ANTIGRAVITY_WORKER_PROMPT_RELATIVE_PATH": _rel(project_root, paths["antigravity_worker_prompt"]),
|
|
591
|
+
"REPORT_WRITER_WORKER_PROMPT_RELATIVE_PATH": _rel(project_root, paths["report_writer_worker_prompt"]),
|
|
592
|
+
"FINAL_REPORT_RELATIVE_PATH": _rel(project_root, paths["final_report"]),
|
|
593
|
+
"FINAL_STATUS_RELATIVE_PATH": _rel(project_root, paths["final_status"]),
|
|
594
|
+
"TEAM_STATE_RELATIVE_PATH": _rel(project_root, paths["team_state"]),
|
|
595
|
+
"ACTIVE_RUN_CONTEXT_RELATIVE_PATH": _rel(project_root, paths["active_run_context"]),
|
|
596
|
+
"LEAD_EVENTS_RELATIVE_PATH": _rel(project_root, paths["lead_events"]),
|
|
597
|
+
"WORKER_RESULTS_RELATIVE_PATH": _rel(project_root, paths["worker_results"]),
|
|
598
|
+
"RUN_CARRY_RELATIVE_PATH": _rel(project_root, paths["run_carry"]),
|
|
599
|
+
"FINAL_REPORT_TEMPLATE_RELATIVE_PATH": _rel(project_root, paths["final_report_template"]),
|
|
600
|
+
"FINAL_REPORT_SCHEMA_RELATIVE_PATH": _rel(project_root, paths["final_report_schema"]),
|
|
601
|
+
"REFERENCE_EXPECTATIONS_RELATIVE_PATH": _rel(project_root, paths["reference_expectations"]),
|
|
602
|
+
"CLAUDE_RESUME_COMMAND_RELATIVE_PATH": _rel(project_root, paths["claude_resume_command"]),
|
|
603
|
+
"RUN_VALIDATOR_RELATIVE_PATH": _rel(project_root, paths["workspace_root"] / "validators" / "validate-run.py"),
|
|
604
|
+
"CLAUDE_WORKER_RESULT_RELATIVE_PATH": _rel(project_root, paths["claude_worker_result"]),
|
|
605
|
+
"CODEX_WORKER_RESULT_RELATIVE_PATH": _rel(project_root, paths["codex_worker_result"]),
|
|
606
|
+
"ANTIGRAVITY_WORKER_RESULT_RELATIVE_PATH": _rel(project_root, paths["antigravity_worker_result"]),
|
|
607
|
+
"REPORT_WRITER_WORKER_RESULT_RELATIVE_PATH": _rel(project_root, paths["report_writer_worker_result"]),
|
|
608
|
+
"RUN_ERRORS_LOG_RELATIVE_PATH": _rel(project_root, paths["run_errors_log"]),
|
|
609
|
+
"CLAUDE_WORKER_ERRORS_SIDECAR_RELATIVE_PATH": _rel(project_root, paths["claude_worker_errors_sidecar"]),
|
|
610
|
+
"CODEX_WORKER_ERRORS_SIDECAR_RELATIVE_PATH": _rel(project_root, paths["codex_worker_errors_sidecar"]),
|
|
611
|
+
"ANTIGRAVITY_WORKER_ERRORS_SIDECAR_RELATIVE_PATH": _rel(project_root, paths["antigravity_worker_errors_sidecar"]),
|
|
612
|
+
"REPORT_WRITER_WORKER_ERRORS_SIDECAR_RELATIVE_PATH": _rel(project_root, paths["report_writer_errors_sidecar"]),
|
|
613
|
+
"LATEST_RUN_RELATIVE_PATH": _rel(project_root, paths["run_dir"]),
|
|
614
|
+
}
|
|
615
|
+
|
|
616
|
+
|
|
617
|
+
def _timestamp_fields(timestamps: Any) -> dict[str, str]:
|
|
618
|
+
values = _mapping(timestamps)
|
|
619
|
+
return {
|
|
620
|
+
"RUN_TIMESTAMP_ISO": str(values.get("runTimestampIso", "")),
|
|
621
|
+
"TASK_DATE": str(values.get("taskDate", "")),
|
|
622
|
+
}
|
|
623
|
+
|
|
624
|
+
|
|
625
|
+
def _task_root_hint(hints: Mapping[str, Any]) -> str:
|
|
626
|
+
task_hints = _mapping(hints.get("task"))
|
|
627
|
+
return str(task_hints.get("taskRoot", ""))
|
|
628
|
+
|
|
629
|
+
|
|
630
|
+
def _segment(identity: Mapping[str, Any], name: str) -> str:
|
|
631
|
+
segments = _mapping(identity.get("segments"))
|
|
632
|
+
return str(segments.get(name, ""))
|
|
633
|
+
|
|
634
|
+
|
|
635
|
+
def _mapping(value: Any) -> Mapping[str, Any]:
|
|
636
|
+
return value if isinstance(value, Mapping) else {}
|
|
637
|
+
|
|
638
|
+
|
|
639
|
+
def _sequences(run_hints: Mapping[str, Any]) -> dict[str, str]:
|
|
640
|
+
raw = _mapping(run_hints.get("sequences"))
|
|
641
|
+
return {
|
|
642
|
+
"manifests": str(raw.get("manifests", "")),
|
|
643
|
+
"prompts": str(raw.get("prompts", "")),
|
|
644
|
+
"reports": str(raw.get("reports", "")),
|
|
645
|
+
"status": str(raw.get("status", "")),
|
|
646
|
+
"state": str(raw.get("state", "")),
|
|
647
|
+
"sessions": str(raw.get("sessions", "")),
|
|
648
|
+
"worker_results": str(raw.get("workerResults", "")),
|
|
649
|
+
}
|
|
650
|
+
|
|
651
|
+
|
|
652
|
+
def _suffixes(task_type_segment: str, sequences: Mapping[str, str]) -> dict[str, str]:
|
|
653
|
+
return {key: f"-{task_type_segment}-{seq}" for key, seq in sequences.items()}
|
|
654
|
+
|
|
655
|
+
|
|
656
|
+
def _project_path(project_root: Path, value: str) -> Path:
|
|
657
|
+
path = Path(value)
|
|
658
|
+
return path if path.is_absolute() else project_root / path
|
|
659
|
+
|
|
660
|
+
|
|
661
|
+
def _rel(project_root: Path, target: Path) -> str:
|
|
662
|
+
try:
|
|
663
|
+
return str(target.resolve().relative_to(project_root.resolve()))
|
|
664
|
+
except (ValueError, OSError):
|
|
665
|
+
return str(target)
|
|
@@ -31,6 +31,7 @@ from okstra_project.dirs import OKSTRA_DIR_NAME, project_json_path
|
|
|
31
31
|
from . import fix_cycles
|
|
32
32
|
from .paths import okstra_home
|
|
33
33
|
from .lead_runtime import lead_runtime_info
|
|
34
|
+
from .path_hints import compact_active_run_context, hydrate_run_context
|
|
34
35
|
from .workflow import DEFAULT_NEXT_PHASE, PHASE_SEQUENCE
|
|
35
36
|
|
|
36
37
|
|
|
@@ -56,8 +57,8 @@ def _load_ctx(ctx_arg: str) -> dict:
|
|
|
56
57
|
"""
|
|
57
58
|
s = ctx_arg.lstrip()
|
|
58
59
|
if s.startswith("{"):
|
|
59
|
-
return json.loads(ctx_arg)
|
|
60
|
-
return json.loads(Path(ctx_arg).read_text(encoding="utf-8"))
|
|
60
|
+
return hydrate_run_context(json.loads(ctx_arg))
|
|
61
|
+
return hydrate_run_context(json.loads(Path(ctx_arg).read_text(encoding="utf-8")))
|
|
61
62
|
|
|
62
63
|
|
|
63
64
|
def _write_text(path: Path, text: str) -> None:
|
|
@@ -434,7 +435,7 @@ def render_active_run_context(active_context_path: str, ctx: dict) -> None:
|
|
|
434
435
|
"sourceArtifacts": _active_source_artifacts(ctx),
|
|
435
436
|
"lazyReadPlan": _active_lazy_read_plan(),
|
|
436
437
|
}
|
|
437
|
-
_write_json(Path(active_context_path), payload)
|
|
438
|
+
_write_json(Path(active_context_path), compact_active_run_context(ctx, payload))
|
|
438
439
|
|
|
439
440
|
|
|
440
441
|
def render_team_state(team_state_path: str, ctx: dict) -> None:
|
|
@@ -25,6 +25,7 @@ from typing import Iterator, Optional
|
|
|
25
25
|
|
|
26
26
|
from okstra_project.dirs import okstra_home
|
|
27
27
|
|
|
28
|
+
from .path_hints import compact_run_context, hydrate_run_context
|
|
28
29
|
from .paths import compute_run_paths, task_runs_dir
|
|
29
30
|
|
|
30
31
|
|
|
@@ -188,7 +189,7 @@ def compute_and_write_run_context(
|
|
|
188
189
|
str(ctx_path.relative_to(project_root.resolve()))
|
|
189
190
|
if ctx_path.resolve().is_relative_to(project_root.resolve())
|
|
190
191
|
else str(ctx_path))
|
|
191
|
-
_atomic_write_json(ctx_path, ctx)
|
|
192
|
+
_atomic_write_json(ctx_path, compact_run_context(ctx))
|
|
192
193
|
return ctx
|
|
193
194
|
|
|
194
195
|
|
|
@@ -225,7 +226,8 @@ def read_run_context(run_manifests_dir: Path, task_type_segment: str,
|
|
|
225
226
|
path = Path(run_manifests_dir) / _run_context_filename(task_type_segment, seq)
|
|
226
227
|
if not path.is_file():
|
|
227
228
|
return None
|
|
228
|
-
|
|
229
|
+
payload = json.loads(path.read_text(encoding="utf-8"))
|
|
230
|
+
return hydrate_run_context(payload)
|
|
229
231
|
|
|
230
232
|
|
|
231
233
|
def read_run_inputs(run_manifests_dir: Path, task_type_segment: str,
|
|
@@ -1053,6 +1053,18 @@ def _opt(value: str, label: str = "", description: str = "") -> Option:
|
|
|
1053
1053
|
|
|
1054
1054
|
# --- builders ---
|
|
1055
1055
|
|
|
1056
|
+
def _contract_outcome_suffix(entry: dict) -> str:
|
|
1057
|
+
outcome = entry.get("phaseOutcome")
|
|
1058
|
+
implementation = outcome.get("implementation") if isinstance(outcome, dict) else {}
|
|
1059
|
+
if not isinstance(implementation, dict):
|
|
1060
|
+
return ""
|
|
1061
|
+
if implementation.get("state") != "completed":
|
|
1062
|
+
return ""
|
|
1063
|
+
if entry.get("currentStatus") == "contract-violated":
|
|
1064
|
+
return " · implementation complete; contract warnings"
|
|
1065
|
+
return ""
|
|
1066
|
+
|
|
1067
|
+
|
|
1056
1068
|
def _build_task_pick(state: WizardState) -> Prompt:
|
|
1057
1069
|
t = _p(state.workspace_root, "task_pick")
|
|
1058
1070
|
project_root = Path(state.project_root)
|
|
@@ -1070,7 +1082,7 @@ def _build_task_pick(state: WizardState) -> Prompt:
|
|
|
1070
1082
|
phase = entry.get("currentPhase") or ttype
|
|
1071
1083
|
nxt = entry.get("nextRecommendedPhase") or ""
|
|
1072
1084
|
suffix = latest_suffix if key == latest_key else ""
|
|
1073
|
-
label = f"{key} · {phase} · next: {nxt}{suffix}"
|
|
1085
|
+
label = f"{key} · {phase} · next: {nxt}{_contract_outcome_suffix(entry)}{suffix}"
|
|
1074
1086
|
options.append(_opt(value=key, label=label))
|
|
1075
1087
|
for value, label in _static_options(t):
|
|
1076
1088
|
options.append(_opt(value=value, label=label))
|
|
@@ -1,10 +1,13 @@
|
|
|
1
|
-
"""Read-
|
|
1
|
+
"""Read-side state accessors over okstra's on-disk authority files.
|
|
2
2
|
|
|
3
3
|
Skills, okstra.sh, and okstra-ctl all read the same files through this module
|
|
4
4
|
so identity / discovery values are derived from disk on every call instead of
|
|
5
5
|
being passed via process environment. This keeps concurrent claude-code skill
|
|
6
6
|
invocations isolated — each call reads the authoritative state at the moment
|
|
7
7
|
it runs and never sees stale snapshots inherited from a parent process.
|
|
8
|
+
Some accessors also run best-effort reconciliation before returning derived
|
|
9
|
+
phase pointers, so stale catalog entries can be healed from authoritative
|
|
10
|
+
task artifacts.
|
|
8
11
|
|
|
9
12
|
권위 파일 매핑 (okstra root = <PROJECT_ROOT>/<OKSTRA_DIR_NAME>):
|
|
10
13
|
- <okstra root>/project.json
|
|
@@ -78,6 +81,38 @@ def read_task_manifest(task_root: Path) -> Optional[dict]:
|
|
|
78
81
|
return _load_json(Path(task_root) / "task-manifest.json")
|
|
79
82
|
|
|
80
83
|
|
|
84
|
+
def _reconcile_task_root_best_effort(project_root: Path, task_root: Path) -> None:
|
|
85
|
+
try:
|
|
86
|
+
from okstra_ctl.implementation_outcome import reconcile_implementation_outcome
|
|
87
|
+
|
|
88
|
+
reconcile_implementation_outcome(project_root, task_root)
|
|
89
|
+
except Exception:
|
|
90
|
+
return
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def _entry_with_manifest_state(entry: dict, task_root: Path) -> dict:
|
|
94
|
+
out = {**entry}
|
|
95
|
+
manifest = read_task_manifest(task_root) or {}
|
|
96
|
+
workflow = manifest.get("workflow") if isinstance(manifest.get("workflow"), dict) else {}
|
|
97
|
+
for source, target in (
|
|
98
|
+
("currentPhase", "currentPhase"),
|
|
99
|
+
("currentPhaseState", "currentPhaseState"),
|
|
100
|
+
("lastCompletedPhase", "lastCompletedPhase"),
|
|
101
|
+
("nextRecommendedPhase", "nextRecommendedPhase"),
|
|
102
|
+
):
|
|
103
|
+
value = workflow.get(source)
|
|
104
|
+
if isinstance(value, str):
|
|
105
|
+
out[target] = value
|
|
106
|
+
for field in ("currentStatus", "latestRunStatus", "latestReportPath"):
|
|
107
|
+
value = manifest.get(field)
|
|
108
|
+
if isinstance(value, str):
|
|
109
|
+
out[field] = value
|
|
110
|
+
phase_outcome = manifest.get("phaseOutcome")
|
|
111
|
+
if isinstance(phase_outcome, dict):
|
|
112
|
+
out["phaseOutcome"] = phase_outcome
|
|
113
|
+
return out
|
|
114
|
+
|
|
115
|
+
|
|
81
116
|
def parse_task_key(task_key: str) -> tuple[str, str, str]:
|
|
82
117
|
"""`project-id:task-group:task-id` 를 3-tuple 로 분해.
|
|
83
118
|
|
|
@@ -111,10 +146,12 @@ def find_task_root(project_root: Path, task_key: str) -> Optional[Path]:
|
|
|
111
146
|
if isinstance(rel, str) and rel:
|
|
112
147
|
abs_path = project_root / rel if not Path(rel).is_absolute() else Path(rel)
|
|
113
148
|
if abs_path.is_dir():
|
|
149
|
+
_reconcile_task_root_best_effort(project_root, abs_path)
|
|
114
150
|
return abs_path
|
|
115
151
|
|
|
116
152
|
slug_path = project_root / TASKS_RELATIVE / slugify(task_group) / slugify(task_id)
|
|
117
153
|
if slug_path.is_dir():
|
|
154
|
+
_reconcile_task_root_best_effort(project_root, slug_path)
|
|
118
155
|
return slug_path
|
|
119
156
|
return None
|
|
120
157
|
|
|
@@ -180,7 +217,8 @@ def list_project_tasks(
|
|
|
180
217
|
root = find_task_root(project_root, entry_key)
|
|
181
218
|
if root is None:
|
|
182
219
|
continue
|
|
183
|
-
|
|
220
|
+
reconciled_entry = _entry_with_manifest_state(entry, root)
|
|
221
|
+
out.append({**reconciled_entry, "_resolvedTaskRoot": str(root)})
|
|
184
222
|
if limit is not None and limit >= 0:
|
|
185
223
|
out = out[:limit]
|
|
186
224
|
return out
|