diff --git a/.github/workflows/html2video-for-mcode-smoke.yml b/.github/workflows/html2video-for-mcode-smoke.yml
new file mode 100644
index 00000000..f9920f5c
--- /dev/null
+++ b/.github/workflows/html2video-for-mcode-smoke.yml
@@ -0,0 +1,60 @@
+# html2video-for-mcode · scoped smoke CI
+# 官方主 CI(ci.yml)只跑 validate + node --test(无 ffmpeg/playwright, 渲染冒烟会 skip)。
+# 本 workflow 按 CONTRIBUTING/ci.yml 注释给出的"单插件 scoped workflow"模式,
+# 装齐依赖后真实执行全部测试 —— 这是 PR #41 评审要求的可执行测试证据。
+# 触发条件: 仅本插件目录(.github/workflows/ 本文件除外)或本文件自身变更。
+#
+# 第三轮 review 修正(2026-09-18, 1.7.1 才真正推上来 —— 1.7.0 时这份修正只改了本地没推送,
+# PR 分支上一直是旧枚举版, 漏跑 cover-transition / review-round2 / review-round3 / subtitles-invalidate):
+# ① 测试文件不再逐个枚举, 改用 shell glob `tests/*.test.mjs` —— 枚举写法每加一个测试文件就要
+# 手工记得改这里, 历史上已经漏过 css-kit / tokens-fx / chart-kit / table-kit 与上述四个文件。
+# ② 补 permissions: contents: read(与本仓库其余 scoped workflow 一致, 只读仓库)。
+# ③ actions 按仓库主 ci.yml 的同一批版本做**完整 SHA pin**(附版本注释), 不用浮动 tag;
+# SHA 由 gh api repos///commits/ 解析得到, 40 位整(主 ci.yml 的 checkout
+# 那行多了一位字符, 那是宿主仓库自己的笔误, 不去动它 —— 见 PR comment)。
+name: html2video-for-mcode smoke
+
+on:
+ pull_request:
+ paths:
+ - 'plugins/Wzdhehe/html2video-for-mcode/**'
+ - '.github/workflows/html2video-for-mcode-smoke.yml'
+ push:
+ branches: [main]
+ paths:
+ - 'plugins/Wzdhehe/html2video-for-mcode/**'
+ - '.github/workflows/html2video-for-mcode-smoke.yml'
+ workflow_dispatch:
+
+permissions:
+ contents: read
+
+jobs:
+ smoke:
+ name: smoke (ubuntu-latest · ffmpeg + chromium)
+ runs-on: ubuntu-latest
+ timeout-minutes: 20
+ steps:
+ - name: Checkout
+ uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
+
+ - name: Set up Node
+ uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
+ with:
+ node-version: 22
+
+ - name: Install ffmpeg
+ run: sudo apt-get update && sudo apt-get install -y --no-install-recommends ffmpeg
+
+ - name: Install playwright (no package.json / lockfile changes)
+ run: |
+ npm install --no-save --no-package-lock playwright
+ npx playwright install --with-deps chromium
+
+ # 一条命令跑完 tests/ 下的**全部** *.test.mjs(安全 / 策略 / 模板 / 受管块 / 渲染冒烟)。
+ # glob 交给 shell 展开: 以后新增测试文件自动进 CI, 不需要改这里(旧枚举写法漏过 8 个文件)。
+ # 单测超时 300s: 渲染冒烟里的 capture / build-video 在 CI 上要几十秒。
+ - name: All tests (security · policy · templates · css blocks · render smoke)
+ run: |
+ node --test --test-timeout=300000 \
+ plugins/Wzdhehe/html2video-for-mcode/skills/html2video-for-mcode/tests/*.test.mjs
diff --git a/plugins/Wzdhehe/html2video-for-mcode/.claude-plugin/plugin.json b/plugins/Wzdhehe/html2video-for-mcode/.claude-plugin/plugin.json
new file mode 100644
index 00000000..fa4d417b
--- /dev/null
+++ b/plugins/Wzdhehe/html2video-for-mcode/.claude-plugin/plugin.json
@@ -0,0 +1,8 @@
+{
+ "name": "html2video-for-mcode",
+ "version": "1.8.1",
+ "description": "Turn a topic, outline, or script into a narrated MP4: HTML slides with staged entrance animations, TTS voiceover, ffmpeg assembly, and ASR verification.",
+ "skills": [
+ "./skills/html2video-for-mcode/SKILL.md"
+ ]
+}
diff --git a/plugins/Wzdhehe/html2video-for-mcode/CHANGELOG.md b/plugins/Wzdhehe/html2video-for-mcode/CHANGELOG.md
new file mode 100644
index 00000000..527c3bb1
--- /dev/null
+++ b/plugins/Wzdhehe/html2video-for-mcode/CHANGELOG.md
@@ -0,0 +1,370 @@
+# Changelog
+
+## 1.8.1 — 2026-09-19
+
+**Thirteenth-review residue (found by the independent sweep over the published `e845cbd8` tree): `SKILL.md` was the one install surface 1.8.0 left behind**
+
+- **The 1.8.0 channel fix did not reach the file the agent actually reads.** That change collapsed both READMEs' install sections to "copy the reviewed skill directory out of the hosted plugin — not from any other source", but `SKILL.md`'s "Installing into other Agent environments" block still carried a bare `cp -r` whose source was unstated, plus a generic skills-CLI route — which re-admits exactly the "install it from a repository of your choosing" path 1.8.0 removed. It matters more than a README here: `.claude-plugin/plugin.json` points `skills` at this file. Both routes are gone; the block now sources the copy from the hosted plugin directory, worded like the READMEs.
+- No code or test changes (nothing asserts on this block); the version is bumped so published copies carry the corrected text. The removed routes now occur in the published trees only inside the changelog entries that describe their removal.
+
+## 1.8.0 — 2026-09-19
+
+**Thirteenth review (both axes at the exact head): two documented-contract violations fixed, three duplications closed, the rest disclosed**
+
+- **The standalone repository is no longer documented as an install channel.** The host's `docs/architecture.md` is explicit — "the hosted directory is the publication unit … contributors do not create a second repository" — yet this plugin's `homepage` pointed at a second repository and both READMEs offered `npx skills add Wzdhehe/…` / bare `cp -r` as install routes, which routes users around the review gate that guards `main`. `homepage` now points at the hosted plugin directory, and both install sections route through that hosted plugin only ("copy the reviewed skill directory out of the hosted plugin — not from any other source"). The standalone repository remains as a read-only history mirror, no longer a channel.
+- **The paid account is now disclosed as paid.** `CONTRIBUTING.md` requires disclosing "accounts, paid services"; the READMEs named `mmx-cli` / `MINIMAX_API_KEY` without saying they spend a **MiniMax account's paid, quota-metered balance** (billed per character/minute; each transcription call billed by audio duration). Both READMEs now say so, in both languages.
+- **The two redirect-following loops are one function.** `fetch-official-images` had the hop/policy loop implemented twice — once for the `--url` direct-download path, once inline for `--get` — and the second copy lacked the ping-pong cycle guard and the zero-byte body guard. A single `followRedirects(src, get)` core (client injected as an adapter) now serves both paths, so `--get` gained both guards; a structural regression test pins "exactly one hop loop, both call sites go through it, both zero-byte guards present" (a behavioural cycle test cannot run locally: the SSRF policy refuses loopback by design, and public URLs are unreliable in CI).
+- **ffprobe probing consolidated.** The verbatim `format=duration` probe existed three times and the `stream=width,height` probe twice (`asr`, `plan-timings`, `build-video`, `prep-image`); both now live once in `tools.mjs` (`probeDuration`, `probeSize`), behaviour-identical, with local thin wrappers at the call sites.
+- The preview-page file-symlink canary now covers a **per-slide leaf** (`01.html`) in addition to `index.html` — three write sites, previously two of them only code-covered.
+- Judgement calls from the same review, disclosed rather than changed: `review-round2`/`review-round3` are named by review provenance, not by topic (each `describe` block is topical; renaming would orphan the suite list documented in both READMEs); the subtitle fade constants in `capture.mjs` are deliberately re-derived in the Node self-check so drift between the page script and the check would be *caught* rather than hidden by a shared constant; the subtitle-band geometry (render-side bar vs check-side zone) uses intentionally different numbers whose unification would change gate behaviour mid-review; the css-kit region-range merging appears twice in different operations (locate vs remove); and `preview-page`'s `buildPlayPage` embeds the play page as one long template — a structural trade-off, not a defect.
+- The PR body's evidence transcript now matches a fresh validator run (the host has merged unrelated plugins since the old transcript: 26 → 27) and names `publish/validate-plugin.mjs` as an authoring-side tool that is deliberately not part of the shipped plugin tree.
+- Tests **243 → 244 in fourteen files**, all green in the development tree and both published trees.
+
+## 1.7.9 — 2026-09-19
+
+**Docs-only: corrected the 1.7.8 entry's site-class arithmetic** (the per-class numbers did not sum to the headline because the classes overlap — the order-protected site is also a jailed one). The conclusion stands on its own without the tally: an exhaustive primitive-level inventory of every write/delete site across all twenty scripts found no exploitable unjailed write. No code or test changes; versions bumped so the published copies carry the corrected entry.
+
+## 1.7.8 — 2026-09-19
+
+**Eleventh-round audit: the write-site inventory converges; one shipped-in-1.7.7 canary had an unsatisfiable assertion**
+
+- **The 1.7.7 transcode canary's "nothing appears in the input directory" assertion could never pass** on any platform where it actually runs: the test itself plants a symlink at the old temp path, and `existsSync` follows the link to its existing target — so the assertion compared against the test's own plant and would have failed on Linux CI (it never executed anywhere: file symlinks are uncreatable on the author's Windows, and CI runs sit at `action_required`). The assertions are rewritten to what the fix actually guarantees: a before/after diff of the input directory shows **no new entries**, the outside canary file is byte-unchanged, the planted path is still a symlink (not replaced by a regular file), and the `os.tmpdir()` transcode directory is removed by the time the run ends.
+- **PR-body counts re-phrased as capability invariants.** "243 pass, 0 fail, 0 skip" had gone stale the same way twice before — "0 skip" is only true where file symlinks are creatable. The body now states what is invariant (**0 fail in every environment measured**) and describes skips as capability-dependent and always named, instead of carrying per-environment counts.
+- The standards axis' primitive-exhaustive write-site inventory (method changed from file-intuition to primitive enumeration across all twenty scripts) found **no exploitable unjailed write**: jailed sites, CLI-given paths per the documented contract, internal temp files, and one order-protected site (the `build-video` `part-*` writes, disclosed in 1.7.7).
+- Test count unchanged (243 in fourteen files): the canary's assertions were corrected, not added to.
+
+## 1.7.7 — 2026-09-19
+
+**Tenth-round audit: the 1.7.6 "no other member of this class" claim was wrong — one more leaf existed**
+
+- **`asr.mjs` no longer writes its transcode temp next to the input file.** An independent sweep (the spec axis re-did the write-site inventory rather than trusting the changelog) found the one site the 1.7.6 sweep missed: oversized inputs are transcoded to `.asr-.16k.mp3` **beside the input**, so for an input inside the project a pre-planted file symlink at that derived name would be written through by `ffmpeg -y`. The temp now goes to a per-invocation `mkdtemp` directory under `os.tmpdir()`, removed whole when the transcription attempt ends. New canary drives a real 501-second input (past the 500 s limit → real transcode, which runs **before** any network request) with the old temp name planted as a file symlink next to the input: nothing appears in the input directory, the canary is untouched. Runs on Linux CI; names its skip on stock Windows.
+- **Correction to 1.7.6's claim, stated plainly:** its changelog said a sweep found "no other member of this class". That was false — the `asr` transcode temp above was exactly such a member, missed by our own sweep and caught by an independent one. Lesson recorded: a sweep claim about absence is only as good as the inventory it ran on, and "we looked" is not evidence.
+- Disclosed without change: `build-video`'s `part-*.mp3` writes are protected **by order** (the preceding `safeOut`-jailed delete of every old `part-*` rejects a pre-planted leaf symlink before the ffmpeg write) — the invariant is now a code comment so the ordering is not accidentally reordered.
+- Tests **242 → 243 in fourteen files**, all green in the development tree and both published trees (the three file-symlink canaries name their skip where Windows lacks Developer Mode).
+
+## 1.7.6 — 2026-09-19
+
+**Ninth-round audit: the last uncontained leaf, and a body sentence fixed to stop going stale**
+
+- **`grab-frames` frame writes are now leaf-contained.** Its output *directory* was jailed, but `build/introspect/frame--.png` was written with plain `path.join` (the comment argued the `safeId` whitelist rules out path injection — true for traversal, blind to a pre-planted **file symlink**, which `ffmpeg -y` would have written through). The leaf goes through `safeOut` now, with a canary (needs ffmpeg + file symlinks → runs on Linux CI, names its skip on stock Windows). A sweep over all remaining raw write/delete sites in `scripts/` found no other member of this class.
+- **The PR body's tool-less-sandbox sentence no longer carries per-version numbers.** It named "23 / 28 skipped" — true when written (1.7.4), stale twice since, because every release adds tests and the skip count legitimately drifts with the environment. The body now states the invariant ("every test that needs a tool skips with its stated reason — nothing pretends to pass") and points at the CHANGELOG for per-version measurements.
+- Hygiene: the ancestor-link canary file in `safe-paths` is removed after the test instead of lingering in the shared temp root.
+- Tests **241 → 242 in fourteen files**, all green in the development tree and both published trees (the two file-symlink canaries name their skip where Windows lacks Developer Mode).
+
+## 1.7.5 — 2026-09-19
+
+**Third maintainer review, current head: all four code findings fixed**
+
+- **`safeRel` no longer accepts a project-local link to the project's own ancestor.** The "resolved path may sit above the root" fallback — meant for a not-yet-existing project root — also matched a link inside an existing project pointing at its parent, so a read path under the link resolved *outside* the project (reproduced live: a write through `safeRel(root, 'link/new.txt')` landed in the parent directory). The fallback now applies **only while the root itself does not exist**; once it exists, the resolved path must be the root or beneath it. Regression case in `safe-paths` covers both the new-target (the hole) and existing-target shapes.
+- **All three `preview-page` leaf writes are contained.** The output directory was jailed, but `01.html`, `01.nofx.html` and `index.html` inside it were written with plain `path.join` — a pre-planted file symlink redirected the write outside the project. All three go through `safeOut` now, with a file-symlink canary (file links need Windows Developer Mode, so the canary names its skip there and really runs on Linux CI).
+- **`asr.mjs --out` is contained and refuses to clobber.** It previously wrote any path raw and silently overwrote. It now accepts only a project-relative path inside the project (absolute paths rejected with a named error), refuses an existing output without `--force`, and — so the refusals need no network and are testable on bare CI — the checks run *before* the transcription request.
+- **`prep-image --crop` is now a declared exception, not an inconsistency.** Its input and output are given explicitly on the command line (the same trust level as running ffmpeg yourself), so both READMEs carve it out of the write-boundary promise explicitly (overwriting still needs `--force`); every *derived* path remains fully contained.
+- The review also confirmed the earlier rounds' fixes on this head, and noted the fork's CI runs sit at `action_required` — they execute only after a maintainer approves them; the scoped workflow is ready to install ffmpeg + Chromium and run the full suite on approval.
+- Tests **238 → 241 in fourteen files**, all green with 0 skips in the development tree and in both published trees.
+
+## 1.7.4 — 2026-09-18
+
+**Eighth-round audit: no serious findings — one stale evidence line corrected, three small hardenings**
+
+- **The PR body's sandbox sentence had gone stale**: it still said both tool-less sandbox shapes report "0 fail and 23 skipped", which the 1.7.3 evidence itself supersedes (the links-not-creatable shape reports 28 skipped). The body now carries both shape numbers.
+- **`capture` now validates `script.width`/`script.height`** the same way `preview-page` and `build-video` already do (integer, 16–16384) — previously a non-integer died with a raw Playwright stack deep in the browser launch, and an out-of-range value silently produced off-canvas frames. The gate sits **before** the Playwright load, so the new regression case runs on tool-less CI as well.
+- Test-infra hygiene: the two remaining string-concatenated `file://` imports in the test helpers now go through `pathToFileURL` (a checkout path containing `#` or `%` used to break them loudly), and the suite's temp directories are removed on process exit instead of accumulating in `%TEMP%`.
+- Disclosed, not changed: `preview-page`'s `?s=k` step-stepping silently disables itself when a hand-written slide has no `` for the script to attach to (generated slides always have one).
+- Tests **237 → 238 in fourteen files**, all green with 0 skips in the development tree and in both published trees; both tool-less sandbox shapes 0 fail (23 and 28 skipped, reasons stated).
+
+## 1.7.3 — 2026-09-18
+
+**Fifth-round audit: five containment tests could pass vacuously where links cannot be created**
+
+- Five tests that build a symlink/junction to exercise the path jail (`safeRel` escape, `safeRel` dangling link, unit-level `safeOut`, `init-project --upgrade-css` containment, `init-project` skeleton containment) ended with a bare early `return` when the link could not be created — green, but testing nothing, which contradicts the README's "nothing pretends to pass" promise (the seventh-round review caught it; two of the five were pointed out, a repo-wide grep found all five). They now skip **with a named reason**, and the `safeRel` escape test gained the junction fallback so it really runs on stock Windows instead of skipping there.
+- Verified in both tool-less sandbox shapes: links creatable → 237 tests, 0 fail, 23 skipped; links not creatable → 209 pass, 0 fail, 28 skipped — the five formerly vacuous passes are now the five extra named skips, and no shape reports a pass it did not earn.
+- Small dedups from the same pass: `build-video` reads `--transition` through the shared `flagValue` instead of an inline reimplementation; the e2e image-listing fixture builds its `file://` URL with `pathToFileURL` (temp paths with spaces/non-ASCII). Disclosed, not changed: the `--get` download loop re-walks redirects without self-loop detection — it is bounded by `MAX_REDIRECTS`, so this is redundancy, not a hole; the test-side Playwright probe runs one synchronous `npm root -g` at discovery.
+
+## 1.7.2 — 2026-09-18
+
+**Final audit round: one silent feature regression, one environment-dependent test, and a corrected evidence claim**
+
+- **`--json` never printed JSON in its documented position.** The flag is boolean, but it was read through the value-taking reader, so in the documented trailing position (`fetch-official-images.mjs --json`) it resolved to `undefined` and the listing silently degraded to the human-readable form. It is now read by presence, and a new end-to-end case drives a local `file://` fixture page through the real listing path with the flag in **both** positions, asserting actual JSON array output (the pre-existing case only asserted that the run reached URL validation, so it could not see this).
+- **The two symlink-canary cases could redden a tool-less CI.** They had no Playwright guard, and both `capture` and `preview-page` load Playwright *before* their containment check: on a runner where symlinks/junctions are creatable but Playwright is absent (an ubuntu CI), `capture` exits `未找到 playwright` and the case failed on its expected-message assertion — the same class of host-CI reddening fixed in 1.7.1, in a different environment shape. Both cases now skip with a stated reason when Playwright is missing.
+- **Evidence correction to 1.7.1.** Its "clean sandbox: 0 fail, 22 skipped" was measured in a Windows sandbox where junction creation itself failed (the shell was unreachable on the stripped PATH), which masked exactly the case above. Corrected measurements, after the fixes, in two isolated tool-less sandbox shapes — symlinks creatable and not creatable — agree: **237 tests, 0 fail, 23 skipped**, every skip naming its reason.
+- Tests **236 → 237 in fourteen files**, all green with 0 skips in the development tree and in both published trees.
+
+## 1.7.1 — 2026-09-18
+
+**Fourth pass over the *published* PR: three claims that were not true of the published tree, and the one test that could redden the host CI**
+
+- **The 1.7.0 CI rewrite had never been pushed.** The PR branch still carried the old scoped workflow — explicit file enumeration (so `cover-transition`, `review-round2`, `review-round3` and `subtitles-invalidate` never ran in any CI), no `permissions:` block, floating `@v4` action tags — while the 1.7.0 entry above already described the corrected form. The corrected workflow (shell glob `tests/*.test.mjs`, `permissions: contents: read`, full-SHA pins with version comments) is what the branch carries now.
+- **The host monorepo's own CI would have gone red.** `npm run check` = validate + a root-level `node --test`, which auto-discovers this plugin's tests — on runners that have neither ffmpeg nor Playwright. Exactly one case lacked the tool guard every other rendering case has (`asr.mjs` exits with `找不到 ffmpeg` at startup, before any request is made): the ASR-503 case now skips with its stated reason like the rest. Verified in a clean sandbox with neither tool resolvable: **236 tests, 0 fail, 22 skipped with stated reasons** — and the pipeline scripts themselves still fail loudly rather than pretend to run.
+- **The 1.7.0 README sentence "the rendering suites fail rather than skip" was itself wrong** — it rested on an observation made where `ffmpeg-static` was silently discoverable through a workspace-level `node_modules`. Both READMEs now state what actually happens: tests that need a tool skip with their stated reason; the scripts exit with `找不到 ffmpeg` instead of running half a pipeline.
+- **Plan B2's third manifest check is implemented.** `build-video` now also compares the subtitle manifest's `framesCover` with the current frame sequence's real duration (it previously checked only the slide duration and the clause indices) — a re-captured animation window no longer splices subtitle stills at stale offsets. New pixel-level case in `subtitles-invalidate`: a stale manifest is ignored with a named warning (measured luma difference 45 against the stale still — it is not in the picture), an aligned one splices it in (difference 0).
+- Smaller findings from the same pass: `VALUE_FLAGS` no longer lists `--speed` / `--voice` (nothing parses them, and a stray `--speed` silently swallowed the next positional), the six per-script `flag()` helpers collapsed into the one `flagValue` in `tools.mjs`, and the internal `KIT_PROJECT_DIR` variable is now documented in both READMEs.
+- Tests **235 → 236 in fourteen files**, all green with 0 skips in the development tree and in both published trees.
+
+## 1.7.0 — 2026-09-18
+
+**Third review round: the CSS-propagation rewrite, stale-CSS blocking, and six silent-failure paths closed**
+
+**CSS propagation is now a single managed region (`tokens`), and staleness blocks rendering.**
+The skill writes its generated CSS into the project's `slides/tokens.css`. The old mechanism delimited only the three toolkit blocks (no-fx / charts / tables) and decided "up to date" by looking at *the one block it recognised*, which left four paths that reported `ok` while the project still rendered old rules: ① the same toolkit present **twice** (the later copy wins the cascade and was never examined); ② a stale copy **outside** the managed blocks (written by an older, append-style upgrade, or pasted by hand); ③ a **mangled closing delimiter** (the regex no longer recognises the block, so the old existence probe reported `ok`, and the upgrade emitted a *second* opening marker); ④ drift in the **generated body itself** (theme tokens, `--fs-*` scale, fx keyframes/utilities, `.fx-stagger`) was not managed at all. Now the whole generated body is wrapped in one `tokens` region with the three toolkit blocks nested inside, and the status vocabulary is explicit: `ok` / `stale` / `duplicate` / `broken` / `legacy-outside` / `missing`. `--upgrade-css` replaces the region in place, merges duplicates, removes orphan markers, and cleans leftover full copies of a toolkit outside the region — **rule by rule**, so a single project override sitting next to a leftover copy survives (only the verbatim module rules are removed), with the original text in `tokens.css.bak`. A legacy file that is still the old "bare text" format but byte-identical to the current version reports `ok` and is wrapped in place on upgrade rather than duplicated. An adversarial re-review of the new mechanism found **two more silent paths inside it**: a current region **plus a stale copy of the generated body outside it** still reported `ok` (the outside scan only recognised toolkit selectors, not body-shaped rules — it now classifies those too and reports `legacy-outside`), and the `ok` note for a genuine project override did not say **where** the override sits, which is what decides whether it wins: before the region it loses the cascade, after it wins. The note now names the position and which of the two applies. **`check-slides`, `capture` and `build-video` now refuse to run while the region is stale** (exit 1 with the issue list and the `--upgrade-css` command). The gate at the two rendering entry points has an explicit escape — `--allow-stale-css` downgrades it to a warning — while `check-slides` is a static gate and stays blocking by design (it is the check you run *before* rendering; `SKILL.md` records that it has no such flag). The play page's fallback injection states plainly that a correct preview page does **not** mean the rendered video is current. Measured along the way: an ordering bug of my own — the leftover-copy cleanup ran *before* the region step — deleted the toolkit text out of a bare-text body, so the in-place match missed and the upgrade both duplicated the body and left the old rules **winning the cascade**; the regression test asserts the region lands *after* legacy content (later declaration wins) and that the render actually changes, pixel-level (a stale project's frame is measurably darker, and after the upgrade it returns to the skill's current picture within 0.05 luma).
+
+**A two-axis review of this very change set** (standards vs. spec, run against the published 1.6.0 tree) found a further batch, all closed here: `build-video` discovered its tools **before** validating the configuration, so on a machine without ffmpeg a bad `width`/`fps`/`bgm` was reported as "ffmpeg not found" (exit 2) and the real error was swallowed — tool discovery now happens after every config check; `requireFreshCss` was the one entry point that read `tokens.css` **without** the 2 MB scan cap the other three enforce (it now refuses an oversized file on the same rule); `init-project` contained only its `--upgrade-css` path — the **skeleton** writes (8 directories + 5 files) were still raw `path.join`, so a pre-existing `assets/` or `slides/` link pointing outside the project received the generated files (now contained, with the project root created first so the jail has a real root to compare against); `fetch-official-images` contained each downloaded **file** but created its output **directory** unchecked; `readTransition` now names the source it rejected (`--transition` vs `script.transition`); two `generateTokensCss()` re-assemblies collapsed into one shared `expectedTokens()` used by `check-slides` and the play page; and the dead `needsUpgrade` export was removed. Doc-vs-code drift fixed in the same pass: the READMEs still promised "three rev-stamped blocks … rules outside the blocks are untouched" (the pre-1.7.0 contract, and the opposite of what the leftover cleanup does), the README module list named six of eight modules and kept the old mechanism's name, the `init-project` and `check-slides` headers still described their pre-1.7.0 behaviour (`check-slides`' staleness item is error level, and it deliberately has no `--allow-stale-css`), `capture`'s usage line said `--dsf 1|2` and omitted two flags, and the README's "where those are missing they skip with a stated reason" was **false** for the rendering suites — without ffmpeg they fail, so the sentence now says so (only the cases with a declared reason skip).
+
+**The plugin validation entry point no longer validates a stale copy.** The host's `npm run check` scans `_official-plugins/plugins/**`, so validating while the staged copy was one release behind produced a **green result that proved nothing** — which is exactly how the 1024-character `description` limit (`validation.mjs:82`) was missed twice. New `publish/validate-plugin.mjs`: stages the plugin tree into the host checkout, **fingerprints every file** (sha256) and refuses to continue if the staged copy differs from the source tree, then runs the upstream validator and prints its exit code. The skill's `description` is now 977 characters, with the symptom list extracted to `references/symptoms.md` and a pointer to it from `SKILL.md`.
+
+**Second class of output escape closed (a directory inside the project can itself be a symlink).** `tools.mjs`'s path jail walks ancestors with `lstat` now (a **dangling** symlink is detected instead of being treated as "does not exist" and skipped) and **fails closed** when `realpath` cannot be resolved (it used to return the absolute path and let the write through). Output containment (`safeOut`) is applied to every remaining write site — `plan-timings`, `check-timing`, `build-video` (including leaf files `concat.txt`, `audio-timeline.wav`, `audio-mix.wav`, `final.mp4`, `asr/checklist.md`), `asr`, `capture`, `fetch-official-images`, `init-project` — with a unit test asserting "a junction parent pointing outside the project is refused".
+
+**Subtitle stills are invalidated together with the subtitles.** `capture` deletes `build/substills//` and its manifest before writing new ones, and `build-video` validates the manifest against the current slide (`duration`, `stills[].k < clauses.length`, `framesCover`) and ignores it with a named warning if it does not match — otherwise deleting clauses (or `--no-subs`) and rebuilding spliced the *old* subtitles back into the video.
+
+**SSRF policy completed.** Blocked ranges now cover `0/8`, `10/8`, `100.64/10`, `127/8`, `169.254/16`, `172.16/12`, `192.0.0/24`, `192.0.2/24`, `192.168/16`, `198.18/15`, `198.51.100/24`, `203.0.113/24`, `224/4`, `240/4`, plus `fec0::/10` and `2001:db8::/32` on the IPv6 side; a trailing-dot FQDN (`localhost.`) is normalised before the check, embedded IPv4 addresses inside `::ffff:` **NAT64 `64:ff9b::/96`** and **6to4 `2002::/16`** are decoded recursively, and a dead `localhost` branch was removed.
+
+**Seven functional defects (each one a documented way to get a wrong artefact or a misleading exit code).**
+- `check-timing --calibrate` could write back a **mixture** of exact-punctuation-aligned and nearest-neighbour boundaries while reporting `method: "nearest"` (the nearest pass reused the array the failed exact pass had already partly filled). The two passes now allocate separately, `nearest` is only reported when **every** boundary was measured, `sparse` is display-only, and a run that calibrated nothing no longer rewrites the file at all.
+- Positional-argument parsing: `init-project.mjs --topic 主题 ` treated the topic's **value** as the project directory and silently created a skeleton named after the topic in the current directory. Every script that takes a positional project directory now resolves it through `tools.positionalDir`, which skips the values of value-taking flags and shares one `VALUE_FLAGS` list with the rest of the module (`flagValue` is exported for callers that also need a flag's value, with the same list behind it).
+- `fetch-official-images`: `--json` was listed as a value-taking flag, so `--json ` swallowed the URL and reported a usage error (position-independent now); `--min` accepts `WxH` (`800x600`, `x600`) instead of comparing width only; and a `--get` run where **every** download failed exits 1 instead of 0.
+- Argument validation at the entry point: `capture --mode` accepts only `still|motion`, `--dsf` only the integers 1–4, `grab-frames --at` only values in `(0,1]` (a typo used to reach Playwright as `deviceScaleFactor: NaN` or be silently clamped to the first frame); `grab-frames` exits 1 when it captured zero frames, and a missing ffmpeg is exit 2 (the convention `tools.requireTool` already had).
+- The subtitle-band gate matched `padding-bottom`/`margin-bottom` (no word boundary) and flagged elements that are not positioned at all. It now requires `position: absolute|fixed` in the same rule, handles both `
+
+
+