okstra 0.77.0 → 0.78.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (41) hide show
  1. package/README.kr.md +1 -1
  2. package/README.md +1 -1
  3. package/bin/okstra +1 -125
  4. package/docs/contributor-change-matrix.md +12 -0
  5. package/docs/kr/cli.md +5 -4
  6. package/docs/project-structure-overview.md +1 -1
  7. package/docs/superpowers/plans/2026-06-13-repo-risk-hardening.md +493 -0
  8. package/docs/superpowers/specs/2026-06-13-forbidden-actions-ssot-design.md +134 -0
  9. package/docs/superpowers/specs/2026-06-13-neutral-tmux-lead-adapter-design.md +284 -0
  10. package/package.json +5 -2
  11. package/runtime/BUILD.json +2 -2
  12. package/runtime/DO_NOT_EDIT.md +21 -0
  13. package/runtime/agents/SKILL.md +3 -0
  14. package/runtime/bin/okstra-trace-cleanup.sh +16 -12
  15. package/runtime/prompts/profiles/forbidden-actions.json +69 -0
  16. package/runtime/prompts/profiles/implementation.md +1 -8
  17. package/runtime/prompts/profiles/release-handoff.md +1 -15
  18. package/runtime/python/okstra_ctl/codex_dispatch.py +70 -2
  19. package/runtime/python/okstra_ctl/context_cost.py +1 -1
  20. package/runtime/python/okstra_ctl/dispatch_core.py +897 -0
  21. package/runtime/python/okstra_ctl/doctor.py +292 -0
  22. package/runtime/python/okstra_ctl/lead_runtime.py +72 -0
  23. package/runtime/python/okstra_ctl/render.py +67 -16
  24. package/runtime/python/okstra_ctl/run.py +20 -12
  25. package/runtime/python/okstra_ctl/team.py +267 -0
  26. package/runtime/python/okstra_ctl/tmux.py +181 -10
  27. package/runtime/python/okstra_ctl/workflow.py +30 -71
  28. package/runtime/python/okstra_ctl/wrapper_status.py +55 -0
  29. package/runtime/schemas/final-report-v1.0.schema.json +3 -1
  30. package/runtime/skills/okstra-convergence/SKILL.md +3 -0
  31. package/runtime/skills/okstra-report-writer/SKILL.md +3 -0
  32. package/runtime/skills/okstra-team-contract/SKILL.md +12 -0
  33. package/runtime/validators/forbidden_actions.py +135 -0
  34. package/runtime/validators/validate-run.py +65 -9
  35. package/runtime/validators/validate_session_conformance.py +15 -10
  36. package/src/cli-registry.mjs +277 -0
  37. package/src/doctor.mjs +93 -4
  38. package/src/install.mjs +6 -5
  39. package/src/runtime-manifest.mjs +5 -2
  40. package/src/team.mjs +63 -0
  41. package/src/uninstall.mjs +1 -0
@@ -0,0 +1,55 @@
1
+ """Reader for okstra worker wrapper status sidecars."""
2
+ from __future__ import annotations
3
+
4
+ import json
5
+ from dataclasses import dataclass
6
+ from pathlib import Path
7
+ from typing import Any, Mapping
8
+
9
+
10
+ @dataclass(frozen=True)
11
+ class WrapperStatus:
12
+ path: Path
13
+ stage: str
14
+ exit_code: int | None
15
+ timeout: bool
16
+ log_path: Path | None
17
+ raw: Mapping[str, Any]
18
+
19
+ @property
20
+ def is_terminal(self) -> bool:
21
+ return self.stage == "exited"
22
+
23
+
24
+ def status_path_for_prompt(prompt_path: Path) -> Path:
25
+ return prompt_path.with_suffix(prompt_path.suffix + ".status.json")
26
+
27
+
28
+ def read_wrapper_status(path: Path) -> WrapperStatus | None:
29
+ try:
30
+ raw = json.loads(path.read_text(encoding="utf-8"))
31
+ except (OSError, json.JSONDecodeError):
32
+ return None
33
+ if not isinstance(raw, dict):
34
+ return None
35
+ stage = raw.get("stage")
36
+ if not isinstance(stage, str) or not stage:
37
+ return None
38
+ return WrapperStatus(
39
+ path=path,
40
+ stage=stage,
41
+ exit_code=_optional_int(raw.get("exit_code")),
42
+ timeout=raw.get("timeout") is True,
43
+ log_path=_optional_path(raw.get("log_path")),
44
+ raw=raw,
45
+ )
46
+
47
+
48
+ def _optional_int(value: object) -> int | None:
49
+ return value if isinstance(value, int) and not isinstance(value, bool) else None
50
+
51
+
52
+ def _optional_path(value: object) -> Path | None:
53
+ if not isinstance(value, str) or not value.strip():
54
+ return None
55
+ return Path(value)
@@ -745,7 +745,7 @@
745
745
  }
