@optima-chat/optima-agent 0.8.95 → 0.8.97
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/.claude/skills/gen/SKILL.md +1 -1
- package/.claude/skills/video-gen/SKILL.md +449 -0
- package/.claude/skills/video-gen/templates/lifestyle-scene.md +18 -0
- package/.claude/skills/video-gen/templates/pdp-360-showcase.md +17 -0
- package/.claude/skills/video-gen/templates/pdp-feature-highlight.md +18 -0
- package/.claude/skills/video-gen/templates/tiktok-before-after.md +17 -0
- package/.claude/skills/video-gen/templates/tiktok-product-reveal.md +17 -0
- package/.claude/skills/video-gen/templates/tiktok-unboxing.md +18 -0
- package/package.json +1 -1
- package/.claude/skills/video-clone/SKILL.md +0 -199
- package/.claude/skills/video-clone/assets/phase-state-template.json +0 -11
- package/.claude/skills/video-clone/references/ffmpeg-commands.md +0 -42
- package/.claude/skills/video-clone/references/gate-enforcement.md +0 -144
- package/.claude/skills/video-clone/references/kling-api.md +0 -85
- package/.claude/skills/video-clone/references/prompt-template.md +0 -71
- package/.claude/skills/video-clone/references/url-parsing.md +0 -32
- package/.claude/skills/video-clone/references/workflow-system.md +0 -92
- package/.claude/skills/video-clone/scripts/_confirm.py +0 -96
- package/.claude/skills/video-clone/scripts/_confirm_test.py +0 -125
- package/.claude/skills/video-clone/scripts/_gate.py +0 -162
- package/.claude/skills/video-clone/scripts/_gate_e2e_test.py +0 -226
- package/.claude/skills/video-clone/scripts/_gate_test.py +0 -148
- package/.claude/skills/video-clone/scripts/_project.py +0 -56
- package/.claude/skills/video-clone/scripts/analyze_source.py +0 -113
- package/.claude/skills/video-clone/scripts/analyze_source_test.py +0 -52
- package/.claude/skills/video-clone/scripts/assemble.py +0 -106
- package/.claude/skills/video-clone/scripts/confirm.py +0 -12
- package/.claude/skills/video-clone/scripts/edit_first_frame.py +0 -66
- package/.claude/skills/video-clone/scripts/extract_frames.py +0 -108
- package/.claude/skills/video-clone/scripts/gen_video.py +0 -59
- package/.claude/skills/video-clone/scripts/init_project.py +0 -103
- package/.claude/skills/video-clone/scripts/init_project_test.py +0 -106
- package/.claude/skills/video-clone/scripts/kling_generate.py +0 -262
- package/.claude/skills/video-clone/scripts/kling_generate_test.py +0 -191
- package/.claude/skills/video-clone/scripts/preflight.py +0 -102
- package/.claude/skills/video-clone/scripts/preview.py +0 -208
- package/.claude/skills/video-clone/scripts/preview_test.py +0 -169
- package/.claude/skills/video-clone/scripts/save_workflow.py +0 -129
- package/.claude/skills/video-clone/scripts/save_workflow_test.py +0 -106
- package/.claude/skills/video-clone/scripts/status.py +0 -202
- package/.claude/skills/video-clone/scripts/status_test.py +0 -174
|
@@ -1,96 +0,0 @@
|
|
|
1
|
-
"""Shared implementation for confirm.py.
|
|
2
|
-
|
|
3
|
-
run_confirm() handles:
|
|
4
|
-
- argparse with --project / --quote / --force
|
|
5
|
-
- quote validation (non-empty)
|
|
6
|
-
- negation heuristic (refuses quotes that sound like corrections unless --force)
|
|
7
|
-
- resolving the project directory via GEN_OUTPUT_ROOT
|
|
8
|
-
- automatically passing caller=Path(sys.argv[0]).name to _gate.set_gate()
|
|
9
|
-
"""
|
|
10
|
-
from __future__ import annotations
|
|
11
|
-
|
|
12
|
-
import argparse
|
|
13
|
-
import os
|
|
14
|
-
import sys
|
|
15
|
-
from pathlib import Path
|
|
16
|
-
|
|
17
|
-
import _gate
|
|
18
|
-
|
|
19
|
-
NEGATION_MARKERS = (
|
|
20
|
-
"不", "别", "改", "调整", "不行", "不对", "再", "换",
|
|
21
|
-
"no", "not", "don't", "dont", "modify", "change", "different", "wrong",
|
|
22
|
-
)
|
|
23
|
-
|
|
24
|
-
GATE_NAME = "preview_confirmed"
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
def _gen_output_root() -> Path:
|
|
28
|
-
override = os.environ.get("GEN_OUTPUT_ROOT")
|
|
29
|
-
if override:
|
|
30
|
-
return Path(override)
|
|
31
|
-
return Path.cwd() / "gen-output"
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
def _looks_like_negation(quote: str) -> bool:
|
|
35
|
-
low = quote.lower()
|
|
36
|
-
return any(m in low for m in NEGATION_MARKERS)
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
def run_confirm() -> int:
|
|
40
|
-
ap = argparse.ArgumentParser(
|
|
41
|
-
description=f"Record user confirmation for {GATE_NAME}."
|
|
42
|
-
)
|
|
43
|
-
ap.add_argument("--project", required=True, help="Project name under video-clone/")
|
|
44
|
-
ap.add_argument(
|
|
45
|
-
"--quote", required=True,
|
|
46
|
-
help="The user's actual confirmation words (verbatim). Required.",
|
|
47
|
-
)
|
|
48
|
-
ap.add_argument(
|
|
49
|
-
"--force", action="store_true",
|
|
50
|
-
help="Required if the quote contains negation markers "
|
|
51
|
-
"(used when user said something like 'no audio, rest is fine').",
|
|
52
|
-
)
|
|
53
|
-
args = ap.parse_args()
|
|
54
|
-
|
|
55
|
-
quote = args.quote.strip()
|
|
56
|
-
if not quote:
|
|
57
|
-
print(
|
|
58
|
-
"ERROR: --quote is empty. You must pass the user's actual "
|
|
59
|
-
"confirmation words.",
|
|
60
|
-
file=sys.stderr,
|
|
61
|
-
)
|
|
62
|
-
return 1
|
|
63
|
-
|
|
64
|
-
if _looks_like_negation(quote) and not args.force:
|
|
65
|
-
print(
|
|
66
|
-
f"ERROR: quote looks like a correction / negation: {quote!r}\n"
|
|
67
|
-
f"If the user really confirmed (e.g. 'no audio, rest is fine'), "
|
|
68
|
-
f"re-run with --force. Otherwise, go back and get a cleaner "
|
|
69
|
-
f"confirmation from the user.",
|
|
70
|
-
file=sys.stderr,
|
|
71
|
-
)
|
|
72
|
-
return 1
|
|
73
|
-
|
|
74
|
-
project_dir = _gen_output_root() / "video-clone" / args.project
|
|
75
|
-
if not project_dir.is_dir():
|
|
76
|
-
print(
|
|
77
|
-
f"ERROR: project directory not found at {project_dir}\n"
|
|
78
|
-
f"Run init_project.py first.",
|
|
79
|
-
file=sys.stderr,
|
|
80
|
-
)
|
|
81
|
-
return 1
|
|
82
|
-
|
|
83
|
-
# IMPORTANT: caller is sys.argv[0] (the entry script), not __file__.
|
|
84
|
-
# We want the audit to record "confirm.py", not "_confirm.py".
|
|
85
|
-
caller = Path(sys.argv[0]).name if sys.argv and sys.argv[0] else "<unknown>"
|
|
86
|
-
|
|
87
|
-
try:
|
|
88
|
-
_gate.set_gate(project_dir, GATE_NAME, quote, caller=caller)
|
|
89
|
-
except (ValueError, FileNotFoundError, KeyError) as e:
|
|
90
|
-
print(f"ERROR: {e}", file=sys.stderr)
|
|
91
|
-
return 1
|
|
92
|
-
|
|
93
|
-
print(f"OK: {GATE_NAME} recorded for project {args.project}")
|
|
94
|
-
print(f" quote: {quote}")
|
|
95
|
-
print(f" caller: {caller}")
|
|
96
|
-
return 0
|
|
@@ -1,125 +0,0 @@
|
|
|
1
|
-
"""Unit tests for _confirm.run_confirm() — caller injection + quote validation."""
|
|
2
|
-
from __future__ import annotations
|
|
3
|
-
|
|
4
|
-
import json
|
|
5
|
-
import os
|
|
6
|
-
import sys
|
|
7
|
-
import tempfile
|
|
8
|
-
from pathlib import Path
|
|
9
|
-
from unittest.mock import patch
|
|
10
|
-
|
|
11
|
-
SCRIPTS_DIR = Path(__file__).parent
|
|
12
|
-
sys.path.insert(0, str(SCRIPTS_DIR))
|
|
13
|
-
|
|
14
|
-
import _confirm # noqa: E402
|
|
15
|
-
import _gate # noqa: E402
|
|
16
|
-
|
|
17
|
-
TEMPLATE = json.loads(
|
|
18
|
-
(SCRIPTS_DIR.parent / "assets" / "phase-state-template.json").read_text(encoding="utf-8")
|
|
19
|
-
)
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
def _make_project(root: Path, name: str = "p1") -> Path:
|
|
23
|
-
proj = root / "video-clone" / name
|
|
24
|
-
(proj / ".state").mkdir(parents=True)
|
|
25
|
-
state = json.loads(json.dumps(TEMPLATE))
|
|
26
|
-
state["project"] = name
|
|
27
|
-
state["task_type"] = "video_clone"
|
|
28
|
-
(proj / ".state" / "phase.json").write_text(json.dumps(state), encoding="utf-8")
|
|
29
|
-
return proj
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
def _run_with_argv(argv, env=None):
|
|
33
|
-
old_argv = sys.argv
|
|
34
|
-
old_env = dict(os.environ)
|
|
35
|
-
try:
|
|
36
|
-
sys.argv = argv
|
|
37
|
-
if env:
|
|
38
|
-
os.environ.update(env)
|
|
39
|
-
return _confirm.run_confirm()
|
|
40
|
-
finally:
|
|
41
|
-
sys.argv = old_argv
|
|
42
|
-
os.environ.clear()
|
|
43
|
-
os.environ.update(old_env)
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
def test_run_confirm_injects_caller_from_argv0():
|
|
47
|
-
with tempfile.TemporaryDirectory() as td:
|
|
48
|
-
_make_project(Path(td))
|
|
49
|
-
rc = _run_with_argv(
|
|
50
|
-
["confirm.py", "--project", "p1", "--quote", "OK 开始"],
|
|
51
|
-
env={"GEN_OUTPUT_ROOT": td},
|
|
52
|
-
)
|
|
53
|
-
assert rc == 0
|
|
54
|
-
state = _gate.load_state(Path(td) / "video-clone" / "p1")
|
|
55
|
-
history = state["history"]
|
|
56
|
-
assert history[-1]["caller"] == "confirm.py"
|
|
57
|
-
assert history[-1]["user_quote"] == "OK 开始"
|
|
58
|
-
print("PASS test_run_confirm_injects_caller_from_argv0")
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
def test_run_confirm_empty_quote_returns_one():
|
|
62
|
-
with tempfile.TemporaryDirectory() as td:
|
|
63
|
-
_make_project(Path(td))
|
|
64
|
-
rc = _run_with_argv(
|
|
65
|
-
["confirm.py", "--project", "p1", "--quote", " "],
|
|
66
|
-
env={"GEN_OUTPUT_ROOT": td},
|
|
67
|
-
)
|
|
68
|
-
assert rc == 1
|
|
69
|
-
print("PASS test_run_confirm_empty_quote_returns_one")
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
def test_run_confirm_negation_without_force_returns_one():
|
|
73
|
-
with tempfile.TemporaryDirectory() as td:
|
|
74
|
-
_make_project(Path(td))
|
|
75
|
-
rc = _run_with_argv(
|
|
76
|
-
["confirm.py", "--project", "p1", "--quote", "不要这样改"],
|
|
77
|
-
env={"GEN_OUTPUT_ROOT": td},
|
|
78
|
-
)
|
|
79
|
-
assert rc == 1
|
|
80
|
-
print("PASS test_run_confirm_negation_without_force_returns_one")
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
def test_run_confirm_negation_with_force_returns_zero():
|
|
84
|
-
with tempfile.TemporaryDirectory() as td:
|
|
85
|
-
_make_project(Path(td))
|
|
86
|
-
rc = _run_with_argv(
|
|
87
|
-
["confirm.py", "--project", "p1", "--quote", "no audio, rest is fine", "--force"],
|
|
88
|
-
env={"GEN_OUTPUT_ROOT": td},
|
|
89
|
-
)
|
|
90
|
-
assert rc == 0
|
|
91
|
-
print("PASS test_run_confirm_negation_with_force_returns_zero")
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
def test_run_confirm_missing_project_returns_one():
|
|
95
|
-
with tempfile.TemporaryDirectory() as td:
|
|
96
|
-
rc = _run_with_argv(
|
|
97
|
-
["confirm.py", "--project", "does-not-exist", "--quote", "ok"],
|
|
98
|
-
env={"GEN_OUTPUT_ROOT": td},
|
|
99
|
-
)
|
|
100
|
-
assert rc == 1
|
|
101
|
-
print("PASS test_run_confirm_missing_project_returns_one")
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
def test_run_confirm_success_writes_history_with_caller():
|
|
105
|
-
with tempfile.TemporaryDirectory() as td:
|
|
106
|
-
_make_project(Path(td), "p2")
|
|
107
|
-
rc = _run_with_argv(
|
|
108
|
-
["confirm.py", "--project", "p2", "--quote", "好的,开始"],
|
|
109
|
-
env={"GEN_OUTPUT_ROOT": td},
|
|
110
|
-
)
|
|
111
|
-
assert rc == 0
|
|
112
|
-
state = _gate.load_state(Path(td) / "video-clone" / "p2")
|
|
113
|
-
assert state["gates"]["preview_confirmed"]["status"] is True
|
|
114
|
-
assert state["history"][-1]["caller"] == "confirm.py"
|
|
115
|
-
print("PASS test_run_confirm_success_writes_history_with_caller")
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
if __name__ == "__main__":
|
|
119
|
-
test_run_confirm_injects_caller_from_argv0()
|
|
120
|
-
test_run_confirm_empty_quote_returns_one()
|
|
121
|
-
test_run_confirm_negation_without_force_returns_one()
|
|
122
|
-
test_run_confirm_negation_with_force_returns_zero()
|
|
123
|
-
test_run_confirm_missing_project_returns_one()
|
|
124
|
-
test_run_confirm_success_writes_history_with_caller()
|
|
125
|
-
print("All 6 _confirm tests passed.")
|
|
@@ -1,162 +0,0 @@
|
|
|
1
|
-
"""Phase-state machine for the video-clone skill.
|
|
2
|
-
|
|
3
|
-
Every executable script that spends money or writes durable artifacts calls
|
|
4
|
-
`require_gate()` at startup. If the required gate is not yet confirmed, the
|
|
5
|
-
script exits with code 1 and prints a [HARD-GATE BLOCKED] message describing
|
|
6
|
-
exactly how to unblock it.
|
|
7
|
-
|
|
8
|
-
State lives in `<project_dir>/.state/phase.json` (one file per project).
|
|
9
|
-
The schema is defined in `assets/phase-state-template.json`.
|
|
10
|
-
|
|
11
|
-
This module is stdlib-only. Import it from sibling scripts with:
|
|
12
|
-
|
|
13
|
-
from _gate import require_gate
|
|
14
|
-
require_gate(project_dir, "preview_confirmed")
|
|
15
|
-
|
|
16
|
-
Gates (single):
|
|
17
|
-
preview_confirmed — user reviewed the preview bundle and confirmed
|
|
18
|
-
they want video generation to proceed.
|
|
19
|
-
"""
|
|
20
|
-
from __future__ import annotations
|
|
21
|
-
|
|
22
|
-
import json
|
|
23
|
-
import sys
|
|
24
|
-
from datetime import datetime, timezone
|
|
25
|
-
from pathlib import Path
|
|
26
|
-
|
|
27
|
-
GATE_NAMES = ("preview_confirmed",)
|
|
28
|
-
STATE_REL = Path(".state") / "phase.json"
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
def _state_path(project_dir: Path | str) -> Path:
|
|
32
|
-
return Path(project_dir) / STATE_REL
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
def load_state(project_dir: Path | str) -> dict:
|
|
36
|
-
"""Read and parse .state/phase.json for the given project directory."""
|
|
37
|
-
path = _state_path(project_dir)
|
|
38
|
-
if not path.exists():
|
|
39
|
-
raise FileNotFoundError(
|
|
40
|
-
f"No phase state at {path}. Run init_project.py first."
|
|
41
|
-
)
|
|
42
|
-
return json.loads(path.read_text(encoding="utf-8"))
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
def save_state(project_dir: Path | str, state: dict) -> None:
|
|
46
|
-
"""Atomically overwrite .state/phase.json."""
|
|
47
|
-
path = _state_path(project_dir)
|
|
48
|
-
path.parent.mkdir(parents=True, exist_ok=True)
|
|
49
|
-
tmp = path.with_suffix(".json.tmp")
|
|
50
|
-
tmp.write_text(json.dumps(state, indent=2, ensure_ascii=False), encoding="utf-8")
|
|
51
|
-
tmp.replace(path)
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
def _now_iso() -> str:
|
|
55
|
-
return datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
def append_history(state: dict, event: str, **extra) -> None:
|
|
59
|
-
entry = {"event": event, "at": _now_iso()}
|
|
60
|
-
entry.update(extra)
|
|
61
|
-
state.setdefault("history", []).append(entry)
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
def set_gate(
|
|
65
|
-
project_dir: Path | str,
|
|
66
|
-
gate_name: str,
|
|
67
|
-
user_quote: str,
|
|
68
|
-
*,
|
|
69
|
-
caller: str,
|
|
70
|
-
) -> None:
|
|
71
|
-
"""Flip a gate from false → true and log the user's confirmation quote.
|
|
72
|
-
|
|
73
|
-
Raises ValueError if gate_name is unknown, user_quote is empty, or
|
|
74
|
-
caller is empty/whitespace. caller is keyword-only and required so
|
|
75
|
-
bypassing _confirm.run_confirm() either fails or leaves a visible
|
|
76
|
-
`caller` field in history[].
|
|
77
|
-
"""
|
|
78
|
-
if gate_name not in GATE_NAMES:
|
|
79
|
-
raise ValueError(
|
|
80
|
-
f"unknown gate: {gate_name!r}. valid: {', '.join(GATE_NAMES)}"
|
|
81
|
-
)
|
|
82
|
-
if not user_quote or not user_quote.strip():
|
|
83
|
-
raise ValueError("user_quote is required and must be non-empty")
|
|
84
|
-
if not caller or not caller.strip():
|
|
85
|
-
raise ValueError("caller is required and must be non-empty")
|
|
86
|
-
|
|
87
|
-
state = load_state(project_dir)
|
|
88
|
-
if gate_name not in state.get("gates", {}):
|
|
89
|
-
raise KeyError(
|
|
90
|
-
f"gate {gate_name!r} not found in state — likely an old "
|
|
91
|
-
f"3-gate schema. Either complete this project with the "
|
|
92
|
-
f"previous version of the scripts (PR #65), or start a new "
|
|
93
|
-
f"project with init_project.py."
|
|
94
|
-
)
|
|
95
|
-
now = _now_iso()
|
|
96
|
-
state["gates"][gate_name] = {
|
|
97
|
-
"status": True,
|
|
98
|
-
"confirmed_at": now,
|
|
99
|
-
"user_quote": user_quote,
|
|
100
|
-
}
|
|
101
|
-
append_history(state, gate_name, user_quote=user_quote, caller=caller)
|
|
102
|
-
save_state(project_dir, state)
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
def require_gate(project_dir: Path | str, gate_name: str) -> None:
|
|
106
|
-
"""Assert the given gate is confirmed. Exits with code 1 on failure.
|
|
107
|
-
|
|
108
|
-
This is the mechanical HARD-GATE enforcement. Do not catch SystemExit
|
|
109
|
-
to "handle" the failure — the entire point is that Claude cannot
|
|
110
|
-
bypass it.
|
|
111
|
-
"""
|
|
112
|
-
if gate_name not in GATE_NAMES:
|
|
113
|
-
_fail(
|
|
114
|
-
f"require_gate called with unknown gate {gate_name!r}. "
|
|
115
|
-
f"valid: {', '.join(GATE_NAMES)}"
|
|
116
|
-
)
|
|
117
|
-
|
|
118
|
-
try:
|
|
119
|
-
state = load_state(project_dir)
|
|
120
|
-
except FileNotFoundError as e:
|
|
121
|
-
_fail(str(e))
|
|
122
|
-
|
|
123
|
-
gates = state.get("gates", {})
|
|
124
|
-
if gate_name not in gates:
|
|
125
|
-
_fail(
|
|
126
|
-
f"\n[HARD-GATE BLOCKED] {Path(sys.argv[0]).name} expected "
|
|
127
|
-
f"gate {gate_name!r} but it is not present in {project_dir}/"
|
|
128
|
-
f".state/phase.json.\n"
|
|
129
|
-
f"This is most likely an old 3-gate schema project. Either:\n"
|
|
130
|
-
f" 1. Complete this project with the previous version of "
|
|
131
|
-
f"the scripts (PR #65), or\n"
|
|
132
|
-
f" 2. Start a new project with: "
|
|
133
|
-
f"python scripts/init_project.py --name <new-name> "
|
|
134
|
-
f"--task-type <video_clone|video_gen>\n"
|
|
135
|
-
)
|
|
136
|
-
|
|
137
|
-
gate = gates.get(gate_name, {})
|
|
138
|
-
if gate.get("status") is True:
|
|
139
|
-
return # gate satisfied, continue
|
|
140
|
-
|
|
141
|
-
script_name = Path(sys.argv[0]).name if sys.argv and sys.argv[0] else "<script>"
|
|
142
|
-
msg = (
|
|
143
|
-
f"\n[HARD-GATE BLOCKED] {script_name} needs {gate_name}=True\n"
|
|
144
|
-
f"Current state: {gate_name}={gate.get('status', False)}\n"
|
|
145
|
-
f"Project: {Path(project_dir).resolve()}\n"
|
|
146
|
-
f"\n"
|
|
147
|
-
f"To proceed:\n"
|
|
148
|
-
f" 1. Show the preview bundle to the user and wait for their confirmation.\n"
|
|
149
|
-
f" 2. Run: python scripts/confirm.py "
|
|
150
|
-
f"--project <name> --quote \"<user's actual words>\"\n"
|
|
151
|
-
f" 3. Retry this command.\n"
|
|
152
|
-
f"\n"
|
|
153
|
-
f"Claude: do NOT rationalize past this. The gate exists because "
|
|
154
|
-
f"text instructions alone did not stop prior bypass attempts. "
|
|
155
|
-
f"Go get the real user confirmation.\n"
|
|
156
|
-
)
|
|
157
|
-
_fail(msg)
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
def _fail(message: str) -> None:
|
|
161
|
-
print(message, file=sys.stderr)
|
|
162
|
-
sys.exit(1)
|
|
@@ -1,226 +0,0 @@
|
|
|
1
|
-
"""E2E gate-enforcement tests for single-gate model.
|
|
2
|
-
|
|
3
|
-
7 cases:
|
|
4
|
-
1. analyze_source.py runs WITHOUT gate (no gate requirement)
|
|
5
|
-
2. extract_frames.py runs WITHOUT gate (no gate requirement)
|
|
6
|
-
3. edit_first_frame.py runs WITHOUT gate (no gate requirement)
|
|
7
|
-
4. kling_generate.py is BLOCKED by missing preview_confirmed gate
|
|
8
|
-
5. gen_video.py is BLOCKED by missing preview_confirmed gate
|
|
9
|
-
6. save_workflow.py is BLOCKED by missing preview_confirmed gate
|
|
10
|
-
7. Old 3-gate schema is REJECTED by require_gate with informative message
|
|
11
|
-
|
|
12
|
-
Run: python scripts/_gate_e2e_test.py
|
|
13
|
-
"""
|
|
14
|
-
from __future__ import annotations
|
|
15
|
-
|
|
16
|
-
import json
|
|
17
|
-
import os
|
|
18
|
-
import subprocess
|
|
19
|
-
import sys
|
|
20
|
-
import tempfile
|
|
21
|
-
from pathlib import Path
|
|
22
|
-
|
|
23
|
-
SCRIPTS_DIR = Path(__file__).parent
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
def _make_project(tmp: Path, gate: bool = False,
|
|
27
|
-
old_schema: bool = False) -> Path:
|
|
28
|
-
proj = tmp / "video-clone" / "e2e-proj"
|
|
29
|
-
for sub in ("source", "frames", "videos", ".state"):
|
|
30
|
-
(proj / sub).mkdir(parents=True)
|
|
31
|
-
|
|
32
|
-
if old_schema:
|
|
33
|
-
state = {
|
|
34
|
-
"schema_version": 0,
|
|
35
|
-
"project": "e2e-proj",
|
|
36
|
-
"task_type": "video_clone",
|
|
37
|
-
"created_at": "2025-01-01T00:00:00Z",
|
|
38
|
-
"current_phase": 0,
|
|
39
|
-
"gates": {
|
|
40
|
-
"plan_confirmed": {"status": True, "confirmed_at": "x", "user_quote": "y"},
|
|
41
|
-
"prompt_confirmed": {"status": True, "confirmed_at": "x", "user_quote": "y"},
|
|
42
|
-
"frame_confirmed": {"status": True, "confirmed_at": "x", "user_quote": "y"},
|
|
43
|
-
},
|
|
44
|
-
"history": [],
|
|
45
|
-
}
|
|
46
|
-
else:
|
|
47
|
-
state = {
|
|
48
|
-
"schema_version": 1,
|
|
49
|
-
"project": "e2e-proj",
|
|
50
|
-
"task_type": "video_clone",
|
|
51
|
-
"created_at": "2025-01-01T00:00:00Z",
|
|
52
|
-
"current_phase": 0,
|
|
53
|
-
"gates": {
|
|
54
|
-
"preview_confirmed": {
|
|
55
|
-
"status": gate,
|
|
56
|
-
"confirmed_at": "2025-01-01T00:01:00Z" if gate else None,
|
|
57
|
-
"user_quote": "go" if gate else None,
|
|
58
|
-
}
|
|
59
|
-
},
|
|
60
|
-
"history": [],
|
|
61
|
-
}
|
|
62
|
-
(proj / ".state" / "phase.json").write_text(
|
|
63
|
-
json.dumps(state, indent=2), encoding="utf-8"
|
|
64
|
-
)
|
|
65
|
-
return proj
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
def _run(tmp: Path, script: str, extra: list[str] | None = None,
|
|
69
|
-
extra_env: dict | None = None) -> subprocess.CompletedProcess:
|
|
70
|
-
env = os.environ.copy()
|
|
71
|
-
env["GEN_OUTPUT_ROOT"] = str(tmp)
|
|
72
|
-
if extra_env:
|
|
73
|
-
env.update(extra_env)
|
|
74
|
-
cmd = [sys.executable, str(SCRIPTS_DIR / script),
|
|
75
|
-
"--project", "e2e-proj"]
|
|
76
|
-
if extra:
|
|
77
|
-
cmd += extra
|
|
78
|
-
return subprocess.run(cmd, capture_output=True, text=True, env=env)
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
# ------------------------------------------------------------------
|
|
82
|
-
# Case 1: analyze_source.py runs without gate
|
|
83
|
-
# ------------------------------------------------------------------
|
|
84
|
-
def test_analyze_source_runs_without_gate():
|
|
85
|
-
"""analyze_source.py must not call require_gate; a missing source file
|
|
86
|
-
is the expected failure (not a gate block)."""
|
|
87
|
-
with tempfile.TemporaryDirectory() as td:
|
|
88
|
-
tmp = Path(td)
|
|
89
|
-
_make_project(tmp, gate=False)
|
|
90
|
-
result = _run(tmp, "analyze_source.py", ["--source", "nonexistent.mp4"])
|
|
91
|
-
assert "HARD-GATE BLOCKED" not in result.stderr, (
|
|
92
|
-
f"analyze_source must not gate-block. stderr: {result.stderr}"
|
|
93
|
-
)
|
|
94
|
-
assert result.returncode != 0 # fails due to missing file, not gate
|
|
95
|
-
print("PASS test_analyze_source_runs_without_gate")
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
# ------------------------------------------------------------------
|
|
99
|
-
# Case 2: extract_frames.py runs without gate
|
|
100
|
-
# ------------------------------------------------------------------
|
|
101
|
-
def test_extract_frames_runs_without_gate():
|
|
102
|
-
with tempfile.TemporaryDirectory() as td:
|
|
103
|
-
tmp = Path(td)
|
|
104
|
-
_make_project(tmp, gate=False)
|
|
105
|
-
result = _run(tmp, "extract_frames.py", ["--source", "nonexistent.mp4"])
|
|
106
|
-
assert "HARD-GATE BLOCKED" not in result.stderr, (
|
|
107
|
-
f"extract_frames must not gate-block. stderr: {result.stderr}"
|
|
108
|
-
)
|
|
109
|
-
print("PASS test_extract_frames_runs_without_gate")
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
# ------------------------------------------------------------------
|
|
113
|
-
# Case 3: edit_first_frame.py runs without gate
|
|
114
|
-
# ------------------------------------------------------------------
|
|
115
|
-
def test_edit_first_frame_runs_without_gate():
|
|
116
|
-
with tempfile.TemporaryDirectory() as td:
|
|
117
|
-
tmp = Path(td)
|
|
118
|
-
_make_project(tmp, gate=False)
|
|
119
|
-
result = _run(tmp, "edit_first_frame.py", ["--image", "nonexistent.png"])
|
|
120
|
-
assert "HARD-GATE BLOCKED" not in result.stderr, (
|
|
121
|
-
f"edit_first_frame must not gate-block. stderr: {result.stderr}"
|
|
122
|
-
)
|
|
123
|
-
print("PASS test_edit_first_frame_runs_without_gate")
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
# ------------------------------------------------------------------
|
|
127
|
-
# Case 4: kling_generate.py is BLOCKED without preview_confirmed
|
|
128
|
-
# ------------------------------------------------------------------
|
|
129
|
-
def test_kling_generate_blocked_without_gate():
|
|
130
|
-
with tempfile.TemporaryDirectory() as td:
|
|
131
|
-
tmp = Path(td)
|
|
132
|
-
_make_project(tmp, gate=False)
|
|
133
|
-
result = _run(tmp, "kling_generate.py",
|
|
134
|
-
["--frame", "frames/frame_v1.png"],
|
|
135
|
-
extra_env={"PIAPI_KEY": "dummy"})
|
|
136
|
-
assert result.returncode == 1, (
|
|
137
|
-
f"Expected exit 1\nstdout: {result.stdout}\nstderr: {result.stderr}"
|
|
138
|
-
)
|
|
139
|
-
assert "HARD-GATE BLOCKED" in result.stderr, (
|
|
140
|
-
f"Expected HARD-GATE BLOCKED in stderr:\n{result.stderr}"
|
|
141
|
-
)
|
|
142
|
-
print("PASS test_kling_generate_blocked_without_gate")
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
# ------------------------------------------------------------------
|
|
146
|
-
# Case 5: gen_video.py is BLOCKED without preview_confirmed
|
|
147
|
-
# ------------------------------------------------------------------
|
|
148
|
-
def test_gen_video_blocked_without_gate():
|
|
149
|
-
with tempfile.TemporaryDirectory() as td:
|
|
150
|
-
tmp = Path(td)
|
|
151
|
-
_make_project(tmp, gate=False)
|
|
152
|
-
result = _run(tmp, "gen_video.py", ["--frame", "frames/frame_v1.png"])
|
|
153
|
-
assert result.returncode == 1, (
|
|
154
|
-
f"Expected exit 1\nstdout: {result.stdout}\nstderr: {result.stderr}"
|
|
155
|
-
)
|
|
156
|
-
assert "HARD-GATE BLOCKED" in result.stderr, (
|
|
157
|
-
f"Expected HARD-GATE BLOCKED in stderr:\n{result.stderr}"
|
|
158
|
-
)
|
|
159
|
-
print("PASS test_gen_video_blocked_without_gate")
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
# ------------------------------------------------------------------
|
|
163
|
-
# Case 6: save_workflow.py is BLOCKED without preview_confirmed
|
|
164
|
-
# ------------------------------------------------------------------
|
|
165
|
-
def test_save_workflow_blocked_without_gate():
|
|
166
|
-
with tempfile.TemporaryDirectory() as td:
|
|
167
|
-
tmp = Path(td)
|
|
168
|
-
_make_project(tmp, gate=False)
|
|
169
|
-
result = _run(tmp, "save_workflow.py",
|
|
170
|
-
["--name", "wf1", "--scene", "test",
|
|
171
|
-
"--rating", "3", "--strategy", "test strat"])
|
|
172
|
-
assert result.returncode == 1, (
|
|
173
|
-
f"Expected exit 1\nstdout: {result.stdout}\nstderr: {result.stderr}"
|
|
174
|
-
)
|
|
175
|
-
assert "HARD-GATE BLOCKED" in result.stderr, (
|
|
176
|
-
f"Expected HARD-GATE BLOCKED in stderr:\n{result.stderr}"
|
|
177
|
-
)
|
|
178
|
-
print("PASS test_save_workflow_blocked_without_gate")
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
# ------------------------------------------------------------------
|
|
182
|
-
# Case 7: Old 3-gate schema is REJECTED with informative message
|
|
183
|
-
# ------------------------------------------------------------------
|
|
184
|
-
def test_old_3gate_schema_rejected_at_gate_check():
|
|
185
|
-
"""When any gated script encounters an old 3-gate schema, require_gate
|
|
186
|
-
must exit 1 with a message mentioning the old schema."""
|
|
187
|
-
with tempfile.TemporaryDirectory() as td:
|
|
188
|
-
tmp = Path(td)
|
|
189
|
-
_make_project(tmp, old_schema=True)
|
|
190
|
-
result = _run(tmp, "kling_generate.py",
|
|
191
|
-
["--frame", "frames/frame_v1.png"],
|
|
192
|
-
extra_env={"PIAPI_KEY": "dummy"})
|
|
193
|
-
assert result.returncode == 1
|
|
194
|
-
stderr = result.stderr
|
|
195
|
-
assert any(kw in stderr for kw in (
|
|
196
|
-
"old 3-gate", "schema", "plan_confirmed", "HARD-GATE BLOCKED"
|
|
197
|
-
)), f"Expected old-schema rejection message in stderr:\n{stderr}"
|
|
198
|
-
print("PASS test_old_3gate_schema_rejected_at_gate_check")
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
TESTS = [
|
|
202
|
-
("analyze_source runs without gate", test_analyze_source_runs_without_gate),
|
|
203
|
-
("extract_frames runs without gate", test_extract_frames_runs_without_gate),
|
|
204
|
-
("edit_first_frame runs without gate", test_edit_first_frame_runs_without_gate),
|
|
205
|
-
("kling_generate blocked without gate", test_kling_generate_blocked_without_gate),
|
|
206
|
-
("gen_video blocked without gate", test_gen_video_blocked_without_gate),
|
|
207
|
-
("save_workflow blocked without gate", test_save_workflow_blocked_without_gate),
|
|
208
|
-
("old 3-gate schema rejected", test_old_3gate_schema_rejected_at_gate_check),
|
|
209
|
-
]
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
def main() -> int:
|
|
213
|
-
failed = 0
|
|
214
|
-
for name, fn in TESTS:
|
|
215
|
-
try:
|
|
216
|
-
fn()
|
|
217
|
-
print(f"PASS {name}")
|
|
218
|
-
except Exception as e:
|
|
219
|
-
failed += 1
|
|
220
|
-
print(f"FAIL {name}: {type(e).__name__}: {e}")
|
|
221
|
-
print(f"\n{len(TESTS) - failed}/{len(TESTS)} passed")
|
|
222
|
-
return 0 if failed == 0 else 1
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
if __name__ == "__main__":
|
|
226
|
-
sys.exit(main())
|