@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.
Files changed (45) hide show
  1. package/.claude/skills/browser/SKILL.md +8 -0
  2. package/.claude/skills/homepage/SKILL.md +4 -3
  3. package/.claude/skills/kol-outreach/SKILL.md +371 -0
  4. package/.claude/skills/kol-outreach/template/campaign/CONFIG.md +60 -0
  5. package/.claude/skills/kol-outreach/template/campaign/CONVERSATIONS/.gitkeep +0 -0
  6. package/.claude/skills/kol-outreach/template/campaign/KOLS.md +6 -0
  7. package/.claude/skills/kol-outreach/template/campaign/PROGRESS.md +3 -0
  8. package/.claude/skills/kol-outreach/template/campaign/TEMPLATES.md +88 -0
  9. package/.claude/skills/kol-outreach/template/campaign/assets/.gitkeep +0 -0
  10. package/.claude/skills/kol-outreach/template/merchant/BRAND.md +36 -0
  11. package/.claude/skills/kol-outreach/template/merchant/CAMPAIGNS.md +6 -0
  12. package/.claude/skills/kol-outreach/template/merchant/MERCHANT_LIMITS.md +16 -0
  13. package/.claude/skills/kol-outreach/template/merchant/PROGRESS.md +4 -0
  14. package/.claude/skills/kol-outreach/template/merchant/README.md +20 -0
  15. package/.claude/skills/video-clone/SKILL.md +125 -217
  16. package/.claude/skills/video-clone/assets/phase-state-template.json +11 -0
  17. package/.claude/skills/video-clone/references/ffmpeg-commands.md +31 -34
  18. package/.claude/skills/video-clone/references/gate-enforcement.md +144 -0
  19. package/.claude/skills/video-clone/references/kling-api.md +75 -75
  20. package/.claude/skills/video-clone/references/url-parsing.md +32 -13
  21. package/.claude/skills/video-clone/scripts/_confirm.py +96 -0
  22. package/.claude/skills/video-clone/scripts/_confirm_test.py +125 -0
  23. package/.claude/skills/video-clone/scripts/_gate.py +162 -0
  24. package/.claude/skills/video-clone/scripts/_gate_e2e_test.py +226 -0
  25. package/.claude/skills/video-clone/scripts/_gate_test.py +148 -0
  26. package/.claude/skills/video-clone/scripts/_project.py +56 -0
  27. package/.claude/skills/video-clone/scripts/analyze_source.py +113 -0
  28. package/.claude/skills/video-clone/scripts/analyze_source_test.py +52 -0
  29. package/.claude/skills/video-clone/scripts/assemble.py +106 -0
  30. package/.claude/skills/video-clone/scripts/confirm.py +12 -0
  31. package/.claude/skills/video-clone/scripts/edit_first_frame.py +66 -0
  32. package/.claude/skills/video-clone/scripts/extract_frames.py +108 -0
  33. package/.claude/skills/video-clone/scripts/gen_video.py +59 -0
  34. package/.claude/skills/video-clone/scripts/init_project.py +103 -0
  35. package/.claude/skills/video-clone/scripts/init_project_test.py +106 -0
  36. package/.claude/skills/video-clone/scripts/kling_generate.py +262 -0
  37. package/.claude/skills/video-clone/scripts/kling_generate_test.py +191 -0
  38. package/.claude/skills/video-clone/scripts/preflight.py +102 -0
  39. package/.claude/skills/video-clone/scripts/preview.py +208 -0
  40. package/.claude/skills/video-clone/scripts/preview_test.py +169 -0
  41. package/.claude/skills/video-clone/scripts/save_workflow.py +129 -0
  42. package/.claude/skills/video-clone/scripts/save_workflow_test.py +106 -0
  43. package/.claude/skills/video-clone/scripts/status.py +202 -0
  44. package/.claude/skills/video-clone/scripts/status_test.py +174 -0
  45. 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)