okstra 0.76.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 (65) hide show
  1. package/README.kr.md +5 -3
  2. package/README.md +5 -3
  3. package/bin/okstra +1 -117
  4. package/docs/contributor-change-matrix.md +12 -0
  5. package/docs/kr/architecture.md +1 -1
  6. package/docs/kr/cli.md +22 -5
  7. package/docs/pr-template-usage.md +3 -3
  8. package/docs/project-structure-overview.md +1 -1
  9. package/docs/superpowers/plans/2026-05-25-okstra-project-root-rename.md +1 -1
  10. package/docs/superpowers/plans/2026-06-13-repo-risk-hardening.md +493 -0
  11. package/docs/superpowers/specs/2026-06-12-codex-lead-adapter-design.md +358 -0
  12. package/docs/superpowers/specs/2026-06-13-forbidden-actions-ssot-design.md +134 -0
  13. package/docs/superpowers/specs/2026-06-13-neutral-tmux-lead-adapter-design.md +284 -0
  14. package/package.json +5 -2
  15. package/runtime/BUILD.json +2 -2
  16. package/runtime/DO_NOT_EDIT.md +21 -0
  17. package/runtime/agents/SKILL.md +3 -0
  18. package/runtime/bin/lib/okstra/cli.sh +5 -1
  19. package/runtime/bin/lib/okstra/globals.sh +1 -0
  20. package/runtime/bin/lib/okstra/usage.sh +6 -4
  21. package/runtime/bin/okstra-trace-cleanup.sh +16 -12
  22. package/runtime/bin/okstra.sh +1 -0
  23. package/runtime/prompts/launch.template.md +4 -13
  24. package/runtime/prompts/profiles/error-analysis.md +6 -0
  25. package/runtime/prompts/profiles/forbidden-actions.json +69 -0
  26. package/runtime/prompts/profiles/implementation.md +1 -8
  27. package/runtime/prompts/profiles/improvement-discovery.md +5 -0
  28. package/runtime/prompts/profiles/release-handoff.md +3 -17
  29. package/runtime/python/okstra_ctl/analysis_packet.py +17 -0
  30. package/runtime/python/okstra_ctl/codex_dispatch.py +1552 -0
  31. package/runtime/python/okstra_ctl/context_cost.py +1 -1
  32. package/runtime/python/okstra_ctl/dispatch_core.py +897 -0
  33. package/runtime/python/okstra_ctl/doctor.py +292 -0
  34. package/runtime/python/okstra_ctl/lead_events.py +129 -0
  35. package/runtime/python/okstra_ctl/lead_runtime.py +72 -0
  36. package/runtime/python/okstra_ctl/paths.py +3 -0
  37. package/runtime/python/okstra_ctl/pr_template.py +1 -1
  38. package/runtime/python/okstra_ctl/render.py +211 -29
  39. package/runtime/python/okstra_ctl/run.py +89 -18
  40. package/runtime/python/okstra_ctl/team.py +267 -0
  41. package/runtime/python/okstra_ctl/tmux.py +181 -10
  42. package/runtime/python/okstra_ctl/workflow.py +30 -71
  43. package/runtime/python/okstra_ctl/wrapper_status.py +55 -0
  44. package/runtime/python/okstra_token_usage/codex.py +12 -7
  45. package/runtime/python/okstra_token_usage/collect.py +243 -54
  46. package/runtime/python/okstra_token_usage/gemini.py +12 -7
  47. package/runtime/schemas/final-report-v1.0.schema.json +3 -1
  48. package/runtime/skills/okstra-convergence/SKILL.md +3 -0
  49. package/runtime/skills/okstra-report-writer/SKILL.md +3 -0
  50. package/runtime/skills/okstra-setup/SKILL.md +1 -1
  51. package/runtime/skills/okstra-team-contract/SKILL.md +12 -0
  52. package/runtime/validators/forbidden_actions.py +135 -0
  53. package/runtime/validators/validate-run.py +112 -22
  54. package/runtime/validators/validate_session_conformance.py +127 -5
  55. package/src/cli-registry.mjs +277 -0
  56. package/src/codex-dispatch.mjs +70 -0
  57. package/src/codex-run.mjs +68 -0
  58. package/src/doctor.mjs +130 -5
  59. package/src/install.mjs +123 -26
  60. package/src/paths.mjs +17 -3
  61. package/src/render-bundle.mjs +2 -0
  62. package/src/runtime-manifest.mjs +29 -0
  63. package/src/team.mjs +63 -0
  64. package/src/uninstall.mjs +27 -4
  65. /package/runtime/{skills/okstra-run/templates → templates/prd}/pr-body.template.md +0 -0
@@ -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,10 +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
+ WORKER_DISPATCH_MODES = {"cli-wrapper", "mixed", "tmux-pane"}
50
51
 
