@optima-chat/optima-agent 0.8.90 → 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 (46) 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 +136 -429
  16. package/.claude/skills/video-clone/assets/phase-state-template.json +11 -0
  17. package/.claude/skills/video-clone/references/ffmpeg-commands.md +42 -0
  18. package/.claude/skills/video-clone/references/gate-enforcement.md +144 -0
  19. package/.claude/skills/video-clone/references/kling-api.md +52 -0
  20. package/.claude/skills/video-clone/references/prompt-template.md +71 -0
  21. package/.claude/skills/video-clone/references/url-parsing.md +32 -0
  22. package/.claude/skills/video-clone/references/workflow-system.md +92 -0
  23. package/.claude/skills/video-clone/scripts/_confirm.py +96 -0
  24. package/.claude/skills/video-clone/scripts/_confirm_test.py +125 -0
  25. package/.claude/skills/video-clone/scripts/_gate.py +162 -0
  26. package/.claude/skills/video-clone/scripts/_gate_e2e_test.py +226 -0
  27. package/.claude/skills/video-clone/scripts/_gate_test.py +148 -0
  28. package/.claude/skills/video-clone/scripts/_project.py +56 -0
  29. package/.claude/skills/video-clone/scripts/analyze_source.py +113 -0
  30. package/.claude/skills/video-clone/scripts/analyze_source_test.py +52 -0
  31. package/.claude/skills/video-clone/scripts/assemble.py +106 -0
  32. package/.claude/skills/video-clone/scripts/confirm.py +12 -0
  33. package/.claude/skills/video-clone/scripts/edit_first_frame.py +66 -0
  34. package/.claude/skills/video-clone/scripts/extract_frames.py +108 -0
  35. package/.claude/skills/video-clone/scripts/gen_video.py +59 -0
  36. package/.claude/skills/video-clone/scripts/init_project.py +103 -0
  37. package/.claude/skills/video-clone/scripts/init_project_test.py +106 -0
  38. package/.claude/skills/video-clone/scripts/kling_generate.py +182 -0
  39. package/.claude/skills/video-clone/scripts/preflight.py +95 -0
  40. package/.claude/skills/video-clone/scripts/preview.py +208 -0
  41. package/.claude/skills/video-clone/scripts/preview_test.py +169 -0
  42. package/.claude/skills/video-clone/scripts/save_workflow.py +129 -0
  43. package/.claude/skills/video-clone/scripts/save_workflow_test.py +106 -0
  44. package/.claude/skills/video-clone/scripts/status.py +202 -0
  45. package/.claude/skills/video-clone/scripts/status_test.py +174 -0
  46. package/package.json +2 -1
