okstra 0.77.0 → 0.78.1
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 +1 -1
- package/README.md +1 -1
- package/bin/okstra +1 -125
- package/docs/contributor-change-matrix.md +12 -0
- package/docs/kr/cli.md +5 -4
- package/docs/project-structure-overview.md +1 -1
- package/docs/superpowers/plans/2026-06-13-repo-risk-hardening.md +493 -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/okstra-trace-cleanup.sh +16 -12
- package/runtime/prompts/profiles/forbidden-actions.json +69 -0
- package/runtime/prompts/profiles/implementation.md +1 -8
- package/runtime/prompts/profiles/release-handoff.md +1 -15
- package/runtime/python/okstra_ctl/codex_dispatch.py +70 -2
- 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_runtime.py +72 -0
- package/runtime/python/okstra_ctl/pr_template.py +4 -3
- package/runtime/python/okstra_ctl/render.py +67 -16
- package/runtime/python/okstra_ctl/run.py +20 -12
- 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/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-team-contract/SKILL.md +12 -0
- package/runtime/validators/forbidden_actions.py +135 -0
- package/runtime/validators/validate-run.py +65 -9
- package/runtime/validators/validate_session_conformance.py +15 -10
- package/src/cli-registry.mjs +277 -0
- package/src/doctor.mjs +93 -4
- package/src/install.mjs +6 -5
- package/src/runtime-manifest.mjs +5 -2
- package/src/team.mjs +63 -0
- 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
|
-
|
|
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
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
-
"
|
|
204
|
-
"(`leadEventsPath`) —
|
|
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"
|
|
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
|
-
|
|
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
|
|
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
|
-
"
|
|
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"
|
|
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
|
|
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
|
-
|
|
540
|
-
|
|
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:
|