51
52
 
52
53
  def utc_now() -> str:
53
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
+
54
69
 
55
70
 
56
71
  def load_json(path: Path) -> dict:
@@ -401,27 +416,39 @@ def validate_team_state(
401
416
  for w in workers
402
417
  )
403
418
  if any_dispatched:
404
- team_create = team_state.get("teamCreate")
405
- if _is_legal_concurrent_run_skip(team_create, concurrent_run_authorized):
406
- pass
407
- elif not isinstance(team_create, dict) or not team_create.get("attempted"):
408
- failures.append(
409
- "team-state.teamCreate.attempted must be true once any worker has "
410
- "been dispatched (status in completed/timeout/error/in-progress). "
411
- "Phase 3 (TeamCreate) was skipped — workers ran in-process without "
412
- "the Teams split-pane surface. See agents/SKILL.md Phase 3. "
413
- "(The no-team concurrent-run path is legal ONLY when the "
414
- "run-manifest carries prepare-recorded `concurrentRun.detected: "
415
- 'true` AND team-state records `teamCreate: { attempted: false, '
416
- 'status: "skipped", reason: "concurrent-run" }`.)'
417
- )
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))
426
+ failures.append(
427
+ "team-state.dispatchMode must be set for non-Team worker dispatch "
428
+ f"(expected one of: {expected})"
429
+ )
418
430
  else:
419
- tc_status = str(team_create.get("status", "")).strip()
420
- if tc_status not in {"ok", "error"}:
431
+ team_create = team_state.get("teamCreate")
432
+ if _is_legal_concurrent_run_skip(team_create, concurrent_run_authorized):
433
+ pass
434
+ elif not isinstance(team_create, dict) or not team_create.get("attempted"):
421
435
  failures.append(
422
- "team-state.teamCreate.status must be `ok` or `error` once "
423
- f"workers have been dispatched (found: `{tc_status}`)."
436
+ "team-state.teamCreate.attempted must be true once any worker has "
437
+ "been dispatched (status in completed/timeout/error/in-progress). "
438
+ "Phase 3 (TeamCreate) was skipped — workers ran in-process without "
439
+ "the Teams split-pane surface. See agents/SKILL.md Phase 3. "
440
+ "(The no-team concurrent-run path is legal ONLY when the "
441
+ "run-manifest carries prepare-recorded `concurrentRun.detected: "
442
+ 'true` AND team-state records `teamCreate: { attempted: false, '
443
+ 'status: "skipped", reason: "concurrent-run" }`.)'
424
444
  )
445
+ else:
446
+ tc_status = str(team_create.get("status", "")).strip()
447
+ if tc_status not in {"ok", "error"}:
448
+ failures.append(
449
+ "team-state.teamCreate.status must be `ok` or `error` once "
450
+ f"workers have been dispatched (found: `{tc_status}`)."
451
+ )
425
452
 
426
453
  by_role: dict[str, dict] = {}
427
454
  for worker in workers:
