@optima-chat/optima-agent 0.8.91 → 0.8.92

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 (44) 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 +360 -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 +39 -72
  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 +182 -0
  37. package/.claude/skills/video-clone/scripts/preflight.py +95 -0
  38. package/.claude/skills/video-clone/scripts/preview.py +208 -0
  39. package/.claude/skills/video-clone/scripts/preview_test.py +169 -0
  40. package/.claude/skills/video-clone/scripts/save_workflow.py +129 -0
  41. package/.claude/skills/video-clone/scripts/save_workflow_test.py +106 -0
  42. package/.claude/skills/video-clone/scripts/status.py +202 -0
  43. package/.claude/skills/video-clone/scripts/status_test.py +174 -0
  44. package/package.json +2 -1
@@ -0,0 +1,169 @@
1
+ """Tests for preview.py — Phase 4 artifact collection."""
2
+ from __future__ import annotations
3
+
4
+ import json
5
+ import os
6
+ import subprocess
7
+ import sys
8
+ import tempfile
9
+ from pathlib import Path
10
+
11
+ SCRIPTS_DIR = Path(__file__).parent
12
+ SKILL_ROOT = SCRIPTS_DIR.parent
13
+
14
+
15
+ def _make_project(tmp: Path, task_type: str = "video_clone") -> Path:
16
+ proj = tmp / "video-clone" / "test-proj"
17
+ for sub in ("source", "frames", "videos", ".state"):
18
+ (proj / sub).mkdir(parents=True)
19
+ state = {
20
+ "schema_version": 1,
21
+ "project": "test-proj",
22
+ "task_type": task_type,
23
+ "created_at": "2025-01-01T00:00:00Z",
24
+ "current_phase": 0,
25
+ "gates": {
26
+ "preview_confirmed": {"status": False, "confirmed_at": None, "user_quote": None}
27
+ },
28
+ "history": [],
29
+ }
30
+ (proj / ".state" / "phase.json").write_text(
31
+ json.dumps(state, indent=2), encoding="utf-8"
32
+ )
33
+ return proj
34
+
35
+
36
+ def _run(tmp: Path, extra_args: list[str] | None = None) -> subprocess.CompletedProcess:
37
+ env = os.environ.copy()
38
+ env["GEN_OUTPUT_ROOT"] = str(tmp)
39
+ cmd = [sys.executable, str(SCRIPTS_DIR / "preview.py"), "--project", "test-proj"]
40
+ if extra_args:
41
+ cmd += extra_args
42
+ return subprocess.run(cmd, capture_output=True, text=True, env=env)
43
+
44
+
45
+ def _add_analysis(proj: Path) -> None:
46
+ analysis = {
47
+ "duration_s": 10,
48
+ "width": 1280,
49
+ "height": 720,
50
+ "fps": 30.0,
51
+ "has_audio": False,
52
+ "segments": 1,
53
+ "classification": "single",
54
+ "scene_cuts": [],
55
+ }
56
+ (proj / "source" / "analysis_v1.json").write_text(
57
+ json.dumps(analysis), encoding="utf-8"
58
+ )
59
+
60
+
61
+ def _add_grid(proj: Path) -> None:
62
+ extract = proj / "frames" / "extract_v1"
63
+ extract.mkdir(parents=True, exist_ok=True)
64
+ (extract / "grid.jpg").write_bytes(b"\xff\xd8\xff\xe0dummy")
65
+
66
+
67
+ def _add_prompt(proj: Path) -> None:
68
+ (proj / "prompt.md").write_text("A person walking.", encoding="utf-8")
69
+
70
+
71
+ def _add_frame(proj: Path) -> None:
72
+ (proj / "frames" / "frame_v1.png").write_bytes(b"\x89PNG\r\ndummy")
73
+
74
+
75
+ def test_preview_full_artifacts_writes_v1():
76
+ with tempfile.TemporaryDirectory() as td:
77
+ tmp = Path(td)
78
+ proj = _make_project(tmp)
79
+ _add_analysis(proj)
80
+ _add_grid(proj)
81
+ _add_prompt(proj)
82
+ _add_frame(proj)
83
+
84
+ result = _run(tmp)
85
+ assert result.returncode == 0, f"Expected 0 but got {result.returncode}\nstderr: {result.stderr}"
86
+ out_file = proj / "preview_v1.md"
87
+ assert out_file.is_file(), "preview_v1.md should be written"
88
+ content = out_file.read_text(encoding="utf-8")
89
+ assert "# Preview: test-proj" in content
90
+ assert "## 1. 技术方案" in content
91
+ assert "## 4. Motion Prompt" in content
92
+ assert "A person walking." in content
93
+ print("PASS test_preview_full_artifacts_writes_v1")
94
+
95
+
96
+ def test_preview_missing_prompt_does_not_write_file_and_exits_one():
97
+ with tempfile.TemporaryDirectory() as td:
98
+ tmp = Path(td)
99
+ proj = _make_project(tmp)
100
+ _add_analysis(proj)
101
+ _add_grid(proj)
102
+ # NO prompt
103
+ _add_frame(proj)
104
+
105
+ result = _run(tmp)
106
+ assert result.returncode == 1, f"Expected 1 but got {result.returncode}"
107
+ assert "prompt.md" in result.stderr
108
+ out_file = proj / "preview_v1.md"
109
+ assert not out_file.exists(), "preview file must NOT be written on failure"
110
+ print("PASS test_preview_missing_prompt_does_not_write_file_and_exits_one")
111
+
112
+
113
+ def test_preview_missing_cost_json_uses_tbd():
114
+ with tempfile.TemporaryDirectory() as td:
115
+ tmp = Path(td)
116
+ proj = _make_project(tmp)
117
+ _add_analysis(proj)
118
+ _add_grid(proj)
119
+ _add_prompt(proj)
120
+ _add_frame(proj)
121
+ # No cost.json
122
+
123
+ result = _run(tmp)
124
+ assert result.returncode == 0, f"stderr: {result.stderr}"
125
+ content = (proj / "preview_v1.md").read_text(encoding="utf-8")
126
+ assert "TBD" in content
127
+ print("PASS test_preview_missing_cost_json_uses_tbd")
128
+
129
+
130
+ def test_preview_rerun_creates_v2():
131
+ with tempfile.TemporaryDirectory() as td:
132
+ tmp = Path(td)
133
+ proj = _make_project(tmp)
134
+ _add_analysis(proj)
135
+ _add_grid(proj)
136
+ _add_prompt(proj)
137
+ _add_frame(proj)
138
+
139
+ r1 = _run(tmp)
140
+ assert r1.returncode == 0
141
+ assert (proj / "preview_v1.md").is_file()
142
+
143
+ r2 = _run(tmp)
144
+ assert r2.returncode == 0
145
+ assert (proj / "preview_v2.md").is_file()
146
+ print("PASS test_preview_rerun_creates_v2")
147
+
148
+
149
+ def test_preview_video_gen_only_needs_prompt():
150
+ with tempfile.TemporaryDirectory() as td:
151
+ tmp = Path(td)
152
+ proj = _make_project(tmp, task_type="video_gen")
153
+ _add_prompt(proj)
154
+ # No analysis, no grid, no frame
155
+
156
+ result = _run(tmp)
157
+ assert result.returncode == 0, f"Expected 0 but got {result.returncode}\nstderr: {result.stderr}"
158
+ content = (proj / "preview_v1.md").read_text(encoding="utf-8")
159
+ assert "纯生成任务" in content
160
+ print("PASS test_preview_video_gen_only_needs_prompt")
161
+
162
+
163
+ if __name__ == "__main__":
164
+ test_preview_full_artifacts_writes_v1()
165
+ test_preview_missing_prompt_does_not_write_file_and_exits_one()
166
+ test_preview_missing_cost_json_uses_tbd()
167
+ test_preview_rerun_creates_v2()
168
+ test_preview_video_gen_only_needs_prompt()
169
+ print("\nAll 5 preview tests passed.")
@@ -0,0 +1,129 @@
1
+ """Phase 5: capture a successful project as a reusable workflow.
2
+
3
+ Usage:
4
+ python scripts/save_workflow.py --name handheld-phone-swap \
5
+ --project <source-project> \
6
+ --scene "手持vlog + 单物品替换" \
7
+ --rating 5 \
8
+ --strategy "双图首帧, t=15s选帧, 简单手部动作"
9
+
10
+ Creates <gen-output>/video-clone/workflows/<name>.md (must not exist) and
11
+ appends a row to workflows/README.md index.
12
+
13
+ This is where experience becomes durable. Without Phase 5, the workflow
14
+ library stays stuck at 2 entries forever.
15
+ """
16
+ from __future__ import annotations
17
+
18
+ import argparse
19
+ import json
20
+ import sys
21
+ from pathlib import Path
22
+
23
+ from _gate import require_gate
24
+ from _project import gen_output_root, resolve_project
25
+
26
+
27
+ README_HEADER = """# Video Clone Workflows
28
+
29
+ | Workflow | 适用场景 | 效果 | 关键策略 |
30
+ |---|---|---|---|
31
+ """
32
+
33
+
34
+ def _load_or_init_readme(readme: Path) -> str:
35
+ if readme.exists():
36
+ return readme.read_text(encoding="utf-8")
37
+ return README_HEADER
38
+
39
+
40
+ def _append_index_row(readme_text: str, name: str, scene: str,
41
+ rating: int, strategy: str) -> str:
42
+ stars = "⭐" * max(1, min(5, rating))
43
+ row = f"| [{name}]({name}.md) | {scene} | {stars} | {strategy} |\n"
44
+ if name in readme_text:
45
+ # already indexed — don't duplicate. Caller should edit manually.
46
+ return readme_text
47
+ return readme_text.rstrip() + "\n" + row
48
+
49
+
50
+ def _workflow_body(name: str, scene: str, strategy: str, project: str) -> str:
51
+ return f"""# {name}
52
+
53
+ ## 适用场景
54
+ {scene}
55
+
56
+ ## 策略
57
+
58
+ ### 首帧
59
+ {strategy}
60
+
61
+ ### Prompt
62
+ (从 {project}/prompt.md 提取可复用片段)
63
+
64
+ ### 视频生成
65
+ (从 {project}/log.md 提取成功的参数组合)
66
+
67
+ ### 后处理
68
+ (特殊 ffmpeg 参数,如有)
69
+
70
+ ## 成功案例
71
+ - {project}
72
+
73
+ ## 踩坑记录
74
+ - (试过但失败的方法)
75
+ """
76
+
77
+
78
+ def main() -> int:
79
+ ap = argparse.ArgumentParser()
80
+ ap.add_argument("--name", required=True, help="workflow slug")
81
+ ap.add_argument("--project", required=True, help="source project name")
82
+ ap.add_argument("--scene", required=True, help="适用场景 one-liner")
83
+ ap.add_argument("--rating", type=int, choices=range(1, 6), required=True)
84
+ ap.add_argument("--strategy", required=True, help="key strategy one-liner")
85
+ args = ap.parse_args()
86
+
87
+ project_dir = resolve_project(args.project)
88
+ require_gate(project_dir, "preview_confirmed")
89
+
90
+ videos = list((project_dir / "videos").glob("*.mp4"))
91
+ if not videos:
92
+ print(
93
+ f"ERROR: cannot save workflow — no videos in {project_dir / 'videos'}.\n"
94
+ f"Run kling_generate.py or gen_video.py first.",
95
+ file=sys.stderr,
96
+ )
97
+ return 1
98
+
99
+ workflows = gen_output_root() / "video-clone" / "workflows"
100
+ workflows.mkdir(parents=True, exist_ok=True)
101
+
102
+ wf_file = workflows / f"{args.name}.md"
103
+ if wf_file.exists():
104
+ print(
105
+ f"ERROR: workflow {wf_file} already exists. Edit it by hand or "
106
+ f"pick a different --name.",
107
+ file=sys.stderr,
108
+ )
109
+ return 1
110
+
111
+ wf_file.write_text(
112
+ _workflow_body(args.name, args.scene, args.strategy, args.project),
113
+ encoding="utf-8",
114
+ )
115
+
116
+ readme = workflows / "README.md"
117
+ readme_text = _load_or_init_readme(readme)
118
+ readme_text = _append_index_row(
119
+ readme_text, args.name, args.scene, args.rating, args.strategy
120
+ )
121
+ readme.write_text(readme_text, encoding="utf-8")
122
+
123
+ print(f"Saved workflow: {wf_file}")
124
+ print(f"Updated index: {readme}")
125
+ return 0
126
+
127
+
128
+ if __name__ == "__main__":
129
+ sys.exit(main())
@@ -0,0 +1,106 @@
1
+ """Tests for save_workflow.py — Phase 5 workflow capture."""
2
+ from __future__ import annotations
3
+
4
+ import json
5
+ import os
6
+ import subprocess
7
+ import sys
8
+ import tempfile
9
+ from pathlib import Path
10
+
11
+ SCRIPTS_DIR = Path(__file__).parent
12
+
13
+
14
+ def _make_project(tmp: Path, gate: bool = True) -> Path:
15
+ proj = tmp / "video-clone" / "test-proj"
16
+ for sub in ("source", "frames", "videos", ".state"):
17
+ (proj / sub).mkdir(parents=True)
18
+ state = {
19
+ "schema_version": 1,
20
+ "project": "test-proj",
21
+ "task_type": "video_clone",
22
+ "created_at": "2025-01-01T00:00:00Z",
23
+ "current_phase": 0,
24
+ "gates": {
25
+ "preview_confirmed": {
26
+ "status": gate,
27
+ "confirmed_at": "2025-01-01T00:01:00Z" if gate else None,
28
+ "user_quote": "go" if gate else None,
29
+ }
30
+ },
31
+ "history": [],
32
+ }
33
+ (proj / ".state" / "phase.json").write_text(
34
+ json.dumps(state, indent=2), encoding="utf-8"
35
+ )
36
+ if gate:
37
+ # Add a video so save_workflow doesn't fail the "no videos" check
38
+ (proj / "videos" / "final_v1.mp4").write_bytes(b"dummy")
39
+ return proj
40
+
41
+
42
+ def _run(tmp: Path, extra_args: list[str] | None = None) -> subprocess.CompletedProcess:
43
+ env = os.environ.copy()
44
+ env["GEN_OUTPUT_ROOT"] = str(tmp)
45
+ cmd = [
46
+ sys.executable, str(SCRIPTS_DIR / "save_workflow.py"),
47
+ "--project", "test-proj",
48
+ "--name", "test-workflow",
49
+ "--scene", "手持 vlog",
50
+ "--rating", "4",
51
+ "--strategy", "双图首帧",
52
+ ]
53
+ if extra_args:
54
+ cmd += extra_args
55
+ return subprocess.run(cmd, capture_output=True, text=True, env=env)
56
+
57
+
58
+ def test_save_workflow_blocked_without_gate():
59
+ with tempfile.TemporaryDirectory() as td:
60
+ tmp = Path(td)
61
+ _make_project(tmp, gate=False)
62
+ result = _run(tmp)
63
+ assert result.returncode == 1, (
64
+ f"Expected exit 1 without gate\nstdout: {result.stdout}\nstderr: {result.stderr}"
65
+ )
66
+ assert "HARD-GATE BLOCKED" in result.stderr or "preview_confirmed" in result.stderr
67
+ print("PASS test_save_workflow_blocked_without_gate")
68
+
69
+
70
+ def test_save_workflow_creates_file_with_gate():
71
+ with tempfile.TemporaryDirectory() as td:
72
+ tmp = Path(td)
73
+ _make_project(tmp, gate=True)
74
+ result = _run(tmp)
75
+ assert result.returncode == 0, (
76
+ f"Expected exit 0\nstdout: {result.stdout}\nstderr: {result.stderr}"
77
+ )
78
+ wf_file = tmp / "video-clone" / "workflows" / "test-workflow.md"
79
+ assert wf_file.is_file(), "workflow file should be created"
80
+ content = wf_file.read_text(encoding="utf-8")
81
+ assert "手持 vlog" in content
82
+ assert "双图首帧" in content
83
+ # README index should be updated
84
+ readme = tmp / "video-clone" / "workflows" / "README.md"
85
+ assert readme.is_file()
86
+ assert "test-workflow" in readme.read_text(encoding="utf-8")
87
+ print("PASS test_save_workflow_creates_file_with_gate")
88
+
89
+
90
+ def test_save_workflow_rejects_duplicate_name():
91
+ with tempfile.TemporaryDirectory() as td:
92
+ tmp = Path(td)
93
+ _make_project(tmp, gate=True)
94
+ r1 = _run(tmp)
95
+ assert r1.returncode == 0
96
+ r2 = _run(tmp)
97
+ assert r2.returncode == 1, "second run with same name should exit 1"
98
+ assert "already exists" in r2.stderr
99
+ print("PASS test_save_workflow_rejects_duplicate_name")
100
+
101
+
102
+ if __name__ == "__main__":
103
+ test_save_workflow_blocked_without_gate()
104
+ test_save_workflow_creates_file_with_gate()
105
+ test_save_workflow_rejects_duplicate_name()
106
+ print("\nAll 3 save_workflow tests passed.")
@@ -0,0 +1,202 @@
1
+ """Cross-session resume helper: show project status and next action.
2
+
3
+ Usage:
4
+ python scripts/status.py --project <name>
5
+
6
+ Reads phase.json and scans the project directory to determine what has been
7
+ done and what the next step is. Exits 0 always (read-only, no side effects).
8
+
9
+ Decision tree (top to bottom, first match wins):
10
+ 1. preview_confirmed gate set → show "ready to generate / already generated"
11
+ 2. preview_v*.md exists → show "run confirm.py"
12
+ 3. prompt.md non-empty AND
13
+ frame_v*.png exists (clone) → show "run preview.py"
14
+ 4. analysis_v*.json exists → show "edit first frame / write prompt.md"
15
+ 5. (nothing) → show "run analyze_source.py"
16
+ """
17
+ from __future__ import annotations
18
+
19
+ import argparse
20
+ import json
21
+ import sys
22
+ from pathlib import Path
23
+
24
+ from _project import resolve_project
25
+
26
+
27
+ def _latest(dir_path: Path, prefix: str, suffix: str) -> Path | None:
28
+ if not dir_path.is_dir():
29
+ return None
30
+ candidates = sorted(
31
+ dir_path.glob(f"{prefix}v*{suffix}"),
32
+ key=lambda p: int(p.stem.removeprefix(prefix).lstrip("v") or "0"),
33
+ )
34
+ return candidates[-1] if candidates else None
35
+
36
+
37
+ def _latest_extract_dir(frames_dir: Path) -> Path | None:
38
+ if not frames_dir.is_dir():
39
+ return None
40
+ extracts = sorted(
41
+ [p for p in frames_dir.iterdir() if p.is_dir() and p.name.startswith("extract_v")],
42
+ key=lambda p: int(p.name.removeprefix("extract_v") or "0"),
43
+ )
44
+ return extracts[-1] if extracts else None
45
+
46
+
47
+ def _gate_status(state: dict) -> bool:
48
+ return bool(
49
+ state.get("gates", {})
50
+ .get("preview_confirmed", {})
51
+ .get("status", False)
52
+ )
53
+
54
+
55
+ def _section(title: str, lines: list[str]) -> str:
56
+ body = "\n".join(f" {l}" for l in lines)
57
+ return f"{'='*60}\n{title}\n{'='*60}\n{body}\n"
58
+
59
+
60
+ def main() -> int:
61
+ ap = argparse.ArgumentParser(description="Show project status and next action.")
62
+ ap.add_argument("--project", required=True)
63
+ args = ap.parse_args()
64
+
65
+ project_dir = resolve_project(args.project)
66
+ state_path = project_dir / ".state" / "phase.json"
67
+ if not state_path.is_file():
68
+ print(f"ERROR: phase.json not found at {state_path}", file=sys.stderr)
69
+ return 1
70
+ state = json.loads(state_path.read_text(encoding="utf-8"))
71
+ task_type = state.get("task_type", "video_clone")
72
+
73
+ # --- Collect artifact presence ---
74
+ analysis_path = _latest(project_dir / "source", "analysis_", ".json")
75
+ extract_dir = _latest_extract_dir(project_dir / "frames")
76
+ grid_path = (
77
+ (extract_dir / "grid.jpg")
78
+ if extract_dir and (extract_dir / "grid.jpg").exists()
79
+ else None
80
+ )
81
+ prompt_path = project_dir / "prompt.md"
82
+ prompt_text = (
83
+ prompt_path.read_text(encoding="utf-8").strip()
84
+ if prompt_path.is_file()
85
+ else None
86
+ )
87
+ frame_path = _latest(project_dir / "frames", "frame_", ".png")
88
+ preview_path = _latest(project_dir, "preview_", ".md")
89
+ cost_path = project_dir / "cost.json"
90
+ videos = sorted((project_dir / "videos").glob("*.mp4"))
91
+ final_videos = [v for v in videos if v.name.startswith("final_")]
92
+ gate_set = _gate_status(state)
93
+
94
+ # --- Build checklist ---
95
+ def tick(condition: bool) -> str:
96
+ return "[x]" if condition else "[ ]"
97
+
98
+ checklist = []
99
+ if task_type == "video_clone":
100
+ checklist.append(f"{tick(analysis_path is not None)} source/analysis_v*.json")
101
+ checklist.append(f"{tick(grid_path is not None)} frames/extract_v*/grid.jpg")
102
+ checklist.append(f"{tick(bool(prompt_text))} prompt.md (non-empty)")
103
+ checklist.append(f"{tick(frame_path is not None)} frames/frame_v*.png")
104
+ else:
105
+ checklist.append(f"{tick(bool(prompt_text))} prompt.md (non-empty)")
106
+ checklist.append(f"{tick(cost_path.is_file())} cost.json (optional)")
107
+ checklist.append(f"{tick(preview_path is not None)} preview_v*.md")
108
+ checklist.append(f"{tick(gate_set)} preview_confirmed gate")
109
+ checklist.append(f"{tick(bool(final_videos))} videos/final_v*.mp4")
110
+
111
+ # --- Decision tree: next action ---
112
+ if gate_set and final_videos:
113
+ next_action = [
114
+ "Project complete.",
115
+ f"Final video: {final_videos[-1].name}",
116
+ "",
117
+ "Optional: capture as workflow:",
118
+ f' python scripts/save_workflow.py --project {args.project} \\',
119
+ ' --name <slug> --scene "<scene>" --rating 5 --strategy "<strategy>"',
120
+ ]
121
+ elif gate_set and not final_videos:
122
+ next_action = [
123
+ "Gate confirmed — ready to generate video.",
124
+ "",
125
+ "Run one of:",
126
+ f" python scripts/kling_generate.py --project {args.project} --frame frames/frame_v*.png",
127
+ f" python scripts/gen_video.py --project {args.project} --frame frames/frame_v*.png",
128
+ ]
129
+ elif preview_path is not None:
130
+ next_action = [
131
+ f"Preview ready: {preview_path.name}",
132
+ "",
133
+ "Review it, then confirm:",
134
+ f' python scripts/confirm.py --project {args.project} --quote "<your words>"',
135
+ ]
136
+ elif (
137
+ task_type == "video_gen"
138
+ and bool(prompt_text)
139
+ ) or (
140
+ task_type == "video_clone"
141
+ and bool(prompt_text)
142
+ and frame_path is not None
143
+ and analysis_path is not None
144
+ and grid_path is not None
145
+ ):
146
+ next_action = [
147
+ "All prep artifacts present — run preview:",
148
+ f" python scripts/preview.py --project {args.project}",
149
+ ]
150
+ elif bool(prompt_text) and task_type == "video_clone":
151
+ # Have prompt but missing frame or grid
152
+ missing_parts = []
153
+ if frame_path is None:
154
+ missing_parts.append("frames/frame_v*.png (edit_first_frame.py)")
155
+ if grid_path is None:
156
+ missing_parts.append("frames/extract_v*/grid.jpg (extract_frames.py)")
157
+ next_action = ["Still needed:"] + missing_parts
158
+ elif analysis_path is not None:
159
+ next_action = [
160
+ "Analysis done — continue prep:",
161
+ f" python scripts/extract_frames.py --project {args.project} --source source/*.mp4",
162
+ f" # then edit first frame and write prompt.md",
163
+ ]
164
+ else:
165
+ if task_type == "video_clone":
166
+ next_action = [
167
+ "Start here:",
168
+ f" python scripts/analyze_source.py --project {args.project} --source <video.mp4>",
169
+ ]
170
+ else:
171
+ next_action = [
172
+ "Start here — write prompt.md:",
173
+ f" {project_dir / 'prompt.md'}",
174
+ ]
175
+
176
+ # --- Log tail ---
177
+ log_path = project_dir / "log.md"
178
+ log_lines: list[str] = []
179
+ if log_path.is_file():
180
+ all_lines = log_path.read_text(encoding="utf-8").splitlines()
181
+ # skip header, take last 10 non-empty
182
+ entries = [l for l in all_lines if l.strip() and not l.startswith("#")]
183
+ log_lines = entries[-10:]
184
+
185
+ # --- Render ---
186
+ sections = [
187
+ _section(
188
+ f"Project: {args.project} [{task_type}]",
189
+ [f"Dir: {project_dir}"],
190
+ ),
191
+ _section("Prep checklist", checklist),
192
+ _section("Next action", next_action),
193
+ ]
194
+ if log_lines:
195
+ sections.append(_section("Recent log (last 10)", log_lines))
196
+
197
+ print("\n".join(sections))
198
+ return 0
199
+
200
+
201
+ if __name__ == "__main__":
202
+ sys.exit(main())