@@ -0,0 +1,42 @@
1
+ # FFmpeg — where the commands live
2
+
3
+ The ffmpeg commands you'd have inlined here are owned by scripts now. Read
4
+ the script source if you need to see the exact flags.
5
+
6
+ | What | Script |
7
+ |---|---|
8
+ | ffprobe + scene detection (`select='gt(scene,0.3)'`) | `scripts/analyze_source.py` |
9
+ | equidistant frame extraction + tile grid | `scripts/extract_frames.py` |
10
+ | single-segment normalize (`-r 30 -crf 18 -c:a aac -b:a 192k`) | `scripts/assemble.py --single` |
11
+ | multi-segment normalize + concat (scale=720:1280 + concat demuxer) | `scripts/assemble.py --multi` |
12
+
13
+ ## Single vs multi-segment heuristic
14
+
15
+ `analyze_source.py` classifies automatically:
16
+
17
+ - scene cuts ≤1 (after filtering out cuts <0.5s apart) → `classification: single`
18
+ - scene cuts ≥2 → `classification: multi`
19
+
20
+ If the auto-classification is wrong, override the plan manually — don't
21
+ edit the script.
22
+
23
+ ## Things the scripts deliberately don't do
24
+
25
+ - **Audio replacement from source** — Kling 3.0 handles lip sync itself;
26
+ don't splice the source audio back in or you'll get misaligned mouths.
27
+ - **Resolution auto-detection for multi-segment** — `assemble.py --multi`
28
+ hardcodes `scale=720:1280`. If your source is different, pass pre-scaled
29
+ clips or extend the script.
30
+ - **Re-encoding `final_v{N}.mp4` after assembly** — if final looks wrong,
31
+ regenerate the constituent videos, not the final.
32
+
33
+ ## Raw command reference (only when debugging outside the scripts)
34
+
35
+ ```bash
36
+ # probe metadata
37
+ ffprobe -v quiet -print_format json -show_format -show_streams video.mp4
38
+
39
+ # concat list format (scripts generate this automatically)
40
+ file 'clip1_norm.mp4'
41
+ file 'clip2_norm.mp4'
42
+ ```
@@ -0,0 +1,144 @@
1
+ # Gate Enforcement
2
+
3
+ This skill enforces its HARD-GATE mechanically, not textually. Every
4
+ executor script that costs money or generates output calls `require_gate()`
5
+ at startup and exits with code 1 if the gate isn't set. You cannot
6
+ rationalize past a CLI that won't produce output.
7
+
8
+ ## State location
9
+
10
+ Each project has its own state file:
11
+
12
+ ```
13
+ gen-output/video-clone/<project>/.state/phase.json
14
+ ```
15
+
16
+ ## Schema (single-gate model — PR #65)
17
+
18
+ ```json
19
+ {
20
+ "schema_version": 1,
21
+ "project": "handheld-phone-swap",
22
+ "task_type": "video_clone",
23
+ "created_at": "2026-04-11T16:55:00Z",
24
+ "current_phase": 0,
25
+ "gates": {
26
+ "preview_confirmed": {"status": false, "confirmed_at": null, "user_quote": null}
27
+ },
28
+ "history": []
29
+ }
30
+ ```
31
+
32
+ There is **one gate**: `preview_confirmed`. It is set by `confirm.py`
33
+ after the user reviews the complete prep preview (analysis + grid +
34
+ prompt + edited frame). The prep scripts (analyze_source, extract_frames,
35
+ edit_first_frame) run freely before confirmation — only the generation
36
+ scripts are gated.
37
+
38
+ Gates are never un-set. Each set operation appends an entry to `history`
39
+ with timestamp + user quote + caller script name, giving you an audit
40
+ trail to answer "did the user actually confirm this?"
41
+
42
+ ## Which script needs which gate
43
+
44
+ | Script | Required gate |
45
+ |---|---|
46
+ | `analyze_source.py` | (none — prep runs freely) |
47
+ | `extract_frames.py` | (none — prep runs freely) |
48
+ | `edit_first_frame.py` | (none — prep runs freely) |
49
+ | `preview.py` | (none — collects artifacts, no cost) |
50
+ | `kling_generate.py` | **preview_confirmed** |
51
+ | `gen_video.py` | **preview_confirmed** |
52
+ | `save_workflow.py` | **preview_confirmed** |
53
+ | `assemble.py` | (none — post-processes already-generated videos) |
54
+ | `init_project.py` | (none — must run before any gate exists) |
55
+ | `confirm.py` | (none — sets the gate) |
56
+ | `preflight.py` | (none — environment check) |
57
+ | `status.py` | (none — read-only) |
58
+
59
+ ## How to set the gate
60
+
61
+ ```bash
62
+ python scripts/confirm.py --project <name> --quote "<user's actual words>"
63
+ ```
64
+
65
+ - **`--quote` is required** and must be non-empty.
66
+ - **Quote heuristic**: if the quote contains negation markers (`不`, `改`,
67
+ `no`, `not`, `change`, `modify`, `wrong`, …), the script refuses to set
68
+ the gate unless you also pass `--force`. This prevents Claude from
69
+ using a correction ("不需要音频") as a confirmation.
70
+ - **Use `--force` sparingly**: only when the user said something like
71
+ "no audio, otherwise good" — a genuine confirmation that contains a
72
+ negation word.
73
+ - The gate records `caller` (the script name) in history. Fabricated
74
+ quotes are detectable in post-mortem review.
75
+
76
+ ## How a blocked script looks
77
+
78
+ ```
79
+ [HARD-GATE BLOCKED] kling_generate.py needs preview_confirmed=True
80
+ Current state: preview_confirmed=False
81
+ Project: /abs/path/to/gen-output/video-clone/handheld-phone-swap
82
+
83
+ To proceed:
84
+ 1. Show the preview bundle to the user and wait for their confirmation.
85
+ 2. Run: python scripts/confirm.py --project <name> --quote "<user's actual words>"
86
+ 3. Retry this command.
87
+
88
+ Claude: do NOT rationalize past this. The gate exists because text
89
+ instructions alone did not stop prior bypass attempts. Go get the real
90
+ user confirmation.
91
+ ```
92
+
93
+ Exit code is always 1. Downstream pipes will break; you can't accidentally
94
+ feed "locked" output into the next step.
95
+
96
+ ## Old 3-gate schema (pre-PR #65)
97
+
98
+ If you have a project created before the single-gate refactor, the state
99
+ file will have `plan_confirmed`, `prompt_confirmed`, `frame_confirmed`
100
+ instead of `preview_confirmed`. Any gated script will exit 1 with:
101
+
102
+ ```
103
+ [HARD-GATE BLOCKED] expected gate 'preview_confirmed' but it is not
104
+ present. This is most likely an old 3-gate schema project.
105
+ Either: complete this project with PR #65 scripts, or start a new project.
106
+ ```
107
+
108
+ Do NOT manually edit old phase.json files to add `preview_confirmed`.
109
+ Start a new project with `init_project.py` and re-run prep.
110
+
111
+ ## Common bypass attempts (and why the gate still wins)
112
+
113
+ | Attempt | Outcome |
114
+ |---|---|
115
+ | "analyze_source is just analysis, no cost" | Runs freely — no gate on prep scripts. Correct behavior. |
116
+ | "I'll edit phase.json to set the gate" | Possible, but leaves a gap in `history`. Audit trail shows no `confirm.py` call. |
117
+ | `confirm.py --quote 'ok'` without asking user | Sets gate with `user_quote: "ok"`. No user types that in isolation — obvious in post-mortem. |
118
+ | "I'll skip preview.py and confirm directly" | `confirm.py` doesn't require preview_v*.md — but `preview.py` must have run first for artifacts to be present for the user to review. |
119
+ | "I'll symlink phase.json to /dev/null" | Exit 1 on read. |
120
+ | "I'll delete .state/" | Exit 1 — "phase.json not found". |
121
+
122
+ The cheapest path is always to actually get the user's confirmation.
123
+
124
+ ## When to read history
125
+
126
+ The `history` array is append-only. Useful cases:
127
+
128
+ - **Resuming a multi-session project** — read history to know what gate
129
+ is set and what the user said. Use `status.py` for a human-readable view.
130
+ - **Debugging bad output** — if a video is wrong, history shows the exact
131
+ quote the user gave when confirming the preview.
132
+ - **Verifying gate authenticity** — `caller` field shows which script set
133
+ the gate. `confirm.py` is the only legitimate caller.
134
+
135
+ ## Why this exists
136
+
137
+ Previous versions had HARD-GATE rules in SKILL.md text with a
138
+ Rationalization Counter table. They did not stop Claude from running
139
+ ffprobe / downloading videos / calling `gen image` before the user
140
+ confirmed. The failure mode was always the same: Claude decided "my action
141
+ doesn't count as execution" and rationalized past the text rule.
142
+
143
+ Mechanical gates end the argument. The script either runs or it doesn't,
144
+ and the decision doesn't depend on how Claude interprets the rules.
@@ -0,0 +1,52 @@
1
+ # Kling 3.0 via PiAPI — what the script handles + what you need to know
2
+
3
+ The full pipeline (frame upload → submit task → poll → download with
4
+ retry) lives in `scripts/kling_generate.py`. Run it, don't hand-roll it.
5
+
6
+ ```bash
7
+ python scripts/kling_generate.py --project <name> --frame <confirmed-frame.png>
8
+ # options: --duration 5|10 --aspect-ratio 9:16 --mode std|pro
9
+ # --cfg-scale 0.5 --no-audio
10
+ ```
11
+
12
+ ## When to use Kling vs `gen video`
13
+
14
+ | Need audio / lip sync? | Use |
15
+ |---|---|
16
+ | Yes | `kling_generate.py` (Kling 3.0, ~$1 per 10s) |
17
+ | No | `gen_video.py` (Wan 2.6, ~$0.02 per 10s) |
18
+
19
+ ## Non-obvious traps (all already handled by the script)
20
+
21
+ - `cfg_scale` **must be float**. Passing a string makes PiAPI coerce it to
22
+ `500` which produces nonsense. The script uses `float(cfg)`.
23
+ - Status field is lowercase `"completed"` (not `"Completed"`).
24
+ - Output field differs by version: 3.0 returns `output.video`, 2.6 returns
25
+ `output.video_url`. Script hardcodes 3.0's `output.video`.
26
+ - `enable_audio` is 3.0-only — no-op on 2.6.
27
+ - PiAPI CDN drops large downloads; the script retries 3× with 5s backoff.
28
+ - Kling rejects base64 in `image_url`. The script uploads to
29
+ freeimage.host first to get a public URL. Do not switch to catbox.moe
30
+ (PiAPI's servers can't reach it).
31
+
32
+ ## Prompt sourcing
33
+
34
+ The script reads the prompt from `<project>/prompt.md`. If you want to
35
+ use a different prompt for a test run, edit `prompt.md` (it's versioned
36
+ via `log.md` in the project dir, so edits are recoverable).
37
+
38
+ ## Negative prompt (hardcoded in script)
39
+
40
+ ```
41
+ slow motion, dreamy, ethereal, cinematic, blurry,
42
+ distorted, deformed hands, extra fingers
43
+ ```
44
+
45
+ Changing this requires editing `kling_generate.py` directly — it's
46
+ deliberately not a CLI flag because the same negatives apply to every run.
47
+
48
+ ## Required environment
49
+
50
+ - `PIAPI_KEY` env var — run `scripts/preflight.py` to verify
51
+ - `requests` Python package — the script imports lazily and prints a
52
+ clear error if missing
@@ -0,0 +1,71 @@
1
+ # Motion Prompt 模板与规范
2
+
3
+ ## 中文 6 段模板
4
+
5
+ 复刻时由 Claude Opus 分析源帧生成,纯生成时根据用户描述编写。
6
+ 写入 `prompt.md` 并打印给用户,Phase 3 从 `prompt.md` 读取。
7
+
8
+ ```
9
+ ### 视觉风格
10
+ [拍摄设备感 + 画面质感 + 色彩方案 + 光线 + 氛围]
11
+
12
+ ### 场景叙述
13
+ [时间地点 + 人物外貌 + 产品描述(重复颜色/特征) + 背景环境]
14
+
15
+ ### 摄影技术
16
+ [景别 + 运镜 + 焦段 + 景深 + 光线] 情绪:[...]
17
+
18
+ ### 动作清单
19
+ - [时间顺序,精确到哪只手]
20
+ - [产品交互,避免复杂手部操作]
21
+
22
+ ### 对话
23
+ - [语言和风格]
24
+
25
+ ### 背景声音
26
+ - [环境音 + 人声 + 无背景音乐]
27
+ ```
28
+
29
+ ## Anti-AI 风格(融入摄影技术段)
30
+
31
+ 手持拍摄/轻微晃动/自然光/无滤镜/纪实感
32
+
33
+ ## 禁用词
34
+
35
+ 梦幻/空灵/电影感/慢动作/丝滑/优雅
36
+
37
+ ## 长度控制
38
+
39
+ 1200-2000 字符,超 2500 必须精简。
40
+
41
+ ## prompt.md 工作流
42
+
43
+ 1. Claude 分析 → 写入 prompt.md → 打印给用户
44
+ 2. 用户说"OK" → 直接用;用户说"改一下" → 用户编辑或告诉 Claude 改
45
+ 3. Phase 3 从 prompt.md 读取生成视频
46
+ 4. 重跑时:修改 prompt.md → 旧版本记录到 log.md
47
+
48
+ ## 示例
49
+
50
+ ```markdown
51
+ # fishing-scale — Prompt
52
+
53
+ ### 视觉风格
54
+ 竖屏手持vlog,自然饱和色彩,明亮日光,无滤镜,纪实感。
55
+
56
+ ### 场景叙述
57
+ 阳光白天,戴眼镜、深蓝头巾、黑色运动上衣的女子跪坐沙滩...
58
+
59
+ ### 摄影技术
60
+ 中景,手持拍摄,轻微晃动,自然光,浅景深。情绪:轻松日常
61
+
62
+ ### 动作清单
63
+ - 左手托住电子秤底部,右手食指轻触屏幕
64
+ - 产品保持静止,人物微笑看向镜头
65
+
66
+ ### 对话
67
+ - 英语,日常对话风格
68
+
69
+ ### 背景声音
70
+ - 海浪声、风声、远处人声,无背景音乐
71
+ ```
@@ -0,0 +1,32 @@
1
+ # URL / Source Download — decision table
2
+
3
+ **Do not use WebFetch** for source videos (anti-scraping, auth walls).
4
+ Choose the right tool based on the URL shape and use it manually — there
5
+ is no script wrapper because the right approach varies by platform.
6
+
7
+ | URL shape | Command |
8
+ |---|---|
9
+ | `tiktok.com/@user/video/<id>` | `scout tiktok video-detail <id>` → grab video URL → `wget` |
10
+ | `vm.tiktok.com/<short>` | `curl -sI <url>` → `Location:` header → extract id → see TikTok row |
11
+ | `douyin.com/video/<id>` | `scout douyin video-download <id>` → `wget` |
12
+ | `v.douyin.com/<short>` | `scout douyin video-by-url "<url>"` |
13
+ | Instagram Reels (`instagram.com/reel/...`) | `scout instagram download-reel "<url>"` |
14
+ | 小红书视频 (`xiaohongshu.com/explore/<id>`) | `scout xhs note-detail <id>` → grab video link → `wget` |
15
+ | Local file path | Pass directly to `--video` |
16
+
17
+ After download, save to `gen-output/video-clone/<project>/source/` and
18
+ then feed the local path to `analyze_source.py --video <path>`.
19
+
20
+ ## Why the script pipeline starts after download
21
+
22
+ Downloading is the *only* step that remains manual because platform APIs
23
+ change faster than the script would. Everything downstream of
24
+ `--video <local-file>` is automated by the Python scripts.
25
+
26
+ ## Sanity checks before running analyze_source.py
27
+
28
+ 1. File size > 0
29
+ 2. `ffprobe -v error <file>` returns no errors
30
+ 3. Duration makes sense for the source (`ffprobe -show_entries format=duration`)
31
+
32
+ If any of these fail, re-download with a different tool before proceeding.
@@ -0,0 +1,92 @@
1
+ # Workflow 经验库系统
2
+
3
+ ## 什么是 Workflow
4
+
5
+ Workflow 是一次成功视频制作的经验总结。它记录了在特定场景下"什么方法效果最好",让相似任务不用从零摸索。
6
+
7
+ ## 目录结构
8
+
9
+ ```
10
+ gen-output/video-clone/workflows/
11
+ ├── README.md ← 索引,每个 workflow 一行
12
+ ├── handheld-product-swap.md ← 手持vlog产品替换
13
+ ├── multi-scene-product-demo.md ← 多场景产品展示
14
+ └── lifestyle-pure-gen.md ← 生活方式纯生成
15
+ ```
16
+
17
+ ## README.md 格式
18
+
19
+ 索引文件,快速定位。每行一个 workflow,格式:
20
+
21
+ ```markdown
22
+ # Video Clone Workflows
23
+
24
+ | Workflow | 适用场景 | 效果 | 关键策略 |
25
+ |---|---|---|---|
26
+ | [handheld-product-swap](handheld-product-swap.md) | 手持vlog + 单物品替换 | ⭐⭐⭐⭐⭐ | 双图首帧, t=15s选帧, 简单手部动作 |
27
+ | [multi-scene-product-demo](multi-scene-product-demo.md) | 多场景产品展示(>2段) | ⭐⭐⭐⭐ | 每段独立首帧, 产品描述跨段一致 |
28
+ ```
29
+
30
+ ## Workflow 文件格式
31
+
32
+ 每个 `.md` 文件包含:
33
+
34
+ ```markdown
35
+ # {workflow-name}
36
+
37
+ ## 适用场景
38
+ - 什么类型的视频适合用这个 workflow
39
+ - 关键特征(单/多段、有无人物、产品类型等)
40
+
41
+ ## 策略
42
+
43
+ ### 首帧
44
+ - 选帧策略(哪个时间点最好、为什么)
45
+ - gen image 参数(单图/双图、prompt 关键词)
46
+ - 踩坑记录(什么不 work)
47
+
48
+ ### Prompt
49
+ - prompt 风格和重点(哪些段需要重点写)
50
+ - 验证有效的 prompt 片段(可直接复用)
51
+ - 禁用/低效的描述方式
52
+
53
+ ### 视频生成
54
+ - 工具选择和参数
55
+ - cfg_scale / duration / mode 配置
56
+
57
+ ### 后处理
58
+ - 特殊的 ffmpeg 参数
59
+
60
+ ## 成功案例
61
+ - 项目名 + 简要结果(链接到项目目录)
62
+
63
+ ## 踩坑记录
64
+ - 试过但失败的方法,避免重蹈覆辙
65
+ ```
66
+
67
+ ## 何时创建新 Workflow
68
+
69
+ 满足以下全部条件:
70
+
71
+ 1. **用户满意** — 最终视频被用户认可
72
+ 2. **新场景** — 没有已有 workflow 完全覆盖
73
+ 3. **有可复用的经验** — 不是纯靠运气,有明确的策略可提炼
74
+
75
+ ## 何时更新已有 Workflow
76
+
77
+ - 同类任务发现了更好的参数/策略
78
+ - 踩了新坑,值得记录避免下次再踩
79
+ - 工具更新导致旧策略需要调整
80
+
81
+ ## 匹配逻辑
82
+
83
+ Phase 0 读取 README.md 后,按以下维度匹配:
84
+
85
+ 1. **视频类型**:手持vlog / 产品展示 / 口播 / 纯展示
86
+ 2. **片段结构**:单片段 / 多片段
87
+ 3. **产品交互**:手持 / 桌面摆放 / 穿戴 / 无人物
88
+ 4. **音频需求**:有口型同步 / 纯BGM / 无音频
89
+
90
+ 完全匹配 → 直接复用策略。
91
+ 部分匹配 → 以最接近的 workflow 为基础调整。
92
+ 无匹配 → 走通用 pipeline。
@@ -0,0 +1,96 @@
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
@@ -0,0 +1,125 @@
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.")