@@ -587,7 +614,12 @@ _TOKEN_USAGE_SENTINEL_VALUES = frozenset(
587
614
  _TOKEN_USAGE_ZERO_VALUES = frozenset({"0", "$0.00", "$0", "0.00"})
588
615
 
589
616
 
590
- def _scan_token_usage_summary(content: str, failures: list[str]) -> None:
617
+ def _scan_token_usage_summary(
618
+ content: str,
619
+ failures: list[str],
620
+ *,
621
+ allow_unavailable_tokens: bool = False,
622
+ ) -> None:
591
623
  """Reject sentinel / zero values that workers typed into the Token
592
624
  Usage Summary table instead of leaving the `{{...}}` placeholders
593
625
  verbatim for Phase 7 substitution.
@@ -639,6 +671,8 @@ def _scan_token_usage_summary(content: str, failures: list[str]) -> None:
639
671
  for value in _TOKEN_USAGE_BACKTICK_CELL_RE.findall(raw_cell):
640
672
  stripped = value.strip()
641
673
  lowered = stripped.lower()
674
+ if allow_unavailable_tokens and stripped == "--":
675
+ continue
642
676
  if lowered in _TOKEN_USAGE_SENTINEL_VALUES:
643
677
  failures.append(
644
678
  "Token Usage Summary cell contains sentinel value "
@@ -883,7 +917,11 @@ def _validate_conformance(report_path: Path, failures: list[str],
883
917
 
884
918
 
885
919
  def validate_report(
886
- report_path: Path, required_agent_status_entries: list[str], failures: list[str]
920
+ report_path: Path,
921
+ required_agent_status_entries: list[str],
922
+ failures: list[str],
923
+ *,
924
+ allow_unavailable_token_usage: bool = False,
887
925
  ) -> None:
888
926
  if not report_path.exists():
889
927
  failures.append(f"final report is missing: {report_path}")
@@ -905,7 +943,11 @@ def validate_report(
905
943
 
906
944
  # Catch the "workers typed `0` / `pending` instead of the placeholder"
907
945
  # failure mode that bypasses the placeholder check above.
908
- _scan_token_usage_summary(content, failures)
946
+ _scan_token_usage_summary(
947
+ content,
948
+ failures,
949
+ allow_unavailable_tokens=allow_unavailable_token_usage,
950
+ )
909
951
 
910
952
  # Verdict Card is mandatory in every final-report (introduced with the
911
953
  # report-format readability pass). Missing card means the reader has no
@@ -1051,6 +1093,8 @@ def validate_worker_results_audit(
1051
1093
 
1052
1094
 
1053
1095
  def validate_team_state_usage(team_state: dict, failures: list[str]) -> None:
1096
+ if _session_accounting(team_state) == "artifact-only":
1097
+ return
1054
1098
  summary = team_state.get("usageSummary") or {}
1055
1099
  if not summary or not summary.get("collectedAt"):
1056
1100
  failures.append(
@@ -1810,6 +1854,37 @@ def _validate_session_conformance(
1810
1854
  failures.extend(f"session-conformance: {err}" for err in result.errors)
1811
1855
 
1812
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
+
1813
1888
  def _validate_requirements_discovery_fanout(run_dir, failures) -> None:
1814
1889
  """requirements-discovery run 에 fan-out/ 이 있으면 packet+index 를 검증해
1815
1890
  실패를 ``requirements-discovery: `` 접두로 folding 한다. fan-out 이 없으면 no-op.
@@ -1909,6 +1984,8 @@ def _import_token_usage():
1909
1984
 
1910
1985
 
1911
1986
  def _needs_token_autofix(team_state: dict, report_path: Path) -> bool:
1987
+ if _session_accounting(team_state) == "artifact-only":
1988
+ return False
1912
1989
  summary = team_state.get("usageSummary") or {}
1913
1990
  if not summary or not summary.get("collectedAt"):
1914
1991
  return True
@@ -2187,7 +2264,12 @@ def main() -> int:
2187
2264
  _validate_fix_cycle(
2188
2265
  run_manifest, _load_final_report_data(report_path), failures
2189
2266
  )
2190
- validate_report(report_path, contract["required_agent_status_entries"], failures)
2267
+ validate_report(
2268
+ report_path,
2269
+ contract["required_agent_status_entries"],
2270
+ failures,
2271
+ allow_unavailable_token_usage=_session_accounting(team_state) == "artifact-only",
2272
+ )
2191
2273
  validate_team_state_usage(team_state, failures)
2192
2274
 
2193
2275
  validate_phase_boundary(task_type, report_path, failures)
@@ -2202,6 +2284,14 @@ def main() -> int:
2202
2284
  args.claude_projects_dir,
2203
2285
  failures,
2204
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
+ )
2205
2295
  if task_type in ("implementation", "final-verification"):
2206
2296
  _sp = None
