@optima-chat/optima-agent 0.8.91 → 0.8.93
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/browser/SKILL.md +8 -0
- package/.claude/skills/homepage/SKILL.md +4 -3
- package/.claude/skills/kol-outreach/SKILL.md +371 -0
- package/.claude/skills/kol-outreach/template/campaign/CONFIG.md +60 -0
- package/.claude/skills/kol-outreach/template/campaign/CONVERSATIONS/.gitkeep +0 -0
- package/.claude/skills/kol-outreach/template/campaign/KOLS.md +6 -0
- package/.claude/skills/kol-outreach/template/campaign/PROGRESS.md +3 -0
- package/.claude/skills/kol-outreach/template/campaign/TEMPLATES.md +88 -0
- package/.claude/skills/kol-outreach/template/campaign/assets/.gitkeep +0 -0
- package/.claude/skills/kol-outreach/template/merchant/BRAND.md +36 -0
- package/.claude/skills/kol-outreach/template/merchant/CAMPAIGNS.md +6 -0
- package/.claude/skills/kol-outreach/template/merchant/MERCHANT_LIMITS.md +16 -0
- package/.claude/skills/kol-outreach/template/merchant/PROGRESS.md +4 -0
- package/.claude/skills/kol-outreach/template/merchant/README.md +20 -0
- package/.claude/skills/video-clone/SKILL.md +125 -217
- package/.claude/skills/video-clone/assets/phase-state-template.json +11 -0
- package/.claude/skills/video-clone/references/ffmpeg-commands.md +31 -34
- package/.claude/skills/video-clone/references/gate-enforcement.md +144 -0
- package/.claude/skills/video-clone/references/kling-api.md +75 -75
- package/.claude/skills/video-clone/references/url-parsing.md +32 -13
- package/.claude/skills/video-clone/scripts/_confirm.py +96 -0
- package/.claude/skills/video-clone/scripts/_confirm_test.py +125 -0
- package/.claude/skills/video-clone/scripts/_gate.py +162 -0
- package/.claude/skills/video-clone/scripts/_gate_e2e_test.py +226 -0
- package/.claude/skills/video-clone/scripts/_gate_test.py +148 -0
- package/.claude/skills/video-clone/scripts/_project.py +56 -0
- package/.claude/skills/video-clone/scripts/analyze_source.py +113 -0
- package/.claude/skills/video-clone/scripts/analyze_source_test.py +52 -0
- package/.claude/skills/video-clone/scripts/assemble.py +106 -0
- package/.claude/skills/video-clone/scripts/confirm.py +12 -0
- package/.claude/skills/video-clone/scripts/edit_first_frame.py +66 -0
- package/.claude/skills/video-clone/scripts/extract_frames.py +108 -0
- package/.claude/skills/video-clone/scripts/gen_video.py +59 -0
- package/.claude/skills/video-clone/scripts/init_project.py +103 -0
- package/.claude/skills/video-clone/scripts/init_project_test.py +106 -0
- package/.claude/skills/video-clone/scripts/kling_generate.py +262 -0
- package/.claude/skills/video-clone/scripts/kling_generate_test.py +191 -0
- package/.claude/skills/video-clone/scripts/preflight.py +102 -0
- package/.claude/skills/video-clone/scripts/preview.py +208 -0
- package/.claude/skills/video-clone/scripts/preview_test.py +169 -0
- package/.claude/skills/video-clone/scripts/save_workflow.py +129 -0
- package/.claude/skills/video-clone/scripts/save_workflow_test.py +106 -0
- package/.claude/skills/video-clone/scripts/status.py +202 -0
- package/.claude/skills/video-clone/scripts/status_test.py +174 -0
- package/package.json +2 -1
|
@@ -0,0 +1,162 @@
|
|
|
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)
|
|
@@ -0,0 +1,226 @@
|
|
|
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())
|
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
"""Unit tests for _gate.py — single-gate model + caller audit + old-schema rejection."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import json
|
|
5
|
+
import sys
|
|
6
|
+
import tempfile
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
|
|
9
|
+
# Add scripts dir to path so we can import _gate directly.
|
|
10
|
+
SCRIPTS_DIR = Path(__file__).parent
|
|
11
|
+
sys.path.insert(0, str(SCRIPTS_DIR))
|
|
12
|
+
|
|
13
|
+
import _gate # noqa: E402
|
|
14
|
+
|
|
15
|
+
TEMPLATE = json.loads(
|
|
16
|
+
(SCRIPTS_DIR.parent / "assets" / "phase-state-template.json").read_text(encoding="utf-8")
|
|
17
|
+
)
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def _fresh_project(tmp: Path, name: str = "p1") -> Path:
|
|
21
|
+
p = tmp / name
|
|
22
|
+
(p / ".state").mkdir(parents=True)
|
|
23
|
+
state = json.loads(json.dumps(TEMPLATE)) # deep copy
|
|
24
|
+
state["project"] = name
|
|
25
|
+
state["task_type"] = "video_clone"
|
|
26
|
+
(p / ".state" / "phase.json").write_text(json.dumps(state), encoding="utf-8")
|
|
27
|
+
return p
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def test_set_gate_writes_caller_to_history():
|
|
31
|
+
with tempfile.TemporaryDirectory() as td:
|
|
32
|
+
p = _fresh_project(Path(td))
|
|
33
|
+
_gate.set_gate(p, "preview_confirmed", "OK 开始", caller="confirm.py")
|
|
34
|
+
state = _gate.load_state(p)
|
|
35
|
+
history = state["history"]
|
|
36
|
+
assert len(history) == 1
|
|
37
|
+
assert history[0]["event"] == "preview_confirmed"
|
|
38
|
+
assert history[0]["caller"] == "confirm.py"
|
|
39
|
+
assert history[0]["user_quote"] == "OK 开始"
|
|
40
|
+
assert state["gates"]["preview_confirmed"]["status"] is True
|
|
41
|
+
print("PASS test_set_gate_writes_caller_to_history")
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def test_set_gate_missing_caller_raises_typeerror():
|
|
45
|
+
with tempfile.TemporaryDirectory() as td:
|
|
46
|
+
p = _fresh_project(Path(td))
|
|
47
|
+
try:
|
|
48
|
+
_gate.set_gate(p, "preview_confirmed", "OK") # type: ignore[call-arg]
|
|
49
|
+
except TypeError:
|
|
50
|
+
print("PASS test_set_gate_missing_caller_raises_typeerror")
|
|
51
|
+
return
|
|
52
|
+
raise AssertionError("expected TypeError")
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def test_set_gate_empty_caller_raises_valueerror():
|
|
56
|
+
with tempfile.TemporaryDirectory() as td:
|
|
57
|
+
p = _fresh_project(Path(td))
|
|
58
|
+
try:
|
|
59
|
+
_gate.set_gate(p, "preview_confirmed", "OK", caller=" ")
|
|
60
|
+
except ValueError:
|
|
61
|
+
print("PASS test_set_gate_empty_caller_raises_valueerror")
|
|
62
|
+
return
|
|
63
|
+
raise AssertionError("expected ValueError")
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def test_set_gate_records_literal_caller_value():
|
|
67
|
+
"""Bypass via 'caller=ad-hoc' is allowed but leaves visible evidence."""
|
|
68
|
+
with tempfile.TemporaryDirectory() as td:
|
|
69
|
+
p = _fresh_project(Path(td))
|
|
70
|
+
_gate.set_gate(p, "preview_confirmed", "ok", caller="ad-hoc")
|
|
71
|
+
history = _gate.load_state(p)["history"]
|
|
72
|
+
assert history[0]["caller"] == "ad-hoc"
|
|
73
|
+
print("PASS test_set_gate_records_literal_caller_value")
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def test_require_gate_unset_exits_one():
|
|
77
|
+
with tempfile.TemporaryDirectory() as td:
|
|
78
|
+
p = _fresh_project(Path(td))
|
|
79
|
+
try:
|
|
80
|
+
_gate.require_gate(p, "preview_confirmed")
|
|
81
|
+
except SystemExit as e:
|
|
82
|
+
assert e.code == 1
|
|
83
|
+
print("PASS test_require_gate_unset_exits_one")
|
|
84
|
+
return
|
|
85
|
+
raise AssertionError("expected SystemExit(1)")
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def test_require_gate_set_returns_none():
|
|
89
|
+
with tempfile.TemporaryDirectory() as td:
|
|
90
|
+
p = _fresh_project(Path(td))
|
|
91
|
+
_gate.set_gate(p, "preview_confirmed", "ok", caller="confirm.py")
|
|
92
|
+
assert _gate.require_gate(p, "preview_confirmed") is None
|
|
93
|
+
print("PASS test_require_gate_set_returns_none")
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def test_old_3gate_schema_rejected():
|
|
97
|
+
"""An old PR #65 phase.json should be rejected, not silently mishandled."""
|
|
98
|
+
with tempfile.TemporaryDirectory() as td:
|
|
99
|
+
p = Path(td) / "old"
|
|
100
|
+
(p / ".state").mkdir(parents=True)
|
|
101
|
+
old_state = {
|
|
102
|
+
"schema_version": 1,
|
|
103
|
+
"project": "old",
|
|
104
|
+
"task_type": "video_clone",
|
|
105
|
+
"created_at": "2026-01-01T00:00:00Z",
|
|
106
|
+
"current_phase": 0,
|
|
107
|
+
"gates": {
|
|
108
|
+
"plan_confirmed": {"status": False, "confirmed_at": None, "user_quote": None},
|
|
109
|
+
"prompt_confirmed": {"status": False, "confirmed_at": None, "user_quote": None},
|
|
110
|
+
"frame_confirmed": {"status": False, "confirmed_at": None, "user_quote": None},
|
|
111
|
+
},
|
|
112
|
+
"history": [],
|
|
113
|
+
}
|
|
114
|
+
(p / ".state" / "phase.json").write_text(json.dumps(old_state), encoding="utf-8")
|
|
115
|
+
try:
|
|
116
|
+
_gate.require_gate(p, "preview_confirmed")
|
|
117
|
+
except SystemExit as e:
|
|
118
|
+
assert e.code == 1
|
|
119
|
+
print("PASS test_old_3gate_schema_rejected")
|
|
120
|
+
return
|
|
121
|
+
raise AssertionError("expected SystemExit(1)")
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def test_save_state_atomic():
|
|
125
|
+
"""Verify save_state writes via tmp+replace (no partial-write race)."""
|
|
126
|
+
with tempfile.TemporaryDirectory() as td:
|
|
127
|
+
p = _fresh_project(Path(td))
|
|
128
|
+
state = _gate.load_state(p)
|
|
129
|
+
state["project"] = "renamed"
|
|
130
|
+
_gate.save_state(p, state)
|
|
131
|
+
# Verify no orphan .json.tmp file
|
|
132
|
+
tmp = (p / ".state" / "phase.json.tmp")
|
|
133
|
+
assert not tmp.exists()
|
|
134
|
+
# Verify the new value persisted
|
|
135
|
+
assert _gate.load_state(p)["project"] == "renamed"
|
|
136
|
+
print("PASS test_save_state_atomic")
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
if __name__ == "__main__":
|
|
140
|
+
test_set_gate_writes_caller_to_history()
|
|
141
|
+
test_set_gate_missing_caller_raises_typeerror()
|
|
142
|
+
test_set_gate_empty_caller_raises_valueerror()
|
|
143
|
+
test_set_gate_records_literal_caller_value()
|
|
144
|
+
test_require_gate_unset_exits_one()
|
|
145
|
+
test_require_gate_set_returns_none()
|
|
146
|
+
test_old_3gate_schema_rejected()
|
|
147
|
+
test_save_state_atomic()
|
|
148
|
+
print("\nAll 8 _gate tests passed.")
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
"""Tiny helper to resolve a project directory under gen-output/video-clone/.
|
|
2
|
+
|
|
3
|
+
Used by all executor scripts so they share the same GEN_OUTPUT_ROOT convention.
|
|
4
|
+
"""
|
|
5
|
+
from __future__ import annotations
|
|
6
|
+
|
|
7
|
+
import os
|
|
8
|
+
import sys
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def gen_output_root() -> Path:
|
|
13
|
+
override = os.environ.get("GEN_OUTPUT_ROOT")
|
|
14
|
+
if override:
|
|
15
|
+
return Path(override)
|
|
16
|
+
return Path.cwd() / "gen-output"
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def resolve_project(name: str) -> Path:
|
|
20
|
+
path = gen_output_root() / "video-clone" / name
|
|
21
|
+
if not path.is_dir():
|
|
22
|
+
print(
|
|
23
|
+
f"ERROR: project directory not found at {path}\n"
|
|
24
|
+
f"Run: python scripts/init_project.py --name {name} --task-type video_clone",
|
|
25
|
+
file=sys.stderr,
|
|
26
|
+
)
|
|
27
|
+
sys.exit(1)
|
|
28
|
+
return path
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def next_version(dir_path: Path, prefix: str, suffix: str) -> Path:
|
|
32
|
+
"""Return dir_path / f'{prefix}v{N}{suffix}' with N = highest existing + 1.
|
|
33
|
+
|
|
34
|
+
Example: next_version(frames_dir, 'frame_', '.png') →
|
|
35
|
+
frames_dir/frame_v3.png (if v1, v2 already exist)
|
|
36
|
+
"""
|
|
37
|
+
n = 1
|
|
38
|
+
while (dir_path / f"{prefix}v{n}{suffix}").exists():
|
|
39
|
+
n += 1
|
|
40
|
+
return dir_path / f"{prefix}v{n}{suffix}"
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def append_log(project_dir: Path, event: str) -> None:
|
|
44
|
+
"""Append one human-readable event line to <project_dir>/log.md.
|
|
45
|
+
|
|
46
|
+
Format: 'HH:MM:SS <event>'. Used by status.py to render the History
|
|
47
|
+
section. This is the human-readable ledger; phase.json:history[] is the
|
|
48
|
+
machine-significance audit ledger and stays gate-events-only.
|
|
49
|
+
"""
|
|
50
|
+
from datetime import datetime, timezone
|
|
51
|
+
log = project_dir / "log.md"
|
|
52
|
+
line = f"{datetime.now(timezone.utc).strftime('%H:%M:%S')} {event}\n"
|
|
53
|
+
if not log.exists():
|
|
54
|
+
log.write_text("# Log\n\n", encoding="utf-8")
|
|
55
|
+
with log.open("a", encoding="utf-8") as f:
|
|
56
|
+
f.write(line)
|