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.
@@ -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-manifest-<task-type>-<seq>.json`의 path 계열 필드는 모두 대상 프로젝트 루트 기준 상대경로만 저장합니다.
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합니다.
@@ -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 paths / seq | `<run-dir>/manifests/run-context-<task-type>-<seq>.json` |
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "okstra",
3
- "version": "0.102.3",
3
+ "version": "0.102.4",
4
4
  "description": "Multi-agent cross-verification orchestrator runtime + Claude Code skills.",
5
5
  "license": "MIT",
6
6
  "author": "devonshin",
@@ -1,5 +1,5 @@
1
1
  {
2
- "package": "0.102.3",
3
- "builtAt": "2026-06-25T09:16:07.810Z",
2
+ "package": "0.102.4",
3
+ "builtAt": "2026-06-25T17:22:51.416Z",
4
4
  "repoRoot": "/home/runner/work/okstra/okstra"
5
5
  }
@@ -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
- return _load_json_object(path, "active-run-context") if path.is_file() else {}
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
- return json.loads(path.read_text(encoding="utf-8"))
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-only state accessors over okstra's on-disk authority files.
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
- out.append({**entry, "_resolvedTaskRoot": str(root)})
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