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.
- package/README.kr.md +5 -3
- package/README.md +5 -3
- package/bin/okstra +1 -117
- package/docs/contributor-change-matrix.md +12 -0
- package/docs/kr/architecture.md +1 -1
- package/docs/kr/cli.md +22 -5
- package/docs/pr-template-usage.md +3 -3
- package/docs/project-structure-overview.md +1 -1
- package/docs/superpowers/plans/2026-05-25-okstra-project-root-rename.md +1 -1
- package/docs/superpowers/plans/2026-06-13-repo-risk-hardening.md +493 -0
- package/docs/superpowers/specs/2026-06-12-codex-lead-adapter-design.md +358 -0
- package/docs/superpowers/specs/2026-06-13-forbidden-actions-ssot-design.md +134 -0
- package/docs/superpowers/specs/2026-06-13-neutral-tmux-lead-adapter-design.md +284 -0
- package/package.json +5 -2
- package/runtime/BUILD.json +2 -2
- package/runtime/DO_NOT_EDIT.md +21 -0
- package/runtime/agents/SKILL.md +3 -0
- package/runtime/bin/lib/okstra/cli.sh +5 -1
- package/runtime/bin/lib/okstra/globals.sh +1 -0
- package/runtime/bin/lib/okstra/usage.sh +6 -4
- package/runtime/bin/okstra-trace-cleanup.sh +16 -12
- package/runtime/bin/okstra.sh +1 -0
- package/runtime/prompts/launch.template.md +4 -13
- package/runtime/prompts/profiles/error-analysis.md +6 -0
- package/runtime/prompts/profiles/forbidden-actions.json +69 -0
- package/runtime/prompts/profiles/implementation.md +1 -8
- package/runtime/prompts/profiles/improvement-discovery.md +5 -0
- package/runtime/prompts/profiles/release-handoff.md +3 -17
- package/runtime/python/okstra_ctl/analysis_packet.py +17 -0
- package/runtime/python/okstra_ctl/codex_dispatch.py +1552 -0
- package/runtime/python/okstra_ctl/context_cost.py +1 -1
- package/runtime/python/okstra_ctl/dispatch_core.py +897 -0
- package/runtime/python/okstra_ctl/doctor.py +292 -0
- package/runtime/python/okstra_ctl/lead_events.py +129 -0
- package/runtime/python/okstra_ctl/lead_runtime.py +72 -0
- package/runtime/python/okstra_ctl/paths.py +3 -0
- package/runtime/python/okstra_ctl/pr_template.py +1 -1
- package/runtime/python/okstra_ctl/render.py +211 -29
- package/runtime/python/okstra_ctl/run.py +89 -18
- package/runtime/python/okstra_ctl/team.py +267 -0
- package/runtime/python/okstra_ctl/tmux.py +181 -10
- package/runtime/python/okstra_ctl/workflow.py +30 -71
- package/runtime/python/okstra_ctl/wrapper_status.py +55 -0
- package/runtime/python/okstra_token_usage/codex.py +12 -7
- package/runtime/python/okstra_token_usage/collect.py +243 -54
- package/runtime/python/okstra_token_usage/gemini.py +12 -7
- package/runtime/schemas/final-report-v1.0.schema.json +3 -1
- package/runtime/skills/okstra-convergence/SKILL.md +3 -0
- package/runtime/skills/okstra-report-writer/SKILL.md +3 -0
- package/runtime/skills/okstra-setup/SKILL.md +1 -1
- package/runtime/skills/okstra-team-contract/SKILL.md +12 -0
- package/runtime/validators/forbidden_actions.py +135 -0
- package/runtime/validators/validate-run.py +112 -22
- package/runtime/validators/validate_session_conformance.py +127 -5
- package/src/cli-registry.mjs +277 -0
- package/src/codex-dispatch.mjs +70 -0
- package/src/codex-run.mjs +68 -0
- package/src/doctor.mjs +130 -5
- package/src/install.mjs +123 -26
- package/src/paths.mjs +17 -3
- package/src/render-bundle.mjs +2 -0
- package/src/runtime-manifest.mjs +29 -0
- package/src/team.mjs +63 -0
- package/src/uninstall.mjs +27 -4
- /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
|
-
|
|
405
|
-
if
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
"
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
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
|
-
|
|
420
|
-
if
|
|
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.
|
|
423
|
-
|
|
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(
|
|
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,
|
|
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(
|
|
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(
|
|
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
|
|
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
|
-
|
|
427
|
-
|
|
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)
|