2207
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 로만 인정.
@@ -190,6 +191,118 @@ def _collect_lead_evidence(
190
191
  return evidence, None
191
192
 
192
193
 
194
+ def _resolve_lead_events_path(
195
+ team_state: dict, project_root: Path
196
+ ) -> tuple[Path | None, str | None]:
197
+ raw = team_state.get("leadEventsPath") or (
198
+ (team_state.get("artifacts") or {}).get("leadEventsPath")
199
+ if isinstance(team_state.get("artifacts"), dict)
200
+ else ""
201
+ )
202
+ if not raw:
203
+ return None, (
204
+ "artifact lead event log path missing from team-state "
205
+ "(`leadEventsPath`) — artifact-only conformance cannot be verified."
206
+ )
207
+ path = Path(str(raw))
208
+ if not path.is_absolute():
209
+ path = project_root / path
210
+ if not path.is_file():
211
+ return None, (
212
+ f"artifact lead event log not found: {path} — artifact-only "
213
+ "conformance cannot be verified."
214
+ )
215
+ return path, None
216
+
217
+
218
+ def _event_matches_run(event, team_state: dict, task_type: str, run_seq: str) -> bool:
219
+ task_key = str(team_state.get("taskKey", ""))
220
+ expected_runtime = str(team_state.get("leadRuntime", "") or "claude-code")
221
+ if event.lead_runtime != expected_runtime:
222
+ return False
223
+ if task_key and event.task_key != task_key:
224
+ return False
225
+ return event.task_type == task_type and event.run_seq == run_seq
226
+
227
+
228
+ def _progress_line_from_event(event) -> tuple[str, str, str] | None:
229
+ details = event.details
230
+ phase = details.get("phase")
231
+ if not isinstance(phase, str) or not phase:
232
+ line_candidate = details.get("line")
233
+ if isinstance(line_candidate, str):
234
+ match = _PROGRESS_LINE_RE.search(line_candidate)
235
+ if match:
236
+ phase = match.group("phase")
237
+ if not isinstance(phase, str) or not phase:
238
+ return None
239
+ line = details.get("line")
240
+ if not isinstance(line, str) or not line:
241
+ message = details.get("message")
242
+ tail = f" {message}" if isinstance(message, str) and message else ""
243
+ line = f"PROGRESS: {phase}{tail}"
244
+ return (event.timestamp, phase, line)
245
+
246
+
247
+ def _sidecar_read_from_event(event) -> tuple[str, str] | None:
248
+ details = event.details
249
+ if event.event_type != "sidecar-read" and details.get("kind") != "implementation-sidecar":
250
+ return None
251
+ basename = details.get("basename")
252
+ if not isinstance(basename, str) or not basename:
253
+ raw_path = details.get("path") or details.get("filePath")
254
+ basename = Path(str(raw_path or "")).name
255
+ if basename not in _SIDECAR_BASENAMES:
256
+ return None
257
+ return (basename, event.timestamp)
258
+
259
+
260
+ def _collect_artifact_lead_evidence(
261
+ team_state: dict,
262
+ project_root: Path,
263
+ task_type: str,
264
+ suffix: str | None,
265
+ ) -> tuple[_LeadEvidence | None, str | None]:
266
+ if not suffix or "-" not in suffix:
267
+ return None, (
268
+ "artifact lead event log cannot be scoped because team-state filename "
269
+ "does not expose a run artifact suffix."
270
+ )
271
+ events_path, error = _resolve_lead_events_path(team_state, project_root)
272
+ if error:
273
+ return None, error
274
+
275
+ try:
276
+ from okstra_ctl.lead_events import LeadEventParseError, read_lead_events
277
+ except ImportError as exc:
278
+ return None, f"okstra_ctl.lead_events import failed — {exc}"
279
+
280
+ try:
281
+ events = read_lead_events(events_path)
282
+ except LeadEventParseError as exc:
283
+ return None, f"artifact lead event log is malformed — {exc}"
284
+
285
+ run_seq = suffix.rsplit("-", 1)[1]
286
+ evidence = _LeadEvidence(scanned_files=[events_path])
287
+ for event in events:
288
+ if not _event_matches_run(event, team_state, task_type, run_seq):
289
+ continue
290
+ if event.event_type in ("progress", "progress-checkpoint"):
291
+ progress = _progress_line_from_event(event)
292
+ if progress is not None:
293
+ evidence.progress.append(progress)
294
+ elif event.event_type in ("artifact-read", "sidecar-read"):
295
+ read = _sidecar_read_from_event(event)
296
+ if read is not None:
297
+ basename, timestamp = read
298
+ evidence.sidecar_reads.setdefault(basename, []).append(timestamp)
299
+
300
+ evidence.progress.sort()
301
+ for ts_list in evidence.sidecar_reads.values():
302
+ ts_list.sort()
303
+ return evidence, None
304
+
305
+
193
306
  def _convergence_rounds_ran(run_dir: Path, suffix: str | None) -> bool:
194
307
  """이 run 의 convergence state artifact 가 실제 round 를 1회 이상 돌았는지.
195
308
  auto-disable(`totalRounds: 0`)·artifact 부재는 phase-5.5 라인을 요구하지 않는다."""
@@ -420,16 +533,25 @@ def validate_session_conformance(
420
533
  result.errors.append(f"okstra_token_usage import failed — {exc}")
421
534
  return result
422
535
 
423
- run_dir = report_path.parent.parent # reports/ 의 부모 = run 디렉터리
536
+ run_dir = report_path.parent.parent
424
537
  _check_heartbeat_sidecars(run_dir, task_type, result.errors)
538
+ suffix = run_artifact_suffix(team_state_path)
425
539
 
426
- evidence, error = _collect_lead_evidence(
427
- team_state, team_state_path, project_root, claude_projects_dir
428
- )
540
+ lead_runtime = team_state.get("leadRuntime", "") or "claude-code"
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(
546
+ team_state, project_root, task_type, suffix
547
+ )
548
+ else:
549
+ evidence, error = _collect_lead_evidence(
550
+ team_state, team_state_path, project_root, claude_projects_dir
551
+ )
429
552
  if error:
430
553
  result.errors.append(error)
431
554
  return result
432
- suffix = run_artifact_suffix(team_state_path)
433
555
  _check_progress_checkpoints(evidence, team_state, run_dir, suffix, result.errors)
434
556
  if task_type == "implementation":
435
557
  _check_implementation_sidecar_reads(evidence, result.errors)