746
746
  },
747
747
  {
748
- "description": "final-verification verdict token must be one of accepted / conditional-accept / blocked.",
748
+ "description": "final-verification verdict token must be one of accepted / conditional-accept / blocked and must explicitly list conditional acceptance conditions.",
749
749
  "if": {
750
750
  "properties": { "header": { "properties": { "taskType": { "const": "final-verification" } } } },
751
751
  "required": ["header"]
@@ -753,6 +753,7 @@
753
753
  "then": {
754
754
  "properties": {
755
755
  "finalVerdict": {
756
+ "required": ["conditionalAcceptanceConditions"],
756
757
  "properties": {
757
758
  "verdictToken": { "enum": ["accepted", "conditional-accept", "blocked"] }
758
759
  }
@@ -775,6 +776,7 @@
775
776
  "then": {
776
777
  "properties": {
777
778
  "finalVerdict": {
779
+ "not": { "required": ["conditionalAcceptanceConditions"] },
778
780
  "properties": {
779
781
  "verdictToken": { "const": "not-applicable" }
780
782
  }
@@ -273,6 +273,9 @@ Agent(
273
273
  - Agent Teams mode: Spawn within an existing team
274
274
  - Fallback mode: Spawn with `run_in_background: true` and no `team_name`
275
275
 
276
+ For `tmux-pane` backend runs, do not use the Agent snippet. For each reverify round, write a jobs file at `runs/<task-type>/state/reverify-jobs-r<N>-<task-type>-<seq>.json` with `dispatchKind: "reverify-r<N>"` and worker entries containing `workerId`, `provider`, `role`, `modelExecutionValue`, `promptPath`, `resultPath`, `workerResultPath`, and `completionPaths`; then run `okstra team dispatch --project-root <dir> --run-manifest <path> --dispatch-kind reverify-r<N> --jobs-file <jobs-file>` followed by `okstra team await --project-root <dir> --run-manifest <path>`.
277
+
278
+
276
279
  **Completion detection per round (BLOCKING).** Each round dispatches a variable set (1..N) of reverify workers asynchronously; the `Agent(... team_name ...)` calls return `Spawned successfully` immediately, which is NOT completion. Lead MUST detect each round's completion via the self-scheduled polling protocol in [okstra-team-contract](../okstra-team-contract/SKILL.md) "Worker-completion detection (self-scheduled polling)", with the pending set reconstructed from that round's dispatched workers' Result Paths — do NOT restate the algorithm here. Lead MUST NOT treat the spawn ack as completion and MUST NOT end its turn with a prose "waiting" statement.
277
280
 
278
281
  ### Required reverify-prompt anchor headers (BLOCKING)
@@ -82,6 +82,9 @@ Except for `release-handoff` (which is single-lead by design and never dispatche
82
82
 
83
83
  Speculative reasons such as "session resume constraint", "team object no longer exists", or "lead can do it faster" are NOT valid.
84
84
 
85
+ For `tmux-pane` backend runs, do not use the Agent template. Create a one-job jobs file for the `report-writer` with `dispatchKind: "report-writer"` and the same `workerId`, `provider`, `role`, `modelExecutionValue`, `promptPath`, `resultPath`, `workerResultPath`, and `completionPaths` fields used by reverify jobs. Then call `okstra team dispatch --project-root <dir> --run-manifest <path> --jobs-file <jobs-file>` followed by `okstra team await --project-root <dir> --run-manifest <path>`. Completion requires both the report data.json and the report-writer worker-results audit file.
86
+
87
+
85
88
  ## Phase 6 → Phase 7 execution sequence (BLOCKING order)
86
89
 
87
90
  The four steps below MUST execute in this exact order. Reordering them is the recurring root cause of reports shipping with `--` token cells (Phase 7 not run yet), Section 3 missing follow-up entries, or Section 4 rows never spawning.
@@ -127,6 +127,18 @@ Terminal statuses that can be recorded for a worker:
127
127
 
128
128
  Lead dispatches workers asynchronously: an `Agent` call carrying `team_name` returns `Spawned successfully` **immediately** — that ack is NOT a completion. Lead MUST NOT treat the spawn ack as completion, and MUST NOT end its turn with a prose "waiting for ..." statement (that path stalls the run — the Agent Teams idle-notification is experimental and can be dropped, leaving Lead parked until the user manually nudges it). Instead:
129
129
 
130
+ For `tmux-pane` backend runs, Lead MUST use exactly one background wait command instead of raw result-file polling:
131
+
132
+ ```bash
133
+ okstra team await --project-root <dir> --run-manifest <path>
134
+ ```
135
+
136
+ This command checks both result files and wrapper status sidecars. Raw result-file-only polling is forbidden for `tmux-pane` because pane creation and file presence alone are not terminal worker completion.
137
+
138
+ For the Claude Code `team` backend, use the self-scheduled polling protocol below.
139
+
140
+
141
+
130
142
  1. Record the dispatched workers' Result Paths as the **pending set** (resolved to absolute from each launch prompt's `**Result Path:**` anchor header against `**Project Root:**`; the same paths recorded in run-manifest / team-state).
131
143
  2. Arm a SINGLE self-wakeup: one `Bash(run_in_background: true)` poll covering ALL dispatched workers (not one background task per worker):
132
144
 
@@ -0,0 +1,135 @@
1
+ """Phase 별 금지 행위(publish/deploy/force-push)의 post-hoc 자동 스캐너.
2
+
3
+ 배경: 금지 행위는 `prompts/profiles/forbidden-actions.json` (SSOT) 에 선언되고
4
+ launch 경계(PHASE_FORBIDDEN_ACTIONS)와 profile 본문으로 렌더되지만, 지금까지 lead 의
5
+ 수동 self-review 로만 감사됐다(코드 강제 없음). 이 모듈은 run 종료 후 세션 transcript 의
6
+ Bash tool_use 명령을 phase deny-list 와 대조해 위반을 기계적으로 잡아낸다.
7
+
8
+ 스캔 규칙(false-positive 방지가 핵심, validate_session_conformance 와 동일 seam):
9
+ - `type == "assistant"` 레코드만, `isSidechain` 제외.
10
+ - run 윈도우(resolve_run_window)로 스코핑 — 같은 세션 jsonl 에 섞인 직전 run 의
11
+ 명령을 증거로 오인하지 않는다.
12
+ - Bash tool_use 의 `input.command` 만 본다 — text 블록(설명/금지어 언급)이나 다른
13
+ tool 의 인자는 보지 않으므로 "npm publish 하지 말 것" 같은 산문은 잡히지 않는다.
14
+
15
+ 한계 1: claude-code 세션 jsonl 만 스캔한다. codex/gemini lead·worker 는 동일 형식의
16
+ tool_use transcript 를 ~/.claude/projects 에 남기지 않으므로 이 스캐너의 사정권 밖이며,
17
+ 해당 런타임의 금지 행위는 여전히 수동 감사에 의존한다.
18
+ 한계 2: deny-list 는 Bash 명령 문자열 전체를 검색하므로, 금지 토큰이 인자/메시지에
19
+ 등장하는 경우(예: `git commit -m "... npm publish ..."`)도 위반으로 잡힐 수 있다.
20
+ `cd <path> && <cmd>` 형태 때문에 명령 head 앵커링은 불가하므로 이 잔여 오탐은 감수한다.
21
+ """
22
+ from __future__ import annotations
23
+
24
+ import json
25
+ import os
26
+ import re
27
+ import sys
28
+ from pathlib import Path
29
+
30
+ _UNIVERSAL = [
31
+ ("npm publish", re.compile(r"\bnpm\s+publish\b")),
32
+ ("cargo publish", re.compile(r"\bcargo\s+publish\b")),
33
+ ("pip publish", re.compile(r"\bpip\s+publish\b")),
34
+ ("twine upload", re.compile(r"\btwine\s+upload\b")),
35
+ ("gh release", re.compile(r"\bgh\s+release\s+(create|edit)\b")),
36
+ ("docker push", re.compile(r"\bdocker\s+push\b")),
37
+ ("terraform apply", re.compile(r"\bterraform\s+apply\b")),
38
+ ("kubectl apply", re.compile(r"\bkubectl\s+apply\b")),
39
+ ("git push --force", re.compile(r"\bgit\s+push\b.*(?:--force(-with-lease)?|\s-[A-Za-z]*f)")),
40
+ ]
41
+ _NON_HANDOFF_PUSH = ("git push", re.compile(r"\bgit\s+push\b"))
42
+
43
+
44
+ def forbidden_patterns_for(task_type: str) -> list[tuple[str, re.Pattern]]:
45
+ """task_type(=phase profile) 의 deny-list. 모든 phase 가 _UNIVERSAL 을 받고,
46
+ release-handoff 를 제외한 모든 phase 는 bare `git push` 도 금지한다
47
+ (release-handoff 는 feature 브랜치 push 가 정당하므로 제외)."""
48
+ patterns = list(_UNIVERSAL)
49
+ if task_type != "release-handoff":
50
+ patterns.append(_NON_HANDOFF_PUSH)
51
+ return patterns
52
+
53
+
54
+ def _ensure_token_usage_importable() -> None:
55
+ """okstra_token_usage 패키지를 레이아웃별(repo/scripts, runtime/python,
56
+ OKSTRA_PYTHONPATH)로 해소 — validate_session_conformance 와 동일 후보."""
57
+ here = Path(__file__).resolve().parent
58
+ candidates = [here.parent / "scripts", here.parent / "python"]
59
+ env_pp = os.environ.get("OKSTRA_PYTHONPATH", "").strip()
60
+ if env_pp:
61
+ candidates.append(Path(env_pp))
62
+ for candidate in candidates:
63
+ if candidate.is_dir() and (candidate / "okstra_token_usage").is_dir():
64
+ if str(candidate) not in sys.path:
65
+ sys.path.insert(0, str(candidate))
66
+ break
67
+
68
+
69
+ def _scan_one(
70
+ path: Path,
71
+ since: str | None,
72
+ until: str | None,
73
+ patterns: list[tuple[str, re.Pattern]],
74
+ ) -> list[tuple[str, str]]:
75
+ """jsonl 한 파일에서 deny-list 에 걸린 (label, command) 쌍을 추출한다."""
76
+ from okstra_token_usage.paths import ts_in_window
77
+
78
+ hits: list[tuple[str, str]] = []
79
+ try:
80
+ fh = path.open(encoding="utf-8")
81
+ except OSError:
82
+ return hits
83
+ with fh:
84
+ for raw in fh:
85
+ try:
86
+ rec = json.loads(raw)
87
+ except (json.JSONDecodeError, UnicodeDecodeError):
88
+ continue
89
+ if rec.get("type") != "assistant" or rec.get("isSidechain"):
90
+ continue
91
+ ts = rec.get("timestamp") or ""
92
+ if ts and not ts_in_window(ts, since, until):
93
+ continue
94
+ for block in (rec.get("message") or {}).get("content") or []:
95
+ if not isinstance(block, dict):
96
+ continue
97
+ if block.get("type") != "tool_use" or block.get("name") != "Bash":
98
+ continue
99
+ command = (block.get("input") or {}).get("command") or ""
100
+ for label, pattern in patterns:
101
+ if pattern.search(command):
102
+ hits.append((label, command))
103
+ break # one violation per command — most-specific pattern wins
104
+ return hits
105
+
106
+
107
+ def scan_forbidden_actions(
108
+ *,
109
+ team_state: dict,
110
+ team_state_path: Path,
111
+ project_root: Path,
112
+ task_type: str,
113
+ claude_projects_dir: Path | None = None,
114
+ ) -> list[str]:
115
+ """Return human-readable violation strings (empty = clean)."""
116
+ _ensure_token_usage_importable()
117
+ from okstra_token_usage.claude import find_claude_team_sessions
118
+ from okstra_token_usage.collect import resolve_run_window, resolve_team_name
119
+
120
+ since, until = resolve_run_window(team_state_path, team_state)
121
+ lead_sid = (team_state.get("lead") or {}).get("sessionId") or ""
122
+ sessions = find_claude_team_sessions(
123
+ project_root,
124
+ resolve_team_name(team_state),
125
+ lead_sid,
126
+ projects_root=claude_projects_dir,
127
+ )
128
+ patterns = forbidden_patterns_for(task_type)
129
+ violations: list[str] = []
130
+ for sid, path in sorted(sessions.items()):
131
+ for label, command in _scan_one(path, since, until, patterns):
132
+ violations.append(
133
+ f"{label} 명령이 세션 {sid} 에서 실행됨: {command.strip()[:120]}"
134
+ )
135
+ return violations
@@ -47,11 +47,25 @@ from okstra_ctl.md_table import ( # noqa: E402
47
47
 
48
48
  TERMINAL_STATUSES = {"completed", "timeout", "error", "not-run"}
49
49
  ATTEMPTED_STATUSES = {"completed", "timeout", "error"}
50
- CODEX_DISPATCH_MODES = {"cli-wrapper", "mixed"}
50
+ WORKER_DISPATCH_MODES = {"cli-wrapper", "mixed", "tmux-pane"}
51
51
 
52
52
 
53
53
  def utc_now() -> str:
54
54
  return datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
55
+ def _session_accounting(team_state: dict) -> str:
56
+ adapter = (
57
+ team_state.get("leadAdapter") if isinstance(team_state.get("leadAdapter"), dict) else {}
58
+ )
59
+ value = str(adapter.get("sessionAccounting", "")).strip()
60
+ if value:
61
+ return value
62
+ return (
63
+ "artifact-only"
64
+ if team_state.get("leadRuntime") in {"codex", "external"}
65
+ else "claude-jsonl"
66
+ )
67
+
68
+
55
69
 
56
70
 
57
71
  def load_json(path: Path) -> dict:
@@ -402,12 +416,15 @@ def validate_team_state(
402
416
  for w in workers
403
417
  )
404
418
  if any_dispatched:
405
- if team_state.get("leadRuntime") == "codex":
406
- dispatch_mode = str(team_state.get("dispatchMode", "")).strip()
407
- if dispatch_mode not in CODEX_DISPATCH_MODES:
408
- expected = ", ".join(sorted(CODEX_DISPATCH_MODES))
419
+ dispatch_mode = str(team_state.get("dispatchMode", "")).strip()
420
+ if (
421
+ dispatch_mode in WORKER_DISPATCH_MODES
422
+ or team_state.get("leadRuntime") in {"codex", "external"}
423
+ ):
424
+ if dispatch_mode not in WORKER_DISPATCH_MODES:
425
+ expected = ", ".join(sorted(WORKER_DISPATCH_MODES))
409
426
  failures.append(
410
- "team-state.dispatchMode must be set for Codex worker dispatch "
427
+ "team-state.dispatchMode must be set for non-Team worker dispatch "
411
428
  f"(expected one of: {expected})"
412
429
  )
413
430
  else:
@@ -1076,7 +1093,7 @@ def validate_worker_results_audit(
1076
1093
 
1077
1094
 
1078
1095
  def validate_team_state_usage(team_state: dict, failures: list[str]) -> None:
1079
- if team_state.get("leadRuntime") == "codex":
1096
+ if _session_accounting(team_state) == "artifact-only":
1080
1097
  return
1081
1098
  summary = team_state.get("usageSummary") or {}
1082
1099
  if not summary or not summary.get("collectedAt"):
@@ -1837,6 +1854,37 @@ def _validate_session_conformance(
1837
1854
  failures.extend(f"session-conformance: {err}" for err in result.errors)
1838
1855
 
1839
1856
 
1857
+ def _validate_forbidden_actions(
1858
+ team_state: dict,
1859
+ team_state_path: Path,
1860
+ project_root: Path,
1861
+ task_type: str,
1862
+ claude_projects_dir: str | None,
1863
+ failures: list[str],
1864
+ ) -> None:
1865
+ """phase deny-list(publish/deploy/force-push, 비-release-handoff bare push)
1866
+ 위반을 세션 transcript 에서 스캔해 ``forbidden-action: `` 접두로 folding 한다.
1867
+ 설계: docs/superpowers/plans/2026-06-13-repo-risk-hardening.md (P2-3)."""
1868
+ _validators_dir = Path(__file__).resolve().parent
1869
+ if str(_validators_dir) not in sys.path:
1870
+ sys.path.insert(0, str(_validators_dir))
1871
+ try:
1872
+ from forbidden_actions import scan_forbidden_actions # noqa: E402
1873
+ except ImportError as exc:
1874
+ failures.append(
1875
+ f"forbidden-action: scan_forbidden_actions import failed — {exc}"
1876
+ )
1877
+ return
1878
+ violations = scan_forbidden_actions(
1879
+ team_state=team_state,
1880
+ team_state_path=team_state_path,
1881
+ project_root=project_root,
1882
+ task_type=task_type,
1883
+ claude_projects_dir=Path(claude_projects_dir) if claude_projects_dir else None,
1884
+ )
1885
+ failures.extend(f"forbidden-action: {v}" for v in violations)
1886
+
1887
+
1840
1888
  def _validate_requirements_discovery_fanout(run_dir, failures) -> None:
1841
1889
  """requirements-discovery run 에 fan-out/ 이 있으면 packet+index 를 검증해
1842
1890
  실패를 ``requirements-discovery: `` 접두로 folding 한다. fan-out 이 없으면 no-op.
@@ -1936,7 +1984,7 @@ def _import_token_usage():
1936
1984
 
1937
1985
 
1938
1986
  def _needs_token_autofix(team_state: dict, report_path: Path) -> bool:
1939
- if team_state.get("leadRuntime") == "codex":
1987
+ if _session_accounting(team_state) == "artifact-only":
1940
1988
  return False
1941
1989
  summary = team_state.get("usageSummary") or {}
1942
1990
  if not summary or not summary.get("collectedAt"):
@@ -2220,7 +2268,7 @@ def main() -> int:
2220
2268
  report_path,
2221
2269
  contract["required_agent_status_entries"],
2222
2270
  failures,
2223
- allow_unavailable_token_usage=team_state.get("leadRuntime") == "codex",
2271
+ allow_unavailable_token_usage=_session_accounting(team_state) == "artifact-only",
2224
2272
  )
2225
2273
  validate_team_state_usage(team_state, failures)
2226
2274
 
@@ -2236,6 +2284,14 @@ def main() -> int:
2236
2284
  args.claude_projects_dir,
2237
2285
  failures,
2238
2286
  )
2287
+ _validate_forbidden_actions(
2288
+ team_state,
2289
+ team_state_path,
2290
+ project_root,
2291
+ task_type,
2292
+ args.claude_projects_dir,
2293
+ failures,
2294
+ )
2239
2295
  if task_type in ("implementation", "final-verification"):
2240
2296
  _sp = None
2241
2297
  _pj = project_json_path(project_root)
@@ -29,6 +29,7 @@ from datetime import datetime
29
29
  from pathlib import Path
30
30
 
31
31
  _DISPATCHED_STATUSES = {"completed", "timeout", "error", "in-progress"}
32
+ _WORKER_DISPATCH_MODES = {"cli-wrapper", "mixed", "tmux-pane"}
32
33
  _ATTEMPTED_STATUSES = {"completed", "timeout", "error"}
33
34
 
34
35
  # lead 의 체크포인트 라인 — assistant text 블록 안에서 line-anchored 로만 인정.
@@ -200,15 +201,15 @@ def _resolve_lead_events_path(
200
201
  )
201
202
  if not raw:
202
203
  return None, (
203
- "Codex lead event log path missing from team-state "
204
- "(`leadEventsPath`) — leadRuntime=codex conformance cannot be verified."
204
+ "artifact lead event log path missing from team-state "
205
+ "(`leadEventsPath`) — artifact-only conformance cannot be verified."
205
206
  )
206
207
  path = Path(str(raw))
207
208
  if not path.is_absolute():
208
209
  path = project_root / path
209
210
  if not path.is_file():
210
211
  return None, (
211
- f"Codex lead event log not found: {path} — leadRuntime=codex "
212
+ f"artifact lead event log not found: {path} — artifact-only "
212
213
  "conformance cannot be verified."
213
214
  )
214
215
  return path, None
@@ -216,7 +217,8 @@ def _resolve_lead_events_path(
216
217
 
217
218
  def _event_matches_run(event, team_state: dict, task_type: str, run_seq: str) -> bool:
218
219
  task_key = str(team_state.get("taskKey", ""))
219
- if event.lead_runtime != "codex":
220
+ expected_runtime = str(team_state.get("leadRuntime", "") or "claude-code")
221
+ if event.lead_runtime != expected_runtime:
220
222
  return False
221
223
  if task_key and event.task_key != task_key:
222
224
  return False
@@ -255,7 +257,7 @@ def _sidecar_read_from_event(event) -> tuple[str, str] | None:
255
257
  return (basename, event.timestamp)
256
258
 
257
259
 
258
- def _collect_codex_lead_evidence(
260
+ def _collect_artifact_lead_evidence(
259
261
  team_state: dict,
260
262
  project_root: Path,
261
263
  task_type: str,
@@ -263,7 +265,7 @@ def _collect_codex_lead_evidence(
263
265
  ) -> tuple[_LeadEvidence | None, str | None]:
264
266
  if not suffix or "-" not in suffix:
265
267
  return None, (
266
- "Codex lead event log cannot be scoped because team-state filename "
268
+ "artifact lead event log cannot be scoped because team-state filename "
267
269
  "does not expose a run artifact suffix."
268
270
  )
269
271
  events_path, error = _resolve_lead_events_path(team_state, project_root)
@@ -278,7 +280,7 @@ def _collect_codex_lead_evidence(
278
280
  try:
279
281
  events = read_lead_events(events_path)
280
282
  except LeadEventParseError as exc:
281
- return None, f"Codex lead event log is malformed — {exc}"
283
+ return None, f"artifact lead event log is malformed — {exc}"
282
284
 
283
285
  run_seq = suffix.rsplit("-", 1)[1]
284
286
  evidence = _LeadEvidence(scanned_files=[events_path])
@@ -531,13 +533,16 @@ def validate_session_conformance(
531
533
  result.errors.append(f"okstra_token_usage import failed — {exc}")
532
534
  return result
533
535
 
534
- run_dir = report_path.parent.parent # reports/ 의 부모 = run 디렉터리
536
+ run_dir = report_path.parent.parent
535
537
  _check_heartbeat_sidecars(run_dir, task_type, result.errors)
536
538
  suffix = run_artifact_suffix(team_state_path)
537
539
 
538
540
  lead_runtime = team_state.get("leadRuntime", "") or "claude-code"
539
- if lead_runtime == "codex":
540
- evidence, error = _collect_codex_lead_evidence(
541
+ dispatch_mode = str(team_state.get("dispatchMode", "")).strip()
542
+ if lead_runtime in {"codex", "external"} or (
543
+ lead_runtime == "claude-code" and dispatch_mode in _WORKER_DISPATCH_MODES
544
+ ):
545
+ evidence, error = _collect_artifact_lead_evidence(
541
546
  team_state, project_root, task_type, suffix
542
547
  )
543
